diff --git a/.mole-cli-version b/.mole-cli-version index 034552a..2aeaa11 100644 --- a/.mole-cli-version +++ b/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.35.0 diff --git a/MoleUI.xcodeproj/project.pbxproj b/MoleUI.xcodeproj/project.pbxproj index 9d05cb6..01b5de5 100644 --- a/MoleUI.xcodeproj/project.pbxproj +++ b/MoleUI.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.1.5; PRODUCT_BUNDLE_IDENTIFIER = com.qinfuyao.MoleUI; PRODUCT_NAME = "Mole UI"; SDKROOT = macosx; @@ -470,7 +470,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.1.4; + MARKETING_VERSION = 0.1.5; PRODUCT_BUNDLE_IDENTIFIER = com.qinfuyao.MoleUI; PRODUCT_NAME = "Mole UI"; SDKROOT = macosx; diff --git a/MoleUI/.mole-cli-version b/MoleUI/.mole-cli-version index 034552a..2aeaa11 100644 --- a/MoleUI/.mole-cli-version +++ b/MoleUI/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.35.0 diff --git a/Resources/mole/.githooks/pre-commit b/Resources/mole/.githooks/pre-commit new file mode 100755 index 0000000..f1a483e --- /dev/null +++ b/Resources/mole/.githooks/pre-commit @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Pre-commit hook: mirrors GitHub CI checks locally. +# Installed via: git config core.hooksPath .githooks +# +# Runs on every `git commit`. Catches format/lint/test failures before push. + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +_ok() { echo -e "${GREEN}✓${NC} $1"; } +_fail() { echo -e "${RED}✗${NC} $1"; } +_info() { echo -e "${YELLOW}→${NC} $1"; } + +echo "" +_info "Running pre-commit checks (mirrors GitHub CI)..." +echo "" + +# Only check staged shell/Go files to keep commits fast. +STAGED=$(git diff --cached --name-only --diff-filter=ACM) +HAS_SHELL=$(echo "$STAGED" | grep -E '\.sh$|^mole$|^bin/' || true) +HAS_GO=$(echo "$STAGED" | grep -E '\.go$' || true) + +FAILED=0 + +# --- 1. Shell syntax check (fast, no tool required) --- +if [[ -n "$HAS_SHELL" ]]; then + _info "Shell syntax check..." + while IFS= read -r f; do + [[ -f "$f" ]] || continue + if ! bash -n "$f" 2>&1; then + _fail "Syntax error: $f" + FAILED=1 + fi + done <<< "$HAS_SHELL" + [[ $FAILED -eq 0 ]] && _ok "Shell syntax clean" +fi + +# --- 2. shfmt format check (if installed) --- +if [[ -n "$HAS_SHELL" ]] && command -v shfmt > /dev/null 2>&1; then + _info "shfmt format check..." + UNFORMATTED="" + while IFS= read -r f; do + [[ -f "$f" ]] || continue + if ! shfmt -i 4 -ci -sr -d "$f" > /dev/null 2>&1; then + UNFORMATTED="$UNFORMATTED $f" + fi + done <<< "$HAS_SHELL" + if [[ -n "$UNFORMATTED" ]]; then + _fail "shfmt: unformatted files:$UNFORMATTED" + _info "Fix with: ./scripts/check.sh --format" + FAILED=1 + else + _ok "shfmt format clean" + fi +fi + +# --- 3. shellcheck (if installed) --- +if [[ -n "$HAS_SHELL" ]] && command -v shellcheck > /dev/null 2>&1; then + _info "shellcheck..." + while IFS= read -r f; do + [[ -f "$f" ]] || continue + if ! shellcheck "$f" 2>&1; then + FAILED=1 + fi + done <<< "$HAS_SHELL" + [[ $FAILED -eq 0 ]] && _ok "shellcheck clean" +fi + +# --- 4. Go vet (if staged Go files) --- +if [[ -n "$HAS_GO" ]] && command -v go > /dev/null 2>&1; then + _info "go vet..." + if go vet ./cmd/... 2>&1; then + _ok "go vet clean" + else + _fail "go vet failed" + FAILED=1 + fi +fi + +echo "" +if [[ $FAILED -ne 0 ]]; then + _fail "Pre-commit checks failed. Fix the issues above before committing." + _info "Run './scripts/check.sh --format' to auto-fix formatting." + echo "" + exit 1 +fi + +_ok "All pre-commit checks passed." +echo "" diff --git a/Resources/mole/.github/CODEOWNERS b/Resources/mole/.github/CODEOWNERS new file mode 100644 index 0000000..74d9b7c --- /dev/null +++ b/Resources/mole/.github/CODEOWNERS @@ -0,0 +1 @@ +* @tw93 diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md b/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md index 6e0779c..ca2ca4d 100644 --- a/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md +++ b/Resources/mole/.github/ISSUE_TEMPLATE/bug_report.md @@ -10,6 +10,8 @@ assignees: '' A clear and concise description of what the bug is. We suggest using English for better global understanding. +If you believe the issue may allow unsafe deletion, path validation bypass, privilege boundary bypass, or release/install integrity issues, do not file a public bug report. Report it privately using the contact details in `SECURITY.md`. + ## Steps to reproduce 1. Run command: `mo ...` diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/config.yml b/Resources/mole/.github/ISSUE_TEMPLATE/config.yml index 8d9ce89..ad78d2f 100644 --- a/Resources/mole/.github/ISSUE_TEMPLATE/config.yml +++ b/Resources/mole/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Private Security Report + url: mailto:hitw93@gmail.com?subject=Mole%20security%20report + about: Report a suspected vulnerability privately instead of opening a public issue - name: Telegram Community url: https://t.me/+GclQS9ZnxyI2ODQ1 about: Join our Telegram group for questions and discussions diff --git a/Resources/mole/.github/dependabot.yml b/Resources/mole/.github/dependabot.yml index 603f653..5109cab 100644 --- a/Resources/mole/.github/dependabot.yml +++ b/Resources/mole/.github/dependabot.yml @@ -4,8 +4,18 @@ updates: directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + reviewers: + - "tw93" + open-pull-requests-limit: 10 - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" + labels: + - "dependencies" + reviewers: + - "tw93" + open-pull-requests-limit: 10 diff --git a/Resources/mole/.github/pull_request_template.md b/Resources/mole/.github/pull_request_template.md new file mode 100644 index 0000000..b383243 --- /dev/null +++ b/Resources/mole/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +- Describe the change. + +## Safety Review + +- Does this change affect cleanup, uninstall, optimize, installer, remove, analyze delete, update, or install behavior? +- Does this change affect path validation, protected directories, symlink handling, sudo boundaries, or release/install integrity? +- If yes, describe the new boundary or risk change clearly. + +## Tests + +- List the automated tests you ran. +- List any manual checks for high-risk paths or destructive flows. + +## Safety-related changes + +- None. diff --git a/Resources/mole/.github/workflows/check.yml b/Resources/mole/.github/workflows/check.yml index 6f7b0e0..be211b4 100644 --- a/Resources/mole/.github/workflows/check.yml +++ b/Resources/mole/.github/workflows/check.yml @@ -21,7 +21,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Cache Homebrew - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 with: path: | ~/Library/Caches/Homebrew @@ -36,9 +36,9 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 with: - go-version: '1.24.6' + go-version-file: go.mod - name: Install goimports run: go install golang.org/x/tools/cmd/goimports@latest @@ -66,6 +66,8 @@ jobs: name: Check runs-on: macos-latest needs: format + permissions: + contents: read steps: - name: Checkout @@ -74,7 +76,7 @@ jobs: ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} - name: Cache Homebrew - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 with: path: | ~/Library/Caches/Homebrew @@ -89,9 +91,9 @@ jobs: run: brew install shfmt shellcheck golangci-lint - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 with: - go-version: '1.24.6' + go-version-file: go.mod - name: Run check script run: ./scripts/check.sh --no-format diff --git a/Resources/mole/.github/workflows/codeql.yml b/Resources/mole/.github/workflows/codeql.yml new file mode 100644 index 0000000..51e3d2a --- /dev/null +++ b/Resources/mole/.github/workflows/codeql.yml @@ -0,0 +1,52 @@ +name: CodeQL + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: manual + - language: actions + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + + - name: Set up Go + if: matrix.language == 'go' + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 + with: + go-version-file: go.mod + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + + - name: Build for CodeQL + if: matrix.build-mode == 'manual' + run: make build + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/Resources/mole/.github/workflows/release.yml b/Resources/mole/.github/workflows/release.yml index dc22b27..c2b93f9 100644 --- a/Resources/mole/.github/workflows/release.yml +++ b/Resources/mole/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - 'V*' permissions: - contents: write + contents: read jobs: build: @@ -26,9 +26,9 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Build Binaries run: | @@ -48,7 +48,7 @@ jobs: fi - name: Upload artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.artifact_name }} path: bin/*-darwin-* @@ -58,9 +58,13 @@ jobs: name: Publish Release needs: build runs-on: ubuntu-latest + permissions: + contents: write + attestations: write + id-token: write steps: - name: Download all artifacts - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: bin pattern: binaries-* @@ -69,16 +73,32 @@ jobs: - name: Display structure of downloaded files run: ls -R bin/ + - name: Generate release checksums + run: | + cd bin + mapfile -t release_files < <(find . -maxdepth 1 -type f -printf '%P\n' | sort) + if [[ ${#release_files[@]} -eq 0 ]]; then + echo "No release assets found" + exit 1 + fi + sha256sum "${release_files[@]}" > SHA256SUMS + cat SHA256SUMS + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v4 + with: + subject-path: | + bin/analyze-darwin-* + bin/status-darwin-* + bin/binaries-darwin-*.tar.gz + bin/SHA256SUMS + - name: Create Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2 if: startsWith(github.ref, 'refs/tags/') with: name: ${{ github.ref_name }} files: bin/* - body: | - Release assets are ready. - - Final curated release notes should be applied with `gh release edit` after workflow verification. generate_release_notes: false draft: false prerelease: false @@ -87,6 +107,9 @@ jobs: runs-on: ubuntu-latest needs: release steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - name: Extract version from tag id: tag_version run: | @@ -97,21 +120,46 @@ jobs: echo "Releasing version: $VERSION (tag: $TAG)" - name: Update Homebrew formula (Personal Tap) - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6 - with: - formula-name: mole - formula-path: Formula/mole.rb - homebrew-tap: tw93/homebrew-tap - tag-name: ${{ steps.tag_version.outputs.tag }} - commit-message: | - mole ${{ steps.tag_version.outputs.version }} - - Automated release via GitHub Actions env: - COMMITTER_TOKEN: ${{ secrets.PAT_TOKEN }} + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + TAG: ${{ steps.tag_version.outputs.tag }} + VERSION: ${{ steps.tag_version.outputs.version }} + run: | + set -euo pipefail + + curl -fsSL -o /tmp/SHA256SUMS "https://github.com/tw93/Mole/releases/download/${TAG}/SHA256SUMS" + ARM_SHA=$(awk '$2 == "binaries-darwin-arm64.tar.gz" { print $1 }' /tmp/SHA256SUMS) + AMD_SHA=$(awk '$2 == "binaries-darwin-amd64.tar.gz" { print $1 }' /tmp/SHA256SUMS) + SOURCE_SHA=$(curl -fsSL "https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" | sha256sum | awk '{print $1}') + + if [[ -z "$ARM_SHA" || -z "$AMD_SHA" || -z "$SOURCE_SHA" ]]; then + echo "Failed to resolve release checksums" + exit 1 + fi + + git clone "https://x-access-token:${PAT_TOKEN}@github.com/tw93/homebrew-tap.git" /tmp/homebrew-tap + ./scripts/update_homebrew_tap_formula.sh \ + --formula /tmp/homebrew-tap/Formula/mole.rb \ + --tag "${TAG}" \ + --source-sha "${SOURCE_SHA}" \ + --arm-sha "${ARM_SHA}" \ + --amd-sha "${AMD_SHA}" + + cd /tmp/homebrew-tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git diff --quiet -- Formula/mole.rb; then + echo "No Homebrew formula changes to push" + exit 0 + fi + + git add Formula/mole.rb + git commit -m "mole ${VERSION}" -m "Automated release via GitHub Actions" + git push origin HEAD:main - name: Update Homebrew formula (Official Core) - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6 + uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 # v4.1 with: formula-name: mole homebrew-tap: Homebrew/homebrew-core diff --git a/Resources/mole/.github/workflows/test.yml b/Resources/mole/.github/workflows/test.yml index 4151314..6584d32 100644 --- a/Resources/mole/.github/workflows/test.yml +++ b/Resources/mole/.github/workflows/test.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main, dev] +permissions: + contents: read + jobs: tests: name: Unit & Integration Tests @@ -14,12 +17,12 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Install tools - run: brew install bats-core shellcheck + run: brew install bats-core shellcheck coreutils - name: Set up Go - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 with: - go-version: "1.24.6" + go-version-file: go.mod - name: Run test script env: @@ -52,10 +55,13 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + - name: Install tools + run: brew install bats-core coreutils + - 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 + if grep -r "rm -rf" --include="*.sh" lib/ | grep -v "safe_remove\|validate_path\|# \|echo "; then echo "✗ Unsafe rm -rf usage found" exit 1 fi @@ -86,3 +92,10 @@ jobs: exit 1 fi echo "✓ No secrets found" + + - name: Run high-risk path regression tests + env: + BATS_FORMATTER: tap + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + run: bats tests/core_safe_functions.bats tests/purge.bats tests/installer.bats diff --git a/Resources/mole/.gitignore b/Resources/mole/.gitignore index 451942f..6313851 100644 --- a/Resources/mole/.gitignore +++ b/Resources/mole/.gitignore @@ -51,6 +51,7 @@ GEMINI.md ANTIGRAVITY.md WARP.md AGENTS.md +journal/ .cursorrules # Go build artifacts (development) @@ -80,3 +81,4 @@ run_tests.ps1 AGENTS.md mole_guidelines.md CLAUDE.md +.claude/settings.local.json diff --git a/Resources/mole/CONTRIBUTING.md b/Resources/mole/CONTRIBUTING.md index cf7ec5f..0bdb017 100644 --- a/Resources/mole/CONTRIBUTING.md +++ b/Resources/mole/CONTRIBUTING.md @@ -8,6 +8,9 @@ brew install shfmt shellcheck bats-core golangci-lint # Install goimports for better Go formatting go install golang.org/x/tools/cmd/goimports@latest + +# Install pre-commit hook (runs format/lint checks on every commit) +git config core.hooksPath .githooks ``` ## Development diff --git a/Resources/mole/CONTRIBUTORS.svg b/Resources/mole/CONTRIBUTORS.svg index 5db214e..e730ee3 100644 --- a/Resources/mole/CONTRIBUTORS.svg +++ b/Resources/mole/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -14,24 +14,24 @@ - + - - - JackPhallen + + + sebastianbreguel - + - - - bhadraagada + + + JackPhallen @@ -90,6 +90,17 @@ + + + + + + + + PremPrakashCodes + + + @@ -100,7 +111,18 @@ Angelk90 - + + + + + + + + + yetval + + + @@ -111,7 +133,7 @@ Sizk - + @@ -122,7 +144,7 @@ rubnogueira - + @@ -133,7 +155,7 @@ biplavbarua - + @@ -144,7 +166,18 @@ bsisduck - + + + + + + + + + MohammedTarigg + + + @@ -155,7 +188,7 @@ spider-yamet - + @@ -166,7 +199,7 @@ jimmystridh - + @@ -177,7 +210,18 @@ fte-jjmartres - + + + + + + + + + carolyn-sun + + + @@ -188,62 +232,62 @@ Else00 - + - + - - - carolyn-sun + + + abcreativ - + - + - - - ndbroadbent + + + yuzeguitarist - + - + - - - MohammedTarigg + + + TomP0 - + - + - - - onurtashan + + + thijsvanhal - + - + - - - ppauel + + + Harsh-Kapoorr - + @@ -254,51 +298,84 @@ shakeelmohamed - + - + - - - Harsh-Kapoorr + + + pranahonk - + - + - - - thijsvanhal + + + philippb - + - + - - - TomP0 + + + ppauel - + - + - - - yuzeguitarist + + + onurtashan - + + + + + + + + + ndbroadbent + + + + + + + + + + + MohammedEsafi + + + + + + + + + + + mshavliuk + + + @@ -309,7 +386,7 @@ bikraj2 - + @@ -320,7 +397,29 @@ bunizao - + + + + + + + + + guangjun-super + + + + + + + + + + + corevibe555 + + + @@ -331,7 +430,18 @@ rans0 - + + + + + + + + + fishwww-ww + + + @@ -342,7 +452,7 @@ frozturk - + @@ -353,7 +463,7 @@ huyixi - + @@ -364,7 +474,18 @@ purofle - + + + + + + + + + sibisai + + + @@ -375,7 +496,18 @@ yamamel - + + + + + + + + + Parsifa1 + + + @@ -386,7 +518,7 @@ NanmiCoder - + @@ -397,7 +529,29 @@ KoukeNeko - + + + + + + + + + rafay99-epic + + + + + + + + + + + AlexanderAverin + + + @@ -408,7 +562,29 @@ andmev - + + + + + + + + + MASNathan + + + + + + + + + + + aronprins + + + @@ -419,7 +595,29 @@ uluumbch - + + + + + + + + + bevanjkay + + + + + + + + + + + byronwang2005 + + + @@ -430,7 +628,7 @@ ClathW - + @@ -441,7 +639,7 @@ Copper-Eye - + @@ -452,7 +650,18 @@ DimitarNestorov - + + + + + + + + + FelixLyfe + + + @@ -463,7 +672,18 @@ gokulp01 - + + + + + + + + + HaraldNordgren + + + @@ -474,7 +694,7 @@ Hensell - + @@ -485,18 +705,29 @@ jalen0x - + + + + + + + + + jason-costello + + + - + kowyo - + @@ -507,7 +738,7 @@ kwakubiney - + @@ -518,7 +749,7 @@ LmanTW - + @@ -529,7 +760,7 @@ injuxtice - + @@ -540,7 +771,7 @@ khipu-luke - + @@ -551,7 +782,18 @@ mariovtor - + + + + + + + + + degouville + + + @@ -562,7 +804,7 @@ anonymort - + @@ -573,7 +815,7 @@ Schlauer-Hax - + @@ -584,7 +826,7 @@ mickyyy68 - + @@ -595,15 +837,4 @@ EastSun5566 - - - - - - - - - MohammedEsafi - - \ No newline at end of file diff --git a/Resources/mole/README.md b/Resources/mole/README.md index 2c7ad76..49f4936 100644 --- a/Resources/mole/README.md +++ b/Resources/mole/README.md @@ -13,42 +13,42 @@

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

## Features - **All-in-one toolkit**: Combines CleanMyMac, AppCleaner, DaisyDisk, and iStat Menus in a **single binary** -- **Deep cleaning**: Removes caches, logs, and browser leftovers to **reclaim gigabytes of space** +- **Deep cleaning**: Removes caches, logs, browser leftovers, and orphaned app data to **reclaim gigabytes of space** - **Smart uninstaller**: Removes apps plus launch agents, preferences, and **hidden remnants** - **Disk insights**: Visualizes usage, finds large files, **rebuilds caches**, and refreshes system services - **Live monitoring**: Shows real-time CPU, GPU, memory, disk, and network stats ## Quick Start -**Install via Homebrew:** +**Install via Homebrew** ```bash brew install mole ``` -**Or via script:** +**Or via script** ```bash # Optional args: -s latest for main branch code, -s 1.17.0 for specific version curl -fsSL https://raw.githubusercontent.com/tw93/mole/main/install.sh | bash ``` -**Windows:** Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters. +> Note: Mole is built for macOS. An experimental Windows version is available in the [windows branch](https://github.com/tw93/Mole/tree/windows) for early adopters. -**Run:** +**Run** ```bash mo # Interactive menu -mo clean # Deep cleanup -mo uninstall # Remove apps + leftovers +mo clean # Deep cleanup + already-uninstalled app leftovers +mo uninstall # Remove installed apps + their leftovers mo optimize # Refresh caches & services -mo analyze # Visual disk explorer +mo analyze # Visual disk explorer (or 'mo analyse') mo status # Live system health dashboard mo purge # Clean project build artifacts mo installer # Find and remove installer files @@ -60,13 +60,16 @@ mo update --nightly # Update to latest unreleased main build, script in mo remove # Remove Mole from system mo --help # Show help mo --version # Show installed version +``` + +**Preview safely** -# Safe preview before applying changes +```bash mo clean --dry-run mo uninstall --dry-run mo purge --dry-run -# --dry-run also works with: optimize, installer, remove, completion, touchid enable +# Also works with: optimize, installer, remove, completion, touchid enable mo clean --dry-run --debug # Preview + detailed logs mo optimize --whitelist # Manage protected optimization rules mo clean --whitelist # Manage protected caches @@ -74,10 +77,21 @@ mo purge --paths # Configure project scan directories mo analyze /Volumes # Analyze external drives only ``` +## Security & Safety Design + +Mole is a local system maintenance tool, and some commands can perform destructive local operations. + +Mole uses safety-first defaults: path validation, protected-directory rules, conservative cleanup boundaries, and explicit confirmation for higher-risk actions. When risk or uncertainty is high, Mole skips, refuses, or requires stronger confirmation rather than broadening deletion scope. + +`mo analyze` is safer for ad hoc cleanup because it moves files to Trash through Finder instead of deleting them directly. + +Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for reporting guidance, safety boundaries, and current limitations. + ## Tips - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. -- Safety and logs: Deletions are permanent. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. See [Security Audit](SECURITY_AUDIT.md). +- Safety and logs: `clean`, `uninstall`, `purge`, `installer`, and `remove` are destructive. Review with `--dry-run` first, and add `--debug` when needed. File operations are logged to `~/Library/Logs/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. Review [SECURITY.md](SECURITY.md) and [SECURITY_AUDIT.md](SECURITY_AUDIT.md). +- App leftovers: use `mo clean` when the app is already uninstalled, and `mo uninstall` when the app is still installed. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. ## Features in Detail @@ -122,6 +136,8 @@ Uninstalling: Photoshop 2024 - Logs, WebKit storage, Cookies - Extensions, Plugins, Launch daemons +Note: On macOS 15 and later, Local Network permission entries can outlive app removal. Mole warns when an uninstalled app declares Local Network usage, but it does not auto-reset `/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist` because that reset is global and requires Recovery mode. + ==================================================================== Space freed: 12.8GB ==================================================================== @@ -150,7 +166,7 @@ Use `mo optimize --whitelist` to exclude specific optimizations. ### Disk Space Analyzer -By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path. +> Note: By default, Mole skips external drives under `/Volumes` for faster startup. To inspect them, run `mo analyze /Volumes` or a specific mount path. ```bash $ mo analyze @@ -197,9 +213,51 @@ Health score is based on CPU, memory, disk, temperature, and I/O load, with colo Shortcuts: In `mo status`, press `k` to toggle the cat and save the preference, and `q` to quit. +When enabled, `mo status` shows a read-only alert banner for processes that stay above the configured CPU threshold for a sustained window. Use `--proc-cpu-threshold`, `--proc-cpu-window`, or `--proc-cpu-alerts=false` to tune or disable it. + +#### Machine-Readable Output + +Both `mo analyze` and `mo status` support a `--json` flag for scripting and automation. + +`mo status` also auto-detects when its output is piped (not a terminal) and switches to JSON automatically. + +```bash +# Disk analysis as JSON +$ mo analyze --json ~/Documents +{ + "path": "/Users/you/Documents", + "overview": false, + "entries": [ + { "name": "Library", "path": "...", "size": 80939438080, "is_dir": true }, + ... + ], + "large_files": [ + { "name": "backup.zip", "path": "...", "size": 8796093022 } + ], + "total_size": 168393441280, + "total_files": 42187 +} + +# System status as JSON +$ mo status --json +{ + "host": "MacBook-Pro", + "health_score": 92, + "cpu": { "usage": 45.2, "logical_cpu": 8, ... }, + "memory": { "total": 25769803776, "used": 15049334784, "used_percent": 58.4 }, + "disks": [ ... ], + "uptime": "3d 12h 45m", + ... +} + +# Auto-detected JSON when piped +$ mo status | jq '.health_score' +92 +``` + ### Project Artifact Purge -Clean old build artifacts such as `node_modules`, `target`, `build`, and `dist` to free up disk space. +Clean old build artifacts such as `node_modules`, `target`, `.build`, `build`, and `dist` to free up disk space. ```bash mo purge @@ -216,10 +274,10 @@ Select Categories to Clean - 18.5GB (8 selected) ● backend-service 2.5GB | node_modules ``` -> We recommend installing `fd` on macOS. +> Note: We recommend installing `fd` on macOS. > `brew install fd` -> **Use with caution:** This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default. +> Safety: This permanently deletes selected artifacts. Review carefully before confirming. Projects newer than 7 days are marked and unselected by default.
Custom Scan Paths @@ -291,15 +349,15 @@ Thanks to everyone who helped build Mole. Go follow them. ❤️

Real feedback from users who shared Mole on X. -Community feedback on Mole +Community feedback on Mole ## Support -- If Mole helped you, star the repo or [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends. -- Got ideas or bugs? Read the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR. -- Like Mole? Buy Tw93 a Coke to support the project. 🥤 Supporters are below. +- If Mole helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends or give it a star. +- Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. +- I have two cats, TangYuan and Coke. If you think Mole delights your life, you can feed them canned food 🥩. - + ## License diff --git a/Resources/mole/SECURITY.md b/Resources/mole/SECURITY.md new file mode 100644 index 0000000..7a38830 --- /dev/null +++ b/Resources/mole/SECURITY.md @@ -0,0 +1,76 @@ +# Security Policy + +Mole is a local system maintenance tool. It includes high-risk operations such as cleanup, uninstall, optimization, and artifact removal. We treat safety boundaries, deletion logic, and release integrity as security-sensitive areas. + +## Reporting a Vulnerability + +Please report suspected security issues privately. + +- Email: `hitw93@gmail.com` +- Subject line: `Mole security report` + +Do not open a public GitHub issue for an unpatched vulnerability. + +If GitHub Security Advisories private reporting is enabled for the repository, you may use that channel instead of email. + +Include as much of the following as possible: + +- Mole version and install method +- macOS version +- Exact command or workflow involved +- Reproduction steps or proof of concept +- Whether the issue involves deletion boundaries, symlinks, sudo, path validation, or release/install integrity + +## Response Expectations + +- We aim to acknowledge new reports within 7 calendar days. +- We aim to provide a status update within 30 days if a fix or mitigation is not yet available. +- We will coordinate disclosure after a fix, mitigation, or clear user guidance is ready. + +Response times are best-effort for a maintainer-led open source project, but security reports are prioritized over normal bug reports. + +## Supported Versions + +Security fixes are only guaranteed for: + +- The latest published release +- The current `main` branch + +Older releases may not receive security fixes. Users running high-risk commands should stay current. + +## What We Consider a Security Issue + +Examples of security-relevant issues include: + +- Path validation bypasses +- Deletion outside intended cleanup boundaries +- Unsafe handling of symlinks or path traversal +- Unexpected privilege escalation or unsafe sudo behavior +- Sensitive data removal that bypasses documented protections +- Release, installation, update, or checksum integrity issues +- Vulnerabilities in logic that can cause unintended destructive behavior + +## What Usually Does Not Qualify + +The following are usually normal bugs, feature requests, or documentation issues rather than security issues: + +- Cleanup misses that leave recoverable junk behind +- False negatives where Mole refuses to clean something +- Cosmetic UI problems +- Requests for broader or more aggressive cleanup behavior +- Compatibility issues without a plausible security impact + +If you are unsure whether something is security-relevant, report it privately first. + +## Security-Focused Areas in Mole + +The project pays particular attention to: + +- Destructive command boundaries +- Path validation and protected-directory rules +- Sudo and privilege boundaries +- Symlink and path traversal handling +- Sensitive data exclusions +- Packaging, release artifacts, checksums, and update/install flows + +For the current technical design and known limitations, see [SECURITY_AUDIT.md](SECURITY_AUDIT.md). diff --git a/Resources/mole/SECURITY_AUDIT.md b/Resources/mole/SECURITY_AUDIT.md index 0a6e471..674f9cf 100644 --- a/Resources/mole/SECURITY_AUDIT.md +++ b/Resources/mole/SECURITY_AUDIT.md @@ -1,18 +1,60 @@ -# Mole Security Reference +# Mole Security Audit -Version 1.30.0 | 2026-03-08 +This document describes the security-relevant behavior of the current `main` branch. It is intended as a public description of Mole's safety boundaries, destructive-operation controls, release integrity signals, and known limitations. -This document describes the security-relevant behavior of the current codebase on `main`. +## Executive Summary -## Path Validation +Mole is a local system maintenance tool. Its main risk surface is not remote code execution; it is unintended local damage caused by cleanup, uninstall, optimize, purge, installer cleanup, or other destructive operations. -All destructive file operations go through `lib/core/file_ops.sh`. +The project is designed around safety-first defaults: -- `validate_path_for_deletion()` rejects empty paths, relative paths, traversal segments such as `/../`, and control characters. -- Security-sensitive cleanup paths do not use raw `find ... -delete`. -- Removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()`. +- destructive paths are validated before deletion +- critical system roots and sensitive user-data categories are protected +- sudo use is bounded and additional restrictions apply when elevated deletion is required +- symlink handling is conservative +- preview, confirmation, timeout, and operation logging are used to make destructive behavior more visible and auditable -Blocked paths remain protected even with sudo, including: +Mole prioritizes bounded cleanup over aggressive cleanup. When uncertainty exists, the tool should refuse, skip, or require stronger confirmation instead of widening deletion scope. + +The project continues to strengthen: + +- release integrity and public security signals +- targeted regression coverage for high-risk paths +- clearer documentation for privilege boundaries and known limitations + +## Threat Surface + +The highest-risk areas in Mole are: + +- direct file and directory deletion +- recursive cleanup across common user and system cache locations +- uninstall flows that combine app removal with remnant cleanup +- project artifact purge for large dependency/build directories +- elevated cleanup paths that require sudo +- release, install, and update trust signals for distributed artifacts + +`mo analyze` is intentionally lower-risk than cleanup flows: + +- it does not require sudo +- it respects normal user permissions and SIP +- delete actions require explicit confirmation +- deletion routes through Finder Trash behavior rather than direct permanent removal + +## Destructive Operation Boundaries + +All destructive shell file operations are routed through guarded helpers in `lib/core/file_ops.sh`. + +Core controls include: + +- `validate_path_for_deletion()` rejects empty paths +- relative paths are rejected +- path traversal segments such as `..` as a path component are rejected +- paths containing control characters are rejected +- raw `find ... -delete` is avoided for security-sensitive cleanup logic +- removal flows use guarded helpers such as `safe_remove()`, `safe_sudo_remove()`, `safe_find_delete()`, and `safe_sudo_find_delete()` +- incomplete download cleanup skips files currently open (lsof check) and uses quoted glob patterns to prevent word-splitting on filenames that contain spaces + +Blocked paths remain protected even with sudo. Examples include: ```text / @@ -26,7 +68,7 @@ Blocked paths remain protected even with sudo, including: /Library/Extensions ``` -Some subpaths under protected roots are explicitly allowlisted for bounded cache and log cleanup, for example: +Some subpaths under otherwise protected roots are explicitly allowlisted for bounded cleanup where the project intentionally supports cache/log maintenance. Examples include: - `/private/tmp` - `/private/var/tmp` @@ -37,164 +79,220 @@ Some subpaths under protected roots are explicitly allowlisted for bounded cache - `/private/var/db/powerlog` - `/private/var/db/reportmemoryexception` -When running with sudo, symlinked targets are validated before deletion and system-target symlinks are refused. - -## Cleanup Rules +This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns. -### Orphan Detection +## Path Protection Reference -Orphaned app data is handled in `lib/clean/apps.sh`. +### Protected Prefixes (Never Deleted) -- Generic orphaned app data requires both: - - the app is not found by installed-app scanning and fallback checks, and - - the target has been inactive for at least 30 days. -- Claude VM bundles use a stricter app-specific window: - - `~/Library/Application Support/Claude/vm_bundles/claudevm.bundle` must appear orphaned, and - - it must be inactive for at least 7 days before cleanup. -- Sensitive categories such as keychains, password-manager data, and protected app families are excluded from generic orphan cleanup. +```text +/ +/System +/bin +/sbin +/usr +/etc +/var +/private +/Library/Extensions +``` -Installed-app detection is broader than a simple `/Applications` scan and includes: +### Whitelist Exceptions (Allowlisted for Cleanup) -- `/Applications` -- `/System/Applications` -- `~/Applications` -- Homebrew Caskroom locations -- Setapp application paths +Some subpaths under protected roots are explicitly allowlisted: -Spotlight fallback checks are bounded with short timeouts to avoid hangs. +- `/private/tmp` +- `/private/var/tmp` +- `/private/var/log` +- `/private/var/folders` +- `/private/var/db/diagnostics` +- `/private/var/db/DiagnosticPipeline` +- `/private/var/db/powerlog` +- `/private/var/db/reportmemoryexception` -### Uninstall Matching +### Protected Categories -App uninstall behavior is implemented in `lib/uninstall/batch.sh` and related helpers. +In addition to path blocking, these categories are protected: -- LaunchAgent and LaunchDaemon lookups require a valid reverse-DNS bundle identifier. -- Deletion candidates are decoded and validated as absolute paths before removal. -- Homebrew casks are preferentially removed with `brew uninstall --cask --zap`. -- LaunchServices unregister and rebuild steps are skipped safely if `lsregister` is unavailable. +- Keychains, password managers, credentials +- VPN/proxy tools (Shadowsocks, V2Ray, Clash, Tailscale) +- AI tools (Cursor, Claude, ChatGPT, Ollama) +- Browser history and cookies +- Time Machine data (during active backup) +- `com.apple.*` LaunchAgents/LaunchDaemons +- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration +- iCloud-synced `Mobile Documents` -### Developer and Project Cleanup +## Implementation Details -Project artifact cleanup in `lib/clean/project.sh` protects recently modified targets: +All deletion routes pass through `lib/core/file_ops.sh`: -- recently modified project artifacts are treated as recent for 7 days -- protected vendor and build-output heuristics prevent broad accidental deletions -- nested artifacts are filtered to avoid duplicate or parent-child over-deletion +- `validate_path_for_deletion()` - Empty, relative, traversal checks +- `should_protect_path()` - Prefix and pattern matching +- `safe_remove()`, `safe_find_delete()`, `safe_sudo_remove()` - Guarded operations -Developer-cache cleanup preserves toolchains and other high-value state. Examples intentionally left alone include: +See [`journal/2026-03-11-safe-remove-design.md`](journal/2026-03-11-safe-remove-design.md) for design rationale. -- `~/.cargo/bin` -- `~/.rustup` -- `~/.mix/archives` -- `~/.stack/programs` +## Protected Directories and Categories -## Protected Categories +Mole has explicit protected-path and protected-category logic in addition to root-path blocking. Protected or conservatively handled categories include: -- system components such as Control Center, System Settings, TCC, Spotlight, and `/Library/Updates` -- password managers and keychain-related data -- VPN / proxy tools such as Shadowsocks, V2Ray, Clash, and Tailscale +- system components such as Control Center, System Settings, TCC, Spotlight, Finder, and Dock-related state +- keychains, password-manager data, tokens, credentials, and similar sensitive material +- VPN and proxy tools such as Shadowsocks, V2Ray, Clash, and Tailscale - AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama - `~/Library/Messages/Attachments` - browser history and cookies - Time Machine data while backup state is active or ambiguous - `com.apple.*` LaunchAgents and LaunchDaemons +- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration +- iCloud-synced `Mobile Documents` data -## Analyzer +Project purge also uses conservative heuristics: -`mo analyze` is intentionally lower-risk than cleanup flows: +- purge targets must be inside configured project boundaries +- direct-child artifact cleanup is only allowed in single-project mode +- recently modified artifacts are treated as recent for 7 days +- nested artifacts are filtered to avoid parent-child over-deletion +- protected vendor/build-output heuristics block ambiguous directories -- it does not require sudo -- it respects normal user permissions and SIP -- interactive deletion requires an extra confirmation sequence -- deletions route through Trash/Finder behavior rather than direct permanent removal +Developer cleanup also preserves high-value state. Examples intentionally left alone include: -Code lives under `cmd/analyze/*.go`. +- `~/.cargo/bin` +- `~/.rustup` +- `~/.mix/archives` +- `~/.stack/programs` -## Timeouts and Hang Resistance +## Symlink and Path Traversal Handling -`lib/core/timeout.sh` uses this fallback order: +Symlink behavior is intentionally conservative. -1. `gtimeout` / `timeout` -2. a Perl helper with process-group cleanup -3. a shell fallback +- path validation checks symlink targets before deletion +- symlinks pointing at protected system targets are rejected +- `safe_sudo_remove()` refuses to sudo-delete symlinks +- `safe_find_delete()` and `safe_sudo_find_delete()` refuse to scan symlinked base directories +- installer discovery avoids treating symlinked installer files as deletion candidates +- analyzer scanning skips following symlinks to unexpected targets -Current notable timeouts in security-relevant paths: +Path traversal handling is also explicit: -- orphan/Spotlight `mdfind` checks: 2s -- LaunchServices rebuild during uninstall: 10s / 15s bounded steps -- Homebrew uninstall cask flow: 300s default, extended to 600s or 900s for large apps -- Application Support sizing: direct file `stat`, bounded `du` for directories +- non-absolute paths are rejected for destructive helpers +- `..` is rejected when it appears as a path component +- legitimate names containing `..` inside a single path element remain allowed to avoid false positives for real application data +- `mo analyze` delete validates the raw user-supplied path before `filepath.Abs` resolves it, then validates the resolved absolute path a second time, closing a window where traversal segments could survive `Abs` normalization -Additional safety behavior: +## Privilege Escalation and Sudo Boundaries -- `brew_uninstall_cask()` treats exit code `124` as timeout failure and returns failure immediately -- font cache rebuild is skipped while browsers are running -- project-cache discovery and scans use strict timeouts to avoid whole-home stalls +Mole uses sudo for a subset of system-maintenance paths, but elevated behavior is still bounded by validation and protected-path rules. -## User Configuration +Key properties: -Protected paths can be added to `~/.config/mole/whitelist`, one path per line. +- sudo access is explicitly requested instead of assumed +- non-interactive preview remains conservative when sudo is unavailable +- protected roots remain blocked even when sudo is available +- sudo deletion uses the same path validation gate as non-sudo deletion +- sudo cleanup skips or reports denied operations instead of widening scope +- authentication, SIP/MDM, and read-only filesystem failures are classified separately in file-operation results +- sudo credential prompting passes through the system's native PAM prompt rather than a hardcoded string, ensuring correct behavior across locales and PAM configurations -Example: +When sudo is denied or unavailable, Mole prefers skipping privileged cleanup to forcing execution through unsafe fallback behavior. -```bash -/Users/me/important-cache -~/Library/Application Support/MyApp -``` +## Sensitive Data Exclusions -Exact path protection is preferred over pattern-style broad deletion rules. +Mole is not intended to aggressively delete high-value user data. -Use `--dry-run` before destructive operations when validating new cleanup behavior. +Examples of conservative handling include: -## Testing +- sensitive app families are excluded from generic orphan cleanup +- orphaned app data waits for inactivity windows before cleanup +- Claude VM orphan cleanup uses a separate stricter rule +- uninstall file lists are decoded and revalidated before removal +- reverse-DNS bundle ID validation is required before LaunchAgent and LaunchDaemon pattern matching -There is no dedicated `tests/security.bats`. Security-relevant behavior is covered by targeted BATS suites, including: +Installed-app detection is broader than a single `/Applications` scan and includes: -- `tests/clean_core.bats` -- `tests/clean_user_core.bats` -- `tests/clean_dev_caches.bats` -- `tests/clean_system_maintenance.bats` -- `tests/clean_apps.bats` -- `tests/purge.bats` -- `tests/core_safe_functions.bats` -- `tests/optimize.bats` +- `/Applications` +- `/System/Applications` +- `~/Applications` +- Homebrew Caskroom locations +- Setapp application paths -Local verification used for the current branch includes: +This reduces the risk of incorrectly classifying active software as orphaned data. -```bash -bats tests/clean_core.bats tests/clean_user_core.bats tests/clean_dev_caches.bats tests/clean_system_maintenance.bats tests/purge.bats tests/core_safe_functions.bats tests/clean_apps.bats tests/optimize.bats -bash -n lib/core/base.sh lib/clean/apps.sh tests/clean_apps.bats tests/optimize.bats -``` +## Dry-Run, Confirmation, and Audit Logging + +Mole exposes multiple safety controls before and during destructive actions: + +- `--dry-run` previews are available for major destructive commands +- dry-run output deduplicates targets by filesystem identity (device+inode), so aliased paths and symlinks do not appear as separate items +- interactive high-risk flows require explicit confirmation before deletion +- purge marks recent projects conservatively and leaves them unselected by default +- purge configuration is written atomically (mktemp then rename) to prevent partial writes if the process is interrupted +- analyzer delete uses Finder Trash rather than direct permanent removal +- operation logs are written to `~/Library/Logs/mole/operations.log` unless disabled with `MO_NO_OPLOG=1` +- timeouts bound external commands so stalled discovery or uninstall operations do not silently hang the entire flow + +Relevant timeout behavior includes: + +- orphan and Spotlight checks: 2s +- LaunchServices rebuild during uninstall: bounded 10s and 15s steps +- Homebrew uninstall cask flow: 300s by default, extended for large apps when needed +- project scans and sizing operations: bounded to avoid whole-home stalls + +## Release Integrity and Continuous Security Signals + +Mole treats release trust as part of its security posture, not just a packaging detail. -CI additionally runs shell and Go validation on push. +Repository-level signals include: -## Dependencies +- weekly Dependabot updates for Go modules and GitHub Actions +- pre-commit hook that mirrors GitHub CI checks locally (shell syntax, shfmt, shellcheck, Go vet) +- CI checks for unsafe `rm -rf` usage patterns and core protection behavior +- targeted tests for path validation, purge boundaries, symlink behavior, dry-run flows, and destructive helpers +- CodeQL scanning for Go and GitHub Actions workflows, with workflow permission hardening +- curated changelog-driven release notes with a dedicated `Safety-related changes` section +- published SHA-256 checksums for release assets +- GitHub artifact attestations for release assets -Primary Go dependencies are pinned in `go.mod`, including: +These controls do not eliminate all supply-chain risk, but they make release changes easier to review and verify. -- `github.com/charmbracelet/bubbletea v1.3.10` -- `github.com/charmbracelet/lipgloss v1.1.0` -- `github.com/shirou/gopsutil/v4 v4.26.2` -- `github.com/cespare/xxhash/v2 v2.3.0` +## Testing Coverage -System tooling relies mainly on Apple-provided binaries and standard macOS utilities such as: +There is no single `tests/security.bats` file. Instead, security-relevant behavior is covered by focused suites, including: -- `tmutil` -- `diskutil` -- `plutil` -- `launchctl` -- `osascript` -- `find` -- `stat` +- `tests/core_safe_functions.bats` +- `tests/clean_core.bats` +- `tests/clean_user_core.bats` +- `tests/clean_dev_caches.bats` +- `tests/clean_system_maintenance.bats` +- `tests/clean_apps.bats` +- `tests/purge.bats` +- `tests/installer.bats` +- `tests/optimize.bats` -Dependency vulnerability status should be checked separately from this document. +Key coverage areas include: -## Limitations +- path validation rejects empty, relative, traversal, and system paths +- symlinked directories are rejected for destructive scans +- purge protects shallow or ambiguous paths and filters nested artifacts +- dry-run flows preview actions without applying them and do not emit duplicate targets +- confirmation flows exist for high-risk interactive operations +- sudo credential prompting and session management (`tests/manage_sudo.bats`) +- purge config path discovery and write behavior (`tests/purge_config_paths.bats`) +- hint and cleanup-hint flows (`tests/clean_hints.bats`) -- Cleanup is destructive. There is no undo. -- Generic orphan data waits 30 days before automatic cleanup. -- Claude VM orphan cleanup waits 7 days before automatic cleanup. -- Time Machine safety windows are hour-based, not day-based, and remain more conservative. +## Known Limitations and Future Work + +- Cleanup is destructive. Most cleanup and uninstall flows do not provide undo. +- `mo analyze` delete is safer because it uses Trash, but other cleanup flows are permanent once confirmed. +- Generic orphan data waits 30 days before cleanup; this is conservative but heuristic. +- Claude VM orphan cleanup waits 7 days before cleanup; this is also heuristic. +- Time Machine safety windows are hour-based and intentionally conservative. - Localized app names may still be missed in some heuristic paths, though bundle IDs are preferred where available. - Users who want immediate removal of app data should use explicit uninstall flows rather than waiting for orphan cleanup. +- Release signing and provenance signals are improving, but downstream package-manager trust also depends on external distribution infrastructure. +- Planned follow-up work includes stronger destructive-command threat modeling, more regression coverage for high-risk paths, and continued hardening of release integrity and disclosure workflow. + +For reporting procedures and supported versions, see [SECURITY.md](SECURITY.md). diff --git a/Resources/mole/bin/analyze-go b/Resources/mole/bin/analyze-go index ebb96ba..9bd968a 100755 Binary files a/Resources/mole/bin/analyze-go and b/Resources/mole/bin/analyze-go differ diff --git a/Resources/mole/bin/check.sh b/Resources/mole/bin/check.sh index 24e4594..2b56045 100755 --- a/Resources/mole/bin/check.sh +++ b/Resources/mole/bin/check.sh @@ -14,6 +14,7 @@ source "$SCRIPT_DIR/lib/manage/update.sh" source "$SCRIPT_DIR/lib/manage/autofix.sh" source "$SCRIPT_DIR/lib/check/all.sh" +source "$SCRIPT_DIR/lib/check/dev_environment.sh" cleanup_all() { stop_inline_spinner 2> /dev/null || true @@ -42,6 +43,7 @@ main() { local health_file=$(mktemp_file) local security_file=$(mktemp_file) local config_file=$(mktemp_file) + local dev_file=$(mktemp_file) # Run all checks in parallel with spinner if [[ -t 1 ]]; then @@ -58,6 +60,7 @@ main() { check_system_health > "$health_file" 2>&1 & check_all_security > "$security_file" 2>&1 & check_all_config > "$config_file" 2>&1 & + check_all_dev_environment > "$dev_file" 2>&1 & wait } @@ -66,22 +69,21 @@ main() { printf '\n' fi - # Display results - echo -e "${BLUE}${ICON_ARROW}${NC} System updates" + # Display results (headers are printed by the check_all_* functions) cat "$updates_file" printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} System health" cat "$health_file" printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} Security posture" cat "$security_file" printf '\n' - echo -e "${BLUE}${ICON_ARROW}${NC} Configuration" cat "$config_file" + printf '\n' + cat "$dev_file" + # Show suggestions show_suggestions diff --git a/Resources/mole/bin/clean.sh b/Resources/mole/bin/clean.sh index 039fd33..370d2a2 100755 --- a/Resources/mole/bin/clean.sh +++ b/Resources/mole/bin/clean.sh @@ -24,14 +24,34 @@ source "$SCRIPT_DIR/../lib/clean/user.sh" SYSTEM_CLEAN=false DRY_RUN=false PROTECT_FINDER_METADATA=false +EXTERNAL_VOLUME_TARGET="" IS_M_SERIES=$([[ "$(uname -m)" == "arm64" ]] && echo "true" || echo "false") EXPORT_LIST_FILE="$HOME/.config/mole/clean-list.txt" CURRENT_SECTION="" readonly PROTECTED_SW_DOMAINS=( + # Web editors "capcut.com" "photopea.com" "pixlr.com" + # Google Workspace (offline mode) + "docs.google.com" + "sheets.google.com" + "slides.google.com" + "drive.google.com" + "mail.google.com" + # Code platforms (offline/PWA) + "github.com" + "gitlab.com" + "codepen.io" + "codesandbox.io" + "replit.com" + "stackblitz.com" + # Collaboration tools (offline/PWA) + "notion.so" + "figma.com" + "linear.app" + "excalidraw.com" ) declare -a WHITELIST_PATTERNS=() @@ -53,7 +73,7 @@ if [[ -f "$HOME/.config/mole/whitelist" ]]; then fi if [[ "$line" != "$FINDER_METADATA_SENTINEL" ]]; then - if [[ ! "$line" =~ ^[a-zA-Z0-9/_.@\ *-]+$ ]]; then + if [[ "$line" =~ [[:cntrl:]] ]]; then WHITELIST_WARNINGS+=("Invalid path format: $line") continue fi @@ -129,6 +149,7 @@ PROJECT_ARTIFACT_HINT_EXAMPLES=() PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0 PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false +declare -a DRY_RUN_SEEN_IDENTITIES=() # shellcheck disable=SC2329 note_activity() { @@ -137,6 +158,20 @@ note_activity() { fi } +# shellcheck disable=SC2329 +register_dry_run_cleanup_target() { + local path="$1" + local identity + identity=$(mole_path_identity "$path") + + if [[ ${#DRY_RUN_SEEN_IDENTITIES[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${DRY_RUN_SEEN_IDENTITIES[@]}"; then + return 1 + fi + + DRY_RUN_SEEN_IDENTITIES+=("$identity") + return 0 +} + CLEANUP_DONE=false # shellcheck disable=SC2329 cleanup() { @@ -187,6 +222,35 @@ end_section() { # shellcheck disable=SC2329 normalize_paths_for_cleanup() { local -a input_paths=("$@") + + # Fast path for large batches: O(n log n) via sort|awk instead of O(n²) bash loops. + # Lex sort guarantees every parent path precedes its children, so a single-pass + # awk can filter child paths by tracking only the last kept path. + # Paths with embedded newlines cannot go through the newline-delimited pipeline; + # they are output directly with null-byte delimiters and skipped by the sort pass. + if [[ ${#input_paths[@]} -gt 50 ]]; then + local -a _fast_pipeline=() + local _fast_path + for _fast_path in "${input_paths[@]}"; do + if [[ "$_fast_path" == *$'\n'* ]]; then + printf '%s\0' "$_fast_path" + else + _fast_pipeline+=("$_fast_path") + fi + done + if [[ ${#_fast_pipeline[@]} -gt 0 ]]; then + printf '%s\n' "${_fast_pipeline[@]}" | + awk '{sub(/\/$/, ""); if ($0 != "") print}' | + LC_ALL=C sort -u | + awk 'BEGIN { last = "" } { + if (last != "" && substr($0, 1, length(last) + 1) == last "/") next + last = $0; print + }' | + while IFS= read -r _fast_path; do printf '%s\0' "$_fast_path"; done + fi + return + fi + local -a unique_paths=() for path in "${input_paths[@]}"; do @@ -204,9 +268,21 @@ normalize_paths_for_cleanup() { [[ "$found" == "true" ]] || unique_paths+=("$normalized") done + # Paths with embedded newlines cannot safely go through the newline-delimited + # sort pipeline. Collect them separately and append to result as-is. + local -a pipeline_paths=() + local -a passthrough_paths=() + for path in "${unique_paths[@]}"; do + if [[ "$path" == *$'\n'* ]]; then + passthrough_paths+=("$path") + else + pipeline_paths+=("$path") + fi + done + local sorted_paths - if [[ ${#unique_paths[@]} -gt 0 ]]; then - sorted_paths=$(printf '%s\n' "${unique_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-) + if [[ ${#pipeline_paths[@]} -gt 0 ]]; then + sorted_paths=$(printf '%s\n' "${pipeline_paths[@]}" | awk '{print length "|" $0}' | LC_ALL=C sort -n | cut -d'|' -f2-) else sorted_paths="" fi @@ -226,8 +302,13 @@ normalize_paths_for_cleanup() { [[ "$is_child" == "true" ]] || result_paths+=("$path") done <<< "$sorted_paths" + # Append passthrough paths (newline-containing; not deduplicated against others). + if [[ ${#passthrough_paths[@]} -gt 0 ]]; then + result_paths+=("${passthrough_paths[@]}") + fi + if [[ ${#result_paths[@]} -gt 0 ]]; then - printf '%s\n' "${result_paths[@]}" + printf '%s\0' "${result_paths[@]}" fi } @@ -380,7 +461,12 @@ safe_clean() { log_operation "clean" "SKIPPED" "$path" "whitelist" fi [[ "$skip" == "true" ]] && continue - [[ -e "$path" ]] && existing_paths+=("$path") + if [[ -e "$path" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + register_dry_run_cleanup_target "$path" || continue + fi + existing_paths+=("$path") + fi done if [[ "$show_scan_feedback" == "true" ]]; then @@ -419,7 +505,7 @@ safe_clean() { if [[ ${#existing_paths[@]} -gt 1 ]]; then local -a normalized_paths=() - while IFS= read -r path; do + while IFS= read -r -d '' path; do [[ -n "$path" ]] && normalized_paths+=("$path") done < <(normalize_paths_for_cleanup "${existing_paths[@]}") @@ -690,7 +776,9 @@ safe_clean() { done fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} $label${NC}, ${line_color}$size_human${NC}" fi files_cleaned=$((files_cleaned + total_count)) total_size_cleaned=$((total_size_cleaned + total_size_kb)) @@ -705,11 +793,25 @@ start_cleanup() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="clean" log_operation_session_start "clean" + DRY_RUN_SEEN_IDENTITIES=() if [[ -t 1 ]]; then printf '\033[2J\033[H' fi printf '\n' + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + echo -e "${PURPLE_BOLD}Clean External Volume${NC}" + echo -e "${GRAY}${EXTERNAL_VOLUME_TARGET}${NC}" + echo "" + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" + echo "" + fi + SYSTEM_CLEAN=false + return 0 + fi + echo -e "${PURPLE_BOLD}Clean Your Mac${NC}" echo "" @@ -802,9 +904,15 @@ EOF } perform_cleanup() { + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + total_items=0 + files_cleaned=0 + total_size_cleaned=0 + fi + # Test mode skips expensive scans and returns minimal output. local test_mode_enabled=false - if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then + if [[ -z "$EXTERNAL_VOLUME_TARGET" && "${MOLE_TEST_MODE:-0}" == "1" ]]; then test_mode_enabled=true if [[ "$DRY_RUN" == "true" ]]; then echo -e "${YELLOW}Dry Run Mode${NC}, Preview only, no deletions" @@ -838,7 +946,7 @@ perform_cleanup() { total_size_cleaned=0 fi - if [[ "$test_mode_enabled" == "false" ]]; then + if [[ "$test_mode_enabled" == "false" && -z "$EXTERNAL_VOLUME_TARGET" ]]; then echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $(get_free_space)" fi @@ -853,7 +961,9 @@ perform_cleanup() { fi # Pre-check TCC permissions to avoid mid-run prompts. - check_tcc_permissions + if [[ -z "$EXTERNAL_VOLUME_TARGET" ]]; then + check_tcc_permissions + fi if [[ ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then local predefined_count=0 @@ -914,98 +1024,104 @@ perform_cleanup() { # Allow per-section failures without aborting the full run. set +e - # ===== 1. System ===== - if [[ "$SYSTEM_CLEAN" == "true" ]]; then - start_section "System" - clean_deep_system - clean_local_snapshots + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + start_section "External volume" + clean_external_volume_target "$EXTERNAL_VOLUME_TARGET" end_section - fi + else + # ===== 1. System ===== + if [[ "$SYSTEM_CLEAN" == "true" ]]; then + start_section "System" + clean_deep_system + clean_local_snapshots + end_section + fi - if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then - echo "" - for warning in "${WHITELIST_WARNINGS[@]}"; do - echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" - done - fi + if [[ ${#WHITELIST_WARNINGS[@]} -gt 0 ]]; then + echo "" + for warning in "${WHITELIST_WARNINGS[@]}"; do + echo -e " ${GRAY}${ICON_WARNING}${NC} Whitelist: $warning" + done + fi + + # ===== 2. User essentials ===== + start_section "User essentials" + clean_user_essentials + clean_finder_metadata + end_section - # ===== 2. User essentials ===== - start_section "User essentials" - clean_user_essentials - clean_finder_metadata - scan_external_volumes - end_section - - # ===== 3. App caches (merged sandboxed and standard app caches) ===== - start_section "App caches" - clean_app_caches - end_section - - # ===== 4. Browsers ===== - start_section "Browsers" - clean_browsers - end_section - - # ===== 5. Cloud & Office ===== - start_section "Cloud & Office" - clean_cloud_storage - clean_office_applications - end_section - - # ===== 6. Developer tools (merged CLI and GUI tooling) ===== - start_section "Developer tools" - clean_developer_tools - end_section - - # ===== 7. Applications ===== - start_section "Applications" - clean_user_gui_applications - end_section - - # ===== 8. Virtualization ===== - start_section "Virtualization" - clean_virtualization_tools - end_section - - # ===== 9. Application Support ===== - start_section "Application Support" - clean_application_support_logs - end_section - - # ===== 10. Orphaned data ===== - start_section "Orphaned data" - clean_orphaned_app_data - clean_orphaned_system_services - clean_orphaned_launch_agents - end_section - - # ===== 11. Apple Silicon ===== - clean_apple_silicon_caches - - # ===== 12. Device backups ===== - start_section "Device backups" - check_ios_device_backups - end_section - - # ===== 13. Time Machine ===== - start_section "Time Machine" - clean_time_machine_failed_backups - end_section - - # ===== 14. Large files ===== - start_section "Large files" - check_large_file_candidates - end_section - - # ===== 15. System Data clues ===== - start_section "System Data clues" - show_system_data_hint_notice - end_section - - # ===== 16. Project artifacts ===== - start_section "Project artifacts" - show_project_artifact_hint_notice - end_section + # ===== 3. App caches (merged sandboxed and standard app caches) ===== + start_section "App caches" + clean_app_caches + end_section + + # ===== 4. Browsers ===== + start_section "Browsers" + clean_browsers + end_section + + # ===== 5. Cloud & Office ===== + start_section "Cloud & Office" + clean_cloud_storage + clean_office_applications + end_section + + # ===== 6. Developer tools (merged CLI and GUI tooling) ===== + start_section "Developer tools" + clean_developer_tools + end_section + + # ===== 7. Applications ===== + start_section "Applications" + clean_user_gui_applications + end_section + + # ===== 8. Virtualization ===== + start_section "Virtualization" + clean_virtualization_tools + end_section + + # ===== 9. Application Support ===== + start_section "Application Support" + clean_application_support_logs + end_section + + # ===== 10. App leftovers ===== + start_section "App leftovers" + clean_orphaned_app_data + clean_orphaned_system_services + show_user_launch_agent_hint_notice + end_section + + # ===== 11. Apple Silicon ===== + clean_apple_silicon_caches + + # ===== 12. Device backups & firmware ===== + start_section "Device backups & firmware" + clean_cached_device_firmware + check_ios_device_backups + end_section + + # ===== 13. Time Machine ===== + start_section "Time Machine" + clean_time_machine_failed_backups + end_section + + # ===== 14. Large files ===== + start_section "Large files" + check_large_file_candidates + end_section + + # ===== 15. System Data clues ===== + start_section "System Data clues" + show_system_data_hint_notice + end_section + + # ===== 16. Project artifacts ===== + start_section "Project artifacts" + show_project_artifact_hint_notice + end_section + fi # ===== Final summary ===== echo "" @@ -1055,9 +1171,9 @@ perform_cleanup() { summary_details+=("$summary_line") - # Movie comparison only if >= 1GB (1048576 KB) - if ((total_size_cleaned >= 1048576)); then - local freed_gb=$((total_size_cleaned / 1048576)) + # Movie comparison only if >= 1GB + if ((total_size_cleaned >= MOLE_ONE_GIB_KB)); then + local freed_gb=$((total_size_cleaned / MOLE_ONE_GIB_KB)) local movies=$((freed_gb * 10 / 45)) if [[ $movies -gt 0 ]]; then @@ -1095,8 +1211,8 @@ perform_cleanup() { } main() { - for arg in "$@"; do - case "$arg" in + while [[ $# -gt 0 ]]; do + case "$1" in "--help" | "-h") show_clean_help exit 0 @@ -1108,12 +1224,21 @@ main() { DRY_RUN=true export MOLE_DRY_RUN=1 ;; + "--external") + shift + if [[ $# -eq 0 ]]; then + echo "Missing path for --external" >&2 + exit 1 + fi + EXTERNAL_VOLUME_TARGET=$(validate_external_volume_target "$1") || exit 1 + ;; "--whitelist") source "$SCRIPT_DIR/../lib/manage/whitelist.sh" manage_whitelist "clean" exit 0 ;; esac + shift done start_cleanup diff --git a/Resources/mole/bin/completion.sh b/Resources/mole/bin/completion.sh index a575929..94ddca8 100755 --- a/Resources/mole/bin/completion.sh +++ b/Resources/mole/bin/completion.sh @@ -23,13 +23,32 @@ emit_fish_completions() { 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" + printf 'complete -f -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" + printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a bash -d "generate bash completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" + printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a zsh -d "generate zsh completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" + printf 'complete -f -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" +} + +remove_stale_completion_entries() { + local config_file="$1" + local success_message="$2" + + if [[ ! -f "$config_file" ]] || ! grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then + return 1 + fi + + local original_mode="" + local temp_file + 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" + [[ -n "$original_mode" ]] && chmod "$original_mode" "$config_file" 2> /dev/null || true + [[ -n "$success_message" ]] && echo -e "${GREEN}${ICON_SUCCESS}${NC} $success_message" + return 0 } if [[ $# -gt 0 ]]; then @@ -71,6 +90,75 @@ if [[ $# -eq 0 ]]; then completion_name="mo" fi + # Fish uses a separate install path: write to ~/.config/fish/completions/ so + # both `mole` and `mo` load completions independently on terminal startup. + if [[ "$current_shell" == "fish" ]]; then + fish_dir="${HOME}/.config/fish/completions" + mole_file="${fish_dir}/mole.fish" + mo_file="${fish_dir}/mo.fish" + config_fish="${HOME}/.config/fish/config.fish" + + if [[ -z "$completion_name" ]]; then + # Clean up any stale config.fish entries even when mole is not in PATH + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + remove_stale_completion_entries "$config_fish" "Removed stale completion entries from config.fish" || true + fi + log_error "mole not found in PATH, install Mole before enabling completion" + exit 1 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would write Fish completions to:${NC}" + echo " $mole_file" + echo " $mo_file" + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" + exit 0 + fi + + # Remove stale config.fish source-based entries (previous install method) + if remove_stale_completion_entries "$config_fish" "Removed stale source-based entries from config.fish"; then + echo "" + fi + + # Prompt only on first install; silently update if files exist + if [[ ! -f "$mole_file" ]]; then + echo "" + echo -e "${GRAY}Will write Fish completions to:${NC}" + echo " $mole_file" + echo " $mo_file" + echo "" + echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}fish${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 + fi + + mkdir -p "$fish_dir" + "$completion_name" completion fish > "$mole_file" + # mo.fish sources mole.fish so Fish loads mo completions on `mo` + printf '# Mole completions for mo (alias) -- auto-generated, do not edit\n' > "$mo_file" + printf 'source %s\n' "$mole_file" >> "$mo_file" + + if [[ -f "$mole_file" ]]; then + echo -e "${GREEN}${ICON_SUCCESS}${NC} Fish completions written to $fish_dir" + fi + echo "" + exit 0 + fi + case "$current_shell" in bash) config_file="${HOME}/.bashrc" @@ -83,11 +171,6 @@ if [[ $# -eq 0 ]]; then # 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 " diff --git a/Resources/mole/bin/installer.sh b/Resources/mole/bin/installer.sh index 864404a..56d0af4 100755 --- a/Resources/mole/bin/installer.sh +++ b/Resources/mole/bin/installer.sh @@ -184,9 +184,19 @@ format_installer_display() { truncated_name=$(truncate_by_display_width "$filename" "$available_width") local current_width current_width=$(get_display_width "$truncated_name") - local char_count=${#truncated_name} + + # Get byte count for printf width calculation + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local byte_count=${#truncated_name} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding=$((available_width - current_width)) - local printf_width=$((char_count + padding)) + local printf_width=$((byte_count + padding)) # Format: "filename size | source" printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source" diff --git a/Resources/mole/bin/optimize.sh b/Resources/mole/bin/optimize.sh index 1a24451..cbbf5f9 100755 --- a/Resources/mole/bin/optimize.sh +++ b/Resources/mole/bin/optimize.sh @@ -21,6 +21,7 @@ source "$SCRIPT_DIR/lib/optimize/maintenance.sh" source "$SCRIPT_DIR/lib/optimize/tasks.sh" source "$SCRIPT_DIR/lib/check/health_json.sh" source "$SCRIPT_DIR/lib/check/all.sh" +source "$SCRIPT_DIR/lib/check/dev_environment.sh" source "$SCRIPT_DIR/lib/manage/whitelist.sh" print_header() { @@ -28,6 +29,60 @@ print_header() { echo -e "${PURPLE_BOLD}Optimize and Check${NC}" } +# Bash-native JSON parsing helpers (no jq dependency). +# Extract a simple numeric value from JSON by key. +json_get_value() { + local json="$1" + local key="$2" + local value + value=$(echo "$json" | grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9.]*" | head -1 | sed 's/.*:[[:space:]]*//') + echo "${value:-0}" +} + +# Validate JSON has expected structure (basic check). +json_validate() { + local json="$1" + # Check for required keys + [[ "$json" == *'"memory_used_gb"'* ]] && + [[ "$json" == *'"optimizations"'* ]] && + [[ "$json" == *'{'* ]] && [[ "$json" == *'}'* ]] +} + +# Parse optimization items from JSON array. +# Outputs pipe-delimited records: action|name|description|safe +# Single awk pass instead of per-item grep+sed to avoid subprocess overhead. +parse_optimization_items() { + local json="$1" + awk ' + function extract(line, key, pat, val, start, end) { + pat = "\"" key "\"[ \t]*:[ \t]*\"" + if (match(line, pat)) { + start = RSTART + RLENGTH + val = substr(line, start) + # Find closing quote (skip escaped quotes) + end = 1 + while (end <= length(val)) { + if (substr(val, end, 1) == "\"" && substr(val, end-1, 1) != "\\") break + end++ + } + return substr(val, 1, end - 1) + } + return "" + } + /"optimizations".*\[/ { in_arr=1; next } + !in_arr { next } + /\]/ && !in_obj { exit } + /{/ { in_obj=1; action=""; name=""; desc=""; safe="" } + in_obj && /"action"/ { action = extract($0, "action") } + in_obj && /"name"/ { name = extract($0, "name") } + in_obj && /"description"/ { desc = extract($0, "description") } + in_obj && /"safe"/ { + val = $0; sub(/.*"safe"[[:space:]]*:[[:space:]]*/, "", val); sub(/[^a-z].*/, "", val); safe = val + } + /}/ { if (in_obj && action != "") print action "|" name "|" desc "|" safe; in_obj=0 } + ' <<< "$json" +} + run_system_checks() { # Skip checks in dry-run mode. if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then @@ -56,6 +111,8 @@ run_system_checks() { check_all_config echo "" + check_all_dev_environment + show_suggestions if ask_for_updates; then @@ -68,14 +125,13 @@ run_system_checks() { show_optimization_summary() { local safe_count="${OPTIMIZE_SAFE_COUNT:-0}" - local confirm_count="${OPTIMIZE_CONFIRM_COUNT:-0}" - if ((safe_count == 0 && confirm_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then + if ((safe_count == 0)) && [[ -z "${AUTO_FIX_SUMMARY:-}" ]]; then return fi local summary_title local -a summary_details=() - local total_applied=$((safe_count + confirm_count)) + local total_applied=$safe_count if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then summary_title="Dry Run Complete, No Changes Made" @@ -139,12 +195,12 @@ show_optimization_summary() { show_system_health() { local health_json="$1" - 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 disk_used=$(echo "$health_json" | jq -r '.disk_used_gb // 0' 2> /dev/null || echo "0") - local disk_total=$(echo "$health_json" | jq -r '.disk_total_gb // 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 mem_used=$(json_get_value "$health_json" "memory_used_gb") + local mem_total=$(json_get_value "$health_json" "memory_total_gb") + local disk_used=$(json_get_value "$health_json" "disk_used_gb") + local disk_total=$(json_get_value "$health_json" "disk_total_gb") + local disk_percent=$(json_get_value "$health_json" "disk_used_percent") + local uptime=$(json_get_value "$health_json" "uptime_days") mem_used=${mem_used:-0} mem_total=${mem_total:-0} @@ -157,11 +213,6 @@ show_system_health() { "$mem_used" "$mem_total" "$disk_used" "$disk_total" "$uptime" } -parse_optimizations() { - local health_json="$1" - echo "$health_json" | jq -c '.optimizations[]' 2> /dev/null -} - announce_action() { local name="$1" local desc="$2" @@ -251,11 +302,8 @@ collect_security_fix_actions() { SECURITY_FIXES+=("firewall|Enable macOS firewall") fi fi - if [[ "${GATEKEEPER_DISABLED:-}" == "true" ]]; then - if ! is_whitelisted "gatekeeper"; then - SECURITY_FIXES+=("gatekeeper|Enable Gatekeeper, app download protection") - fi - fi + # Gatekeeper state is intentionally user-managed. Optimize may report it, + # but it must not change the user's "Anywhere" preference. if touchid_supported && ! touchid_configured; then if ! is_whitelisted "check_touchid"; then SECURITY_FIXES+=("touchid|Enable Touch ID for sudo") @@ -309,16 +357,6 @@ apply_firewall_fix() { return 1 } -apply_gatekeeper_fix() { - if sudo spctl --master-enable 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Gatekeeper enabled" - GATEKEEPER_DISABLED=false - return 0 - fi - echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable Gatekeeper" - return 1 -} - apply_touchid_fix() { if "$SCRIPT_DIR/bin/touchid.sh" enable; then return 0 @@ -339,9 +377,6 @@ perform_security_fixes() { firewall) apply_firewall_fix && ((applied++)) ;; - gatekeeper) - apply_gatekeeper_fix && ((applied++)) - ;; touchid) apply_touchid_fix && ((applied++)) ;; @@ -406,12 +441,6 @@ main() { echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No files will be modified\n" fi - if ! command -v jq > /dev/null 2>&1; then - echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: jq" - echo -e "${GRAY}Install with: ${GREEN}brew install jq${NC}" - exit 1 - fi - if ! command -v bc > /dev/null 2>&1; then echo -e "${YELLOW}${ICON_ERROR}${NC} Missing dependency: bc" echo -e "${GRAY}Install with: ${GREEN}brew install bc${NC}" @@ -431,13 +460,13 @@ main() { exit 1 fi - if ! echo "$health_json" | jq empty 2> /dev/null; then + if ! json_validate "$health_json"; then if [[ -t 1 ]]; then stop_inline_spinner fi echo "" log_error "Invalid system health data format" - echo -e "${GRAY}${ICON_REVIEW}${NC} Check if jq, awk, sysctl, and df commands are available" + echo -e "${GRAY}${ICON_REVIEW}${NC} Check if awk, sysctl, and df commands are available" exit 1 fi @@ -459,28 +488,14 @@ main() { fi fi - local -a safe_items=() - local -a confirm_items=() + local -a items=() local opts_file opts_file=$(mktemp_file) - parse_optimizations "$health_json" > "$opts_file" - - while IFS= read -r opt_json; do - [[ -z "$opt_json" ]] && continue - - local name=$(echo "$opt_json" | jq -r '.name') - local desc=$(echo "$opt_json" | jq -r '.description') - local action=$(echo "$opt_json" | jq -r '.action') - local path=$(echo "$opt_json" | jq -r '.path // ""') - local safe=$(echo "$opt_json" | jq -r '.safe') + parse_optimization_items "$health_json" > "$opts_file" - local item="${name}|${desc}|${action}|${path}" - - if [[ "$safe" == "true" ]]; then - safe_items+=("$item") - else - confirm_items+=("$item") - fi + while IFS='|' read -r action name desc safe; do + [[ -z "$action" ]] && continue + items+=("${name}|${desc}|${action}|") done < "$opts_file" echo "" @@ -489,29 +504,18 @@ main() { fi export FIRST_ACTION=true - if [[ ${#safe_items[@]} -gt 0 ]]; then - for item in "${safe_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" - announce_action "$name" "$desc" "safe" - execute_optimization "$action" "$path" - done - fi - - if [[ ${#confirm_items[@]} -gt 0 ]]; then - for item in "${confirm_items[@]}"; do - IFS='|' read -r name desc action path <<< "$item" - announce_action "$name" "$desc" "confirm" - execute_optimization "$action" "$path" - done - fi + for item in "${items[@]}"; do + IFS='|' read -r name desc action path <<< "$item" + announce_action "$name" "$desc" "safe" + execute_optimization "$action" "$path" + done - local safe_count=${#safe_items[@]} - local confirm_count=${#confirm_items[@]} + local safe_count=${#items[@]} run_system_checks export OPTIMIZE_SAFE_COUNT=$safe_count - export OPTIMIZE_CONFIRM_COUNT=$confirm_count + export OPTIMIZE_CONFIRM_COUNT=0 show_optimization_summary diff --git a/Resources/mole/bin/purge.sh b/Resources/mole/bin/purge.sh index cd373bd..03a7dc9 100755 --- a/Resources/mole/bin/purge.sh +++ b/Resources/mole/bin/purge.sh @@ -40,6 +40,53 @@ note_activity() { fi } +# Keep the most specific tail of a long purge path visible on the live scan line. +compact_purge_scan_path() { + local path="$1" + local max_path_len="${2:-0}" + + if ! [[ "$max_path_len" =~ ^[0-9]+$ ]] || [[ "$max_path_len" -lt 4 ]]; then + max_path_len=4 + fi + + if [[ ${#path} -le $max_path_len ]]; then + echo "$path" + return + fi + + local suffix_len=$((max_path_len - 3)) + local suffix="${path: -$suffix_len}" + local path_tail="" + local remainder="$path" + + while [[ "$remainder" == */* ]]; do + local segment="/${remainder##*/}" + remainder="${remainder%/*}" + + if [[ -z "$path_tail" ]]; then + if [[ ${#segment} -le $suffix_len ]]; then + path_tail="$segment" + else + break + fi + continue + fi + + if [[ $((${#segment} + ${#path_tail})) -le $suffix_len ]]; then + path_tail="${segment}${path_tail}" + else + break + fi + done + + if [[ -n "$path_tail" ]]; then + echo "...${path_tail}" + return + fi + + echo "...$suffix" +} + # Main purge function start_purge() { # Set current command for operation logging @@ -127,24 +174,13 @@ perform_purge() { # Set up trap to exit cleanly (erase the spinner line via /dev/tty) trap 'printf "\r\033[2K" >/dev/tty 2>/dev/null; exit 0' INT TERM - # Truncate path to guaranteed fit - truncate_path() { - local path="$1" - if [[ ${#path} -le $max_path_len ]]; then - echo "$path" - return - fi - local side_len=$(((max_path_len - 3) / 2)) - echo "${path:0:$side_len}...${path: -$side_len}" - } - while [[ -f "$stats_dir/purge_scanning" ]]; do local current_path current_path=$(cat "$stats_dir/purge_scanning" 2> /dev/null || echo "") if [[ -n "$current_path" ]]; then local display_path="${current_path/#$HOME/~}" - display_path=$(truncate_path "$display_path") + display_path=$(compact_purge_scan_path "$display_path" "$max_path_len") last_path="$display_path" fi @@ -291,4 +327,12 @@ main() { show_cursor } +if [[ "${MOLE_SKIP_MAIN:-0}" == "1" ]]; then + if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then + return 0 + else + exit 0 + fi +fi + main "$@" diff --git a/Resources/mole/bin/status-go b/Resources/mole/bin/status-go index 290cabf..e3e369b 100755 Binary files a/Resources/mole/bin/status-go and b/Resources/mole/bin/status-go differ diff --git a/Resources/mole/bin/touchid.sh b/Resources/mole/bin/touchid.sh index 76b5cc2..9459e7f 100755 --- a/Resources/mole/bin/touchid.sh +++ b/Resources/mole/bin/touchid.sh @@ -292,7 +292,7 @@ show_menu() { echo "" case "$key" in - $'\e') # ESC + $'\e' | q | Q) # ESC or Q return 0 ;; "" | $'\n' | $'\r') # Enter @@ -310,7 +310,7 @@ show_menu() { drain_pending_input # Clean up any escape sequence remnants case "$key" in - $'\e') # ESC + $'\e' | q | Q) # ESC or Q return 0 ;; "" | $'\n' | $'\r') # Enter diff --git a/Resources/mole/bin/uninstall.sh b/Resources/mole/bin/uninstall.sh index 5c96661..6f50544 100755 --- a/Resources/mole/bin/uninstall.sh +++ b/Resources/mole/bin/uninstall.sh @@ -135,6 +135,16 @@ uninstall_resolve_display_name() { if [[ "$display_name" == /* ]]; then display_name="$app_name" fi + + # Keep versioned bundle names when metadata collapses distinct installs. + if [[ -n "$display_name" && "$app_name" == "$display_name"* && "$app_name" != "$display_name" ]]; then + local suffix + suffix="${app_name#"$display_name"}" + if [[ "$suffix" == *[0-9]* ]]; then + display_name="$app_name" + fi + fi + display_name="${display_name%.app}" display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" @@ -176,6 +186,31 @@ uninstall_release_metadata_lock() { [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true } +# Atomically replace the metadata cache file, healing stale root-owned copies. +# stdin is closed so BSD mv/cp never blocks prompting on a non-writable target. +uninstall_persist_cache_file() { + local src="$1" + local dst="$2" + + [[ -s "$src" ]] || { + rm -f "$src" 2> /dev/null || true + return 0 + } + + # Heal stale file the user cannot write to (e.g. root-owned from a prior + # sudo run). The parent dir is user-owned, so rm succeeds regardless. + if [[ -e "$dst" && ! -w "$dst" ]]; then + rm -f "$dst" 2> /dev/null || true + fi + + # shellcheck disable=SC2217 # BSD mv/cp read stdin when prompting; close it to avoid hang. + mv -f "$src" "$dst" < /dev/null 2> /dev/null || { + # shellcheck disable=SC2217 + cp -f "$src" "$dst" < /dev/null 2> /dev/null || true + rm -f "$src" 2> /dev/null || true + } +} + uninstall_collect_inline_metadata() { local app_path="$1" local app_mtime="${2:-0}" @@ -322,10 +357,7 @@ start_uninstall_metadata_refresh() { } ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$merged_file" - mv "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || { - cp "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true - rm -f "$merged_file" - } + uninstall_persist_cache_file "$merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" rm -f "$updates_file" @@ -469,12 +501,11 @@ scan_applications() { while IFS= read -r -d '' app_path; do if [[ ! -e "$app_path" ]]; then continue; fi - local app_name - app_name=$(basename "$app_path" .app) + local app_name="${app_path##*/}" + app_name="${app_name%.app}" # Skip nested apps inside another .app bundle. - local parent_dir - parent_dir=$(dirname "$app_path") + local parent_dir="${app_path%/*}" if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then continue fi @@ -485,9 +516,10 @@ scan_applications() { if [[ -n "$link_target" ]]; then local resolved_target="$link_target" if [[ "$link_target" != /* ]]; then - local link_dir - link_dir=$(dirname "$app_path") - resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$(dirname "$link_target")" 2> /dev/null && pwd)/$(basename "$link_target") 2> /dev/null || echo "" + local link_dir="${app_path%/*}" + local _link_parent="${link_target%/*}" + [[ "$_link_parent" == "$link_target" ]] && _link_parent="." + resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$_link_parent" 2> /dev/null && pwd)/"${link_target##*/}" 2> /dev/null || echo "" fi case "$resolved_target" in /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) @@ -547,6 +579,15 @@ scan_applications() { return 0 fi + local plist="$app_path/Contents/Info.plist" + if [[ -f "$plist" ]]; then + local bg_only + bg_only=$(defaults read "$plist" LSBackgroundOnly 2> /dev/null || echo "") + if [[ "$bg_only" == "1" || "$bg_only" == "YES" || "$bg_only" == "true" ]]; then + return 0 + fi + fi + local display_name="${cached_display_name:-}" if [[ -z "$display_name" ]]; then display_name=$(uninstall_resolve_display_name "$app_path" "$app_name") @@ -636,6 +677,8 @@ scan_applications() { local current_epoch current_epoch=$(get_epoch_seconds) local inline_metadata_count=0 + local inline_metadata_effective_limit=$MOLE_UNINSTALL_INLINE_METADATA_LIMIT + [[ $cache_source_is_temp == true ]] && inline_metadata_effective_limit=99999 local metadata_total=0 metadata_total=$(wc -l < "$merged_file" 2> /dev/null || echo "0") [[ "$metadata_total" =~ ^[0-9]+$ ]] || metadata_total=0 @@ -661,7 +704,7 @@ scan_applications() { fi local final_size_kb=0 - local final_size="N/A" + local final_size="--" if [[ "$cached_size_kb" =~ ^[0-9]+$ && $cached_size_kb -gt 0 ]]; then final_size_kb="$cached_size_kb" final_size=$(bytes_to_human "$((cached_size_kb * 1024))") @@ -696,7 +739,7 @@ scan_applications() { fi if [[ $needs_refresh == true ]]; then - if [[ $inline_metadata_count -lt $MOLE_UNINSTALL_INLINE_METADATA_LIMIT ]]; then + if [[ $inline_metadata_count -lt $inline_metadata_effective_limit ]]; then local inline_metadata inline_size_kb inline_epoch inline_updated_epoch inline_metadata=$(uninstall_collect_inline_metadata "$app_path" "${app_mtime:-0}" "$current_epoch") IFS='|' read -r inline_size_kb inline_epoch inline_updated_epoch <<< "$inline_metadata" @@ -729,10 +772,7 @@ scan_applications() { update_scan_status "Updating cache..." "0" "0" if [[ -s "$cache_snapshot_file" ]]; then if uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then - mv "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || { - cp "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null || true - rm -f "$cache_snapshot_file" - } + uninstall_persist_cache_file "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" fi fi @@ -808,12 +848,226 @@ cleanup() { trap cleanup EXIT INT TERM +# Match app names from scan data against user-provided search terms. +# Performs case-insensitive substring matching on app display names. +# Returns matched entries from apps_data in selected_apps. +match_apps_by_name() { + local -a search_terms=("$@") + selected_apps=() + local -a matched_indices=() + + for search_term in "${search_terms[@]}"; do + local search_lower + search_lower=$(echo "$search_term" | tr '[:upper:]' '[:lower:]') + # Escape glob characters to prevent pattern injection + search_lower=${search_lower//\\/\\\\} + search_lower=${search_lower//\*/\\*} + search_lower=${search_lower//\?/\\?} + search_lower=${search_lower//\[/\\[} + local found=false + local idx=0 + for app_data in "${apps_data[@]}"; do + IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$app_data" + local name_lower + name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + # Also try matching against the .app directory base name + local dir_name + dir_name=$(basename "$app_path" .app) + local dir_lower + dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]') + + if [[ "$name_lower" == "$search_lower" || "$dir_lower" == "$search_lower" ]]; then + # Exact match - prefer this + local already=false + local mi + for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do + [[ -z "$mi" ]] && continue + [[ "$mi" == "$idx" ]] && already=true && break + done + if [[ "$already" == "false" ]]; then + selected_apps+=("$app_data") + matched_indices+=("$idx") + fi + found=true + break + fi + idx=$((idx + 1)) + done + + # If no exact match, try substring match + if [[ "$found" == "false" ]]; then + idx=0 + for app_data in "${apps_data[@]}"; do + IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$app_data" + local name_lower + name_lower=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + local dir_name + dir_name=$(basename "$app_path" .app) + local dir_lower + dir_lower=$(echo "$dir_name" | tr '[:upper:]' '[:lower:]') + + if [[ "$name_lower" == *"$search_lower"* || "$dir_lower" == *"$search_lower"* ]]; then + local already=false + local mi + for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do + [[ -z "$mi" ]] && continue + [[ "$mi" == "$idx" ]] && already=true && break + done + if [[ "$already" == "false" ]]; then + selected_apps+=("$app_data") + matched_indices+=("$idx") + fi + found=true + fi + idx=$((idx + 1)) + done + fi + + if [[ "$found" == "false" ]]; then + echo -e "${YELLOW}Warning:${NC} No application found matching '$search_term'" + fi + done +} + +# Escape a value for embedding in a single-line JSON string. Only handles +# the chars that would break a one-line value: backslash, quote, and C0 +# whitespace. Bundle IDs / display names never contain control bytes worth +# preserving in this output. +uninstall_list_json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/ }" + s="${s//$'\r'/ }" + s="${s//$'\n'/ }" + printf '%s' "$s" +} + +# Read-only listing: surface each installed app's display name, bundle id, +# the exact name `mo uninstall` accepts, and human-readable size. Reuses the +# existing scanner so the output stays in lockstep with what the destructive +# path sees. +uninstall_list_apps() { + local apps_file="" + if ! apps_file=$(scan_applications); then + return 1 + fi + if [[ ! -f "$apps_file" ]]; then + return 1 + fi + if ! load_applications "$apps_file"; then + rm -f "$apps_file" + return 1 + fi + rm -f "$apps_file" + + # Auto-switch to JSON when stdout is piped, matching `mo status`. + local format="text" + if [[ ! -t 1 ]]; then + format="json" + fi + + if [[ "$format" == "json" ]]; then + printf '[' + local first=1 + local app_data + for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data" + local cask="" + if is_homebrew_available; then + cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + fi + local uninstall_name="${cask:-$app_name}" + local source_label="App" + [[ -n "$cask" ]] && source_label="Homebrew" + local size_display + size_display=$(uninstall_normalize_size_display "$size") + if [[ $first -eq 1 ]]; then + first=0 + printf '\n' + else + printf ',\n' + fi + printf ' {"name": "%s", "bundle_id": "%s", "source": "%s", "uninstall_name": "%s", "path": "%s", "size": "%s"}' \ + "$(uninstall_list_json_escape "$app_name")" \ + "$(uninstall_list_json_escape "$bundle_id")" \ + "$source_label" \ + "$(uninstall_list_json_escape "$uninstall_name")" \ + "$(uninstall_list_json_escape "$app_path")" \ + "$(uninstall_list_json_escape "$size_display")" + done + if [[ $first -eq 0 ]]; then + printf '\n' + fi + printf ']\n' + return 0 + fi + + local total=${#apps_data[@]} + if [[ $total -eq 0 ]]; then + echo "No applications found." + return 0 + fi + + printf '\n' + printf '%-36s %-30s %-30s %8s\n' 'NAME' 'BUNDLE ID' 'UNINSTALL NAME' 'SIZE' + printf -- '-%.0s' $(seq 1 108) + printf '\n' + + local app_data + for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do + IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data" + local cask="" + if is_homebrew_available; then + cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) + fi + local uninstall_name="${cask:-$app_name}" + local size_display + size_display=$(uninstall_normalize_size_display "$size") + + # Truncate by display columns, then adjust printf width for CJK. + # printf counts bytes (LC_ALL=C), but CJK chars are 3 bytes yet only + # 2 display columns wide, so we pad with the extra bytes to land on + # the correct visual column. + local name_trunc name_display_w name_byte_count name_printf_w + name_trunc=$(truncate_by_display_width "$app_name" 34) + name_display_w=$(get_display_width "$name_trunc") + + # Get byte count in C locale for printf + local old_lc="${LC_ALL:-}" + export LC_ALL=C + name_byte_count=${#name_trunc} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + + name_printf_w=$((36 + name_byte_count - name_display_w)) + + printf "%-*s %-30s %-30s %8s\n" \ + "$name_printf_w" "$name_trunc" \ + "${bundle_id:0:28}" \ + "${uninstall_name:0:28}" \ + "$size_display" + done + + printf '\n%d application(s) | Remove with: mo uninstall \n\n' "$total" + return 0 +} + main() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="uninstall" log_operation_session_start "uninstall" - # Global flags + # Default to Trash routing so an accidental uninstall is recoverable. + # The caller can opt back into rm -rf with --permanent. See #723. + export MOLE_DELETE_MODE="${MOLE_DELETE_MODE:-trash}" + + # Parse flags and collect app name arguments + local -a app_name_args=() + local list_mode=0 for arg in "$@"; do case "$arg" in "--help" | "-h") @@ -826,6 +1080,12 @@ main() { "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; + "--permanent") + export MOLE_DELETE_MODE="permanent" + ;; + "--list") + list_mode=1 + ;; "--whitelist") echo "Unknown uninstall option: $arg" echo "Whitelist management is currently supported by: mo clean --whitelist / mo optimize --whitelist" @@ -838,19 +1098,78 @@ main() { exit 1 ;; *) - echo "Unknown uninstall argument: $arg" - echo "Use 'mo uninstall --help' for supported options." - exit 1 + app_name_args+=("$arg") ;; esac done + # --list short-circuits before any destructive code. Read-only path: + # scan, resolve uninstall names, print table or JSON, exit 0. + if [[ $list_mode -eq 1 ]]; then + uninstall_list_apps + return $? + fi + hide_cursor if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified" printf '\n' fi + # Direct uninstall by app name + if [[ ${#app_name_args[@]} -gt 0 ]]; then + local apps_file="" + if ! apps_file=$(scan_applications); then + show_cursor + return 1 + fi + if [[ ! -f "$apps_file" ]]; then + show_cursor + return 1 + fi + if ! load_applications "$apps_file"; then + rm -f "$apps_file" + show_cursor + return 1 + fi + + match_apps_by_name "${app_name_args[@]}" + rm -f "$apps_file" + + if [[ ${#selected_apps[@]} -eq 0 ]]; then + show_cursor + echo "No matching applications found." + return 1 + fi + + show_cursor + clear_screen + local selection_count=${#selected_apps[@]} + echo -e "${BLUE}${ICON_CONFIRM}${NC} Matched ${selection_count} app(s):" + local index=1 + for selected_app in "${selected_apps[@]}"; do + IFS='|' read -r _ app_path app_name _ size last_used _ <<< "$selected_app" + local size_display + size_display=$(uninstall_normalize_size_display "$size") + local last_display + last_display=$(uninstall_normalize_last_used_display "$last_used") + printf "%d. %s %s | Last: %s\n" "$index" "$app_name" "$size_display" "$last_display" + ((index++)) + done + + printf '\n' + printf "Proceed with uninstallation? [y/N] " + local confirm + read -r confirm + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo "Aborted." + return 0 + fi + + batch_uninstall_applications + return 0 + fi + local first_scan=true while true; do unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN @@ -874,6 +1193,11 @@ main() { return 1 fi + # Keystrokes typed during the scan/load phase must not leak into the + # selector. A queued Enter would confirm whichever app is highlighted + # first and drop the user straight into the destructive path. See #726. + drain_pending_input + set +e select_apps_for_uninstall local exit_code=$? @@ -962,9 +1286,19 @@ main() { IFS='|' read -r name_cell size_cell last_cell <<< "$row" local name_display_width name_display_width=$(get_display_width "$name_cell") - local name_char_count=${#name_cell} + + # Get byte count for printf width calculation + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local name_byte_count=${#name_cell} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding_needed=$((max_name_display_width - name_display_width)) - local printf_name_width=$((name_char_count + padding_needed)) + local printf_name_width=$((name_byte_count + padding_needed)) printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" ((index++)) @@ -974,22 +1308,21 @@ main() { rm -f "$apps_file" - local prompt_timeout="${MOLE_UNINSTALL_RETURN_PROMPT_TIMEOUT_SEC:-3}" - if [[ ! "$prompt_timeout" =~ ^[0-9]+$ ]] || [[ "$prompt_timeout" -lt 1 ]]; then - prompt_timeout=3 - fi - - echo -e "${GRAY}Press Enter to return to the app list, press any other key or wait ${prompt_timeout}s to exit.${NC}" - local key - local read_ok=false - if IFS= read -r -s -n1 -t "$prompt_timeout" key; then - read_ok=true - else - key="" - fi + local _countdown=5 + local _key="" + local _pressed=false + while [[ $_countdown -gt 0 ]]; do + printf "\r${GRAY}Press Enter to return to the app list, press q to exit (%d)${NC} " "$_countdown" + if IFS= read -r -s -n1 -t 1 _key; then + _pressed=true + break + fi + ((_countdown--)) + done + printf "\n" drain_pending_input - if [[ "$read_ok" == "true" && -z "$key" ]]; then + if [[ "$_pressed" == "true" && -z "$_key" ]]; then : else show_cursor diff --git a/Resources/mole/cmd/analyze/analyze_test.go b/Resources/mole/cmd/analyze/analyze_test.go index 7a2fecd..28ceb8b 100644 --- a/Resources/mole/cmd/analyze/analyze_test.go +++ b/Resources/mole/cmd/analyze/analyze_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -5,10 +7,13 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "sync/atomic" "testing" "time" + + tea "github.com/charmbracelet/bubbletea" ) func resetOverviewSnapshotForTest() { @@ -18,6 +23,30 @@ func resetOverviewSnapshotForTest() { overviewSnapshotMu.Unlock() } +func runScanResultCmd(t *testing.T, cmd tea.Cmd) scanResultMsg { + t.Helper() + + msg := cmd() + switch typed := msg.(type) { + case scanResultMsg: + return typed + case tea.BatchMsg: + for _, batchCmd := range typed { + if batchCmd == nil { + continue + } + if scanMsg, ok := batchCmd().(scanResultMsg); ok { + return scanMsg + } + } + t.Fatalf("expected tea.BatchMsg to contain a scanResultMsg, got %T", msg) + default: + t.Fatalf("expected scanResultMsg or tea.BatchMsg, got %T", msg) + } + + return scanResultMsg{} +} + func TestScanPathConcurrentBasic(t *testing.T) { root := t.TempDir() @@ -109,7 +138,7 @@ func TestPerformScanForJSONCountsTopLevelFiles(t *testing.T) { t.Fatalf("write nested file: %v", err) } - result := performScanForJSON(root) + result := performScanForJSON(root, false) if result.TotalFiles != 2 { t.Fatalf("expected 2 files in JSON output, got %d", result.TotalFiles) @@ -180,6 +209,97 @@ func TestOverviewStoreAndLoad(t *testing.T) { } } +func TestUpdateKeyEscGoesBackFromDirectoryView(t *testing.T) { + m := model{ + path: "/tmp/child", + history: []historyEntry{ + { + Path: "/tmp", + Entries: []dirEntry{{Name: "child", Path: "/tmp/child", Size: 1, IsDir: true}}, + TotalSize: 1, + Selected: 0, + EntryOffset: 0, + }, + }, + entries: []dirEntry{{Name: "file.txt", Path: "/tmp/child/file.txt", Size: 1}}, + } + + updated, cmd := m.updateKey(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil { + t.Fatalf("expected no command when returning from cached history, got %v", cmd) + } + + got, ok := updated.(model) + if !ok { + t.Fatalf("expected model, got %T", updated) + } + if got.path != "/tmp" { + t.Fatalf("expected path /tmp after Esc, got %s", got.path) + } + if got.status == "" { + t.Fatalf("expected status to be updated after Esc navigation") + } +} + +func TestUpdateKeyCtrlCQuits(t *testing.T) { + m := model{} + + _, cmd := m.updateKey(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatalf("expected quit command for Ctrl+C") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg from quit command") + } +} + +func TestViewShowsEscBackAndCtrlCQuitHints(t *testing.T) { + m := model{ + path: "/tmp/project", + history: []historyEntry{{Path: "/tmp"}}, + entries: []dirEntry{{Name: "cache", Path: "/tmp/project/cache", Size: 1, IsDir: true}}, + largeFiles: []fileEntry{{Name: "large.bin", Path: "/tmp/project/large.bin", Size: 1024}}, + totalSize: 1024, + } + + view := m.View() + if !strings.Contains(view, "Esc Back") { + t.Fatalf("expected Esc Back hint in view, got:\n%s", view) + } + if !strings.Contains(view, "Ctrl+C Quit") { + t.Fatalf("expected Ctrl+C Quit hint in view, got:\n%s", view) + } +} + +func TestOverviewViewShowsFreeSpaceLabel(t *testing.T) { + m := model{ + path: "/", + isOverview: true, + diskFree: 123_400_000, + entries: []dirEntry{{Name: "Home", Path: "/tmp/home", Size: 1, IsDir: true}}, + } + + view := m.View() + want := fmt.Sprintf("(%s free)", humanizeBytes(m.diskFree)) + if !strings.Contains(view, want) { + t.Fatalf("expected free-space label %q in overview view, got:\n%s", want, view) + } +} + +func TestOverviewViewOmitsFreeSpaceLabelWhenUnknown(t *testing.T) { + m := model{ + path: "/", + isOverview: true, + diskFree: 0, + entries: []dirEntry{{Name: "Home", Path: "/tmp/home", Size: 1, IsDir: true}}, + } + + view := m.View() + if strings.Contains(view, "free)") { + t.Fatalf("expected overview view to omit free-space label when unavailable, got:\n%s", view) + } +} + func TestCacheSaveLoadRoundTrip(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) @@ -218,6 +338,401 @@ func TestCacheSaveLoadRoundTrip(t *testing.T) { } } +func TestScanPathConcurrentWarmsChildDirectoryCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "root.txt"), []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root data: %v", err) + } + if err := os.WriteFile(filepath.Join(child, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write child data: %v", err) + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + if _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current); err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + cached, err := loadCacheFromDisk(child) + if err != nil { + t.Fatalf("expected warmed child cache, got error: %v", err) + } + if cached.TotalSize <= 0 { + t.Fatalf("expected positive cached child size, got %d", cached.TotalSize) + } + if len(cached.Entries) == 0 { + t.Fatalf("expected cached child entries to be populated") + } + if cached.TotalFiles != 1 { + t.Fatalf("expected warmed child cache to track local file count 1, got %d", cached.TotalFiles) + } + if !cached.NeedsRefresh { + t.Fatalf("expected warmed child cache to be marked for refresh") + } +} + +func TestScanPathConcurrentUsesChildCacheLargeFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + largeFile := filepath.Join(child, "large.bin") + if err := os.WriteFile(largeFile, []byte(strings.Repeat("x", 2<<20)), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + var childFiles, childDirs, childBytes int64 + childCurrent := &atomic.Value{} + childCurrent.Store("") + childResult, err := scanPathConcurrent(child, &childFiles, &childDirs, &childBytes, childCurrent) + if err != nil { + t.Fatalf("scanPathConcurrent(child): %v", err) + } + if err := saveCacheToDisk(child, childResult); err != nil { + t.Fatalf("saveCacheToDisk(child): %v", err) + } + + if err := os.Chmod(child, 0o000); err != nil { + t.Fatalf("chmod child unreadable: %v", err) + } + defer func() { + _ = os.Chmod(child, 0o755) + }() + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + if err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + foundChild := false + for _, entry := range result.Entries { + if entry.Path == child { + foundChild = true + if entry.Size != childResult.TotalSize { + t.Fatalf("cached child size mismatch: want %d, got %d", childResult.TotalSize, entry.Size) + } + break + } + } + if !foundChild { + t.Fatalf("expected cached child directory in root entries") + } + + foundLargeFile := false + for _, file := range result.LargeFiles { + if file.Path == largeFile { + foundLargeFile = true + break + } + } + if !foundLargeFile { + t.Fatalf("expected root large files to include cached child large file") + } +} + +func TestScanPathConcurrentWarmsChildCachesWithoutRecursiveSpotlight(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + childOne := filepath.Join(root, "child-one") + childTwo := filepath.Join(root, "child-two") + for _, dir := range []string{childOne, childTwo} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create dir %s: %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write data in %s: %v", dir, err) + } + } + + logPath := filepath.Join(home, "mdfind.log") + stubDir := filepath.Join(home, "bin") + if err := os.MkdirAll(stubDir, 0o755); err != nil { + t.Fatalf("create stub dir: %v", err) + } + stubPath := filepath.Join(stubDir, "mdfind") + stubScript := fmt.Sprintf("#!/bin/sh\necho \"$*\" >> %s\nexit 0\n", strconv.Quote(logPath)) + if err := os.WriteFile(stubPath, []byte(stubScript), 0o755); err != nil { + t.Fatalf("write mdfind stub: %v", err) + } + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + if _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current); err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read mdfind log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("expected only root spotlight invocation, got %d lines: %q", len(lines), string(data)) + } +} + +func TestScanCmdTreatsWarmedCacheAsStale(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + target := filepath.Join(home, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("create target: %v", err) + } + + result := scanResult{ + Entries: []dirEntry{{Name: "child", Path: filepath.Join(target, "child"), Size: 1, IsDir: true}}, + LargeFiles: []fileEntry{{Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2 << 20}}, + TotalSize: 42, + TotalFiles: 1, + } + if err := saveCacheToDiskWithOptions(target, result, true); err != nil { + t.Fatalf("saveCacheToDiskWithOptions: %v", err) + } + + m := newModel(target, false) + msg := m.scanCmd(target)() + scanMsg, ok := msg.(scanResultMsg) + if !ok { + t.Fatalf("expected scanResultMsg, got %T", msg) + } + if !scanMsg.stale { + t.Fatalf("expected warmed cache to trigger stale refresh path") + } + if scanMsg.result.TotalFiles != result.TotalFiles { + t.Fatalf("expected cached result to survive stale load, got %d", scanMsg.result.TotalFiles) + } +} + +func TestEnterSelectedDirRefreshesStaleInMemoryCache(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + parent := filepath.Join(home, "parent") + child := filepath.Join(parent, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + freshPath := filepath.Join(child, "fresh.bin") + if err := os.WriteFile(freshPath, []byte("fresh-data"), 0o644); err != nil { + t.Fatalf("write fresh file: %v", err) + } + freshInfo, err := os.Stat(freshPath) + if err != nil { + t.Fatalf("stat fresh file: %v", err) + } + freshSize := getActualFileSize(freshPath, freshInfo) + + warmed := scanResult{ + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 1}}, + TotalSize: 1, + TotalFiles: 1, + } + if err := saveCacheToDiskWithOptions(child, warmed, true); err != nil { + t.Fatalf("saveCacheToDiskWithOptions: %v", err) + } + + m := newModel(parent, false) + m.entries = []dirEntry{{Name: "child", Path: child, Size: 9, IsDir: true}} + m.cache[child] = historyEntry{ + Path: child, + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 1}}, + TotalSize: 1, + TotalFiles: 1, + NeedsRefresh: true, + } + + updated, cmd := m.enterSelectedDir() + if cmd == nil { + t.Fatalf("expected stale in-memory child cache to trigger a refresh") + } + + got := updated.(model) + if got.path != child { + t.Fatalf("expected path %s, got %s", child, got.path) + } + if !got.scanning { + t.Fatalf("expected directory to remain scanning while refreshing stale cache") + } + if got.totalSize != 1 { + t.Fatalf("expected stale cache contents to be shown immediately, got %d", got.totalSize) + } + + scanMsg := runScanResultCmd(t, cmd) + if scanMsg.stale { + t.Fatalf("expected stale cached navigation to force a fresh scan") + } + if scanMsg.result.TotalSize != freshSize { + t.Fatalf("expected fresh rescan total size %d, got %d", freshSize, scanMsg.result.TotalSize) + } + if scanMsg.result.Entries[0].Name != "fresh.bin" { + t.Fatalf("expected rescan to surface live filesystem contents, got %+v", scanMsg.result.Entries) + } +} + +func TestGoBackRefreshesHistoryEntryNeedingRefresh(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + child := filepath.Join(home, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + freshPath := filepath.Join(child, "fresh.bin") + if err := os.WriteFile(freshPath, []byte("fresh-data-2"), 0o644); err != nil { + t.Fatalf("write fresh file: %v", err) + } + freshInfo, err := os.Stat(freshPath) + if err != nil { + t.Fatalf("stat fresh file: %v", err) + } + freshSize := getActualFileSize(freshPath, freshInfo) + + warmed := scanResult{ + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 2}}, + TotalSize: 2, + TotalFiles: 1, + } + if err := saveCacheToDiskWithOptions(child, warmed, true); err != nil { + t.Fatalf("saveCacheToDiskWithOptions: %v", err) + } + + m := newModel(filepath.Join(child, "grandchild"), false) + m.history = []historyEntry{{ + Path: child, + Entries: []dirEntry{{Name: "stale.bin", Path: filepath.Join(child, "stale.bin"), Size: 2}}, + TotalSize: 2, + TotalFiles: 1, + NeedsRefresh: true, + }} + + updated, cmd := m.goBack() + if cmd == nil { + t.Fatalf("expected stale history entry to trigger a refresh") + } + + got := updated.(model) + if got.path != child { + t.Fatalf("expected path %s after goBack, got %s", child, got.path) + } + if !got.scanning { + t.Fatalf("expected goBack to keep scanning while refreshing stale history entry") + } + if got.totalSize != 2 { + t.Fatalf("expected stale history snapshot to be restored immediately, got %d", got.totalSize) + } + + scanMsg := runScanResultCmd(t, cmd) + if scanMsg.stale { + t.Fatalf("expected stale history navigation to force a fresh scan") + } + if scanMsg.result.TotalSize != freshSize { + t.Fatalf("expected fresh rescan total size %d, got %d", freshSize, scanMsg.result.TotalSize) + } + if scanMsg.result.Entries[0].Name != "fresh.bin" { + t.Fatalf("expected rescan to surface live filesystem contents, got %+v", scanMsg.result.Entries) + } +} + +func TestScanPathConcurrentWarmsChildCacheWithLiveProgress(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := filepath.Join(home, "root") + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + const dirCount = 32 + const filesPerDir = 256 + for i := range dirCount { + dir := filepath.Join(child, fmt.Sprintf("dir-%02d", i)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create nested dir %s: %v", dir, err) + } + for j := range filesPerDir { + file := filepath.Join(dir, fmt.Sprintf("file-%03d.bin", j)) + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatalf("write %s: %v", file, err) + } + } + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + done := make(chan struct{}) + errCh := make(chan error, 1) + go func() { + _, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + errCh <- err + close(done) + }() + + deadline := time.Now().Add(5 * time.Second) + sawLiveProgress := false + for time.Now().Before(deadline) { + if atomic.LoadInt64(&filesScanned) > 0 { + select { + case <-done: + default: + sawLiveProgress = true + } + if sawLiveProgress { + break + } + } + select { + case <-done: + if !sawLiveProgress { + t.Fatalf("expected live progress before child warm scan completed, final files=%d", atomic.LoadInt64(&filesScanned)) + } + default: + } + time.Sleep(2 * time.Millisecond) + } + + if !sawLiveProgress { + t.Fatalf("expected filesScanned to advance before warm child scan finished") + } + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("scanPathConcurrent(root): %v", err) + } + case <-time.After(5 * time.Second): + t.Fatalf("scan did not complete") + } +} + func TestMeasureOverviewSize(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/Resources/mole/cmd/analyze/cache.go b/Resources/mole/cmd/analyze/cache.go index 872a8d5..0de94ff 100644 --- a/Resources/mole/cmd/analyze/cache.go +++ b/Resources/mole/cmd/analyze/cache.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -36,13 +38,36 @@ func snapshotFromModel(m model) historyEntry { EntryOffset: m.offset, LargeSelected: m.largeSelected, LargeOffset: m.largeOffset, + NeedsRefresh: m.viewNeedsRefresh, IsOverview: m.isOverview, } } -func cacheSnapshot(m model) historyEntry { - entry := snapshotFromModel(m) - entry.Dirty = false +func filterNonEmptyEntries(entries []dirEntry) []dirEntry { + filtered := make([]dirEntry, 0, len(entries)) + for _, entry := range entries { + if entry.Size > 0 { + filtered = append(filtered, entry) + } + } + return filtered +} + +func historyEntryFromScanResult(path string, result scanResult, previous historyEntry, needsRefresh bool) historyEntry { + entry := historyEntry{ + Path: path, + Entries: slices.Clone(result.Entries), + LargeFiles: slices.Clone(result.LargeFiles), + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + Selected: previous.Selected, + EntryOffset: previous.EntryOffset, + LargeSelected: previous.LargeSelected, + LargeOffset: previous.LargeOffset, + NeedsRefresh: needsRefresh, + Dirty: false, + IsOverview: previous.IsOverview, + } return entry } @@ -253,6 +278,10 @@ func loadStaleCacheFromDisk(path string) (*cacheEntry, error) { } func saveCacheToDisk(path string, result scanResult) error { + return saveCacheToDiskWithOptions(path, result, false) +} + +func saveCacheToDiskWithOptions(path string, result scanResult, needsRefresh bool) error { cachePath, err := getCachePath(path) if err != nil { return err @@ -264,12 +293,13 @@ func saveCacheToDisk(path string, result scanResult) error { } entry := cacheEntry{ - Entries: result.Entries, - LargeFiles: result.LargeFiles, - TotalSize: result.TotalSize, - TotalFiles: result.TotalFiles, - ModTime: info.ModTime(), - ScanTime: time.Now(), + Entries: result.Entries, + LargeFiles: result.LargeFiles, + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + ModTime: info.ModTime(), + ScanTime: time.Now(), + NeedsRefresh: needsRefresh, } file, err := os.Create(cachePath) diff --git a/Resources/mole/cmd/analyze/cleanable.go b/Resources/mole/cmd/analyze/cleanable.go index 4c80879..5a7f8c5 100644 --- a/Resources/mole/cmd/analyze/cleanable.go +++ b/Resources/mole/cmd/analyze/cleanable.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/constants.go b/Resources/mole/cmd/analyze/constants.go index d400035..bdf79ab 100644 --- a/Resources/mole/cmd/analyze/constants.go +++ b/Resources/mole/cmd/analyze/constants.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import "time" @@ -19,11 +21,15 @@ const ( cacheReuseWindow = 24 * time.Hour staleCacheTTL = 3 * 24 * time.Hour - // Worker pool limits. - minWorkers = 16 - maxWorkers = 64 - cpuMultiplier = 4 - maxDirWorkers = 32 + // Worker pool limits. Deliberately conservative: the App Library scan + // blocks many goroutines in syscalls on high-fan-out trees (Steam + // workshop/temp, browser caches), and each blocked goroutine holds an + // OS thread. Exceeding the per-user thread limit on macOS produces a + // fatal "runtime: failed to create new OS thread" with no recovery. + minWorkers = 4 + maxWorkers = 16 + cpuMultiplier = 1 + maxDirWorkers = 8 openCommandTimeout = 10 * time.Second ) diff --git a/Resources/mole/cmd/analyze/delete.go b/Resources/mole/cmd/analyze/delete.go index 11feaee..00d07b4 100644 --- a/Resources/mole/cmd/analyze/delete.go +++ b/Resources/mole/cmd/analyze/delete.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -6,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "sort" "strings" "sync/atomic" @@ -119,11 +122,21 @@ func trashPathWithProgress(root string, counter *int64) (int64, error) { // moveToTrash uses macOS Finder to move a file/directory to Trash. // This is the safest method as it uses the system's native trash mechanism. func moveToTrash(path string) error { + // Validate raw input before Abs resolves ".." components away. + if err := validatePath(path); err != nil { + return err + } + absPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("failed to resolve path: %w", err) } + // Validate resolved path as well (defense-in-depth). + if err := validatePath(absPath); err != nil { + return err + } + // Escape path for AppleScript (handle quotes and backslashes). escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\") escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"") @@ -144,3 +157,22 @@ func moveToTrash(path string) error { return nil } + +// validatePath checks path safety for external commands. +// Returns error if path is empty, relative, contains null bytes, or has traversal. +func validatePath(path string) error { + if path == "" { + return fmt.Errorf("path is empty") + } + if !filepath.IsAbs(path) { + return fmt.Errorf("path must be absolute: %s", path) + } + if strings.Contains(path, "\x00") { + return fmt.Errorf("path contains null bytes") + } + // Check for path traversal attempts (.. components). + if slices.Contains(strings.Split(path, string(filepath.Separator)), "..") { + return fmt.Errorf("path contains traversal components: %s", path) + } + return nil +} diff --git a/Resources/mole/cmd/analyze/delete_test.go b/Resources/mole/cmd/analyze/delete_test.go index 8d89ae1..f6b011f 100644 --- a/Resources/mole/cmd/analyze/delete_test.go +++ b/Resources/mole/cmd/analyze/delete_test.go @@ -1,8 +1,11 @@ +//go:build darwin + package main import ( "os" "path/filepath" + "strings" "testing" ) @@ -79,3 +82,95 @@ func TestMoveToTrashNonExistent(t *testing.T) { t.Fatal("expected error for non-existent path") } } + +func TestMoveToTrashRejectsTraversal(t *testing.T) { + // Verify the full production path rejects ".." before filepath.Abs resolves it. + err := moveToTrash("/tmp/fakedir/../../../etc/passwd") + if err == nil { + t.Fatal("expected error for path with traversal components") + } + if !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected traversal error, got: %v", err) + } +} + +func TestValidatePath(t *testing.T) { + tests := []struct { + name string + path string + wantErr bool + }{ + // 基本合法路径 + {"absolute path", "/Users/test/file.txt", false}, + {"path with spaces", "/Users/test/My Documents/file.txt", false}, + {"root", "/", false}, + + // 中文路径 + {"chinese path", "/Users/test/中文文件夹/文件.txt", false}, + {"chinese mixed", "/Users/test/Downloads/报告2024.pdf", false}, + + // Emoji 路径 + {"emoji path", "/Users/test/📁文件夹/📝笔记.txt", false}, + {"emoji only", "/Users/test/🎉/🎊.txt", false}, + + // 特殊字符路径 (之前被错误拒绝的) + {"dollar sign", "/Users/test/$HOME/workspace", false}, + {"semicolon", "/Users/test/project;v2", false}, + {"colon", "/Users/test/project:2024", false}, + {"ampersand", "/Users/test/R&D/project", false}, + {"at sign", "/Users/test/user@domain", false}, + {"hash", "/Users/test/project#123", false}, + {"percent", "/Users/test/100% complete", false}, + {"exclamation", "/Users/test/important!.txt", false}, + {"single quote", "/Users/test/user's files", false}, + {"equals", "/Users/test/key=value", false}, + {"plus", "/Users/test/file+v2", false}, + {"brackets", "/Users/test/[2024] report", false}, + {"parentheses", "/Users/test/project (copy)", false}, + {"comma", "/Users/test/file, backup", false}, + + // 非法路径 + {"empty", "", true}, + {"relative", "relative/path", true}, + {"relative dot", "./file.txt", true}, + {"null byte", "/Users/test\x00/file", true}, + {"path traversal", "/Users/test/../../../etc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("validatePath(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr) + } + }) + } +} + +func TestValidatePathWithChineseAndSpecialChars(t *testing.T) { + // 专门测试之前会导致兼容性回退的路径 + parent := t.TempDir() + testCases := []struct { + name string + path string + }{ + {"chinese", "中文文件夹"}, + {"emoji", "📁 文档"}, + {"mixed", "报告-2024_v2 (终稿) [已审核]"}, + {"special", "Project$2024; Q1: R&D"}, + {"complex", "用户@公司 100% 完成!"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fullPath := filepath.Join(parent, tc.path) + if err := os.MkdirAll(fullPath, 0o755); err != nil { + t.Fatalf("mkdir %q: %v", tc.path, err) + } + + if err := validatePath(fullPath); err != nil { + t.Errorf("validatePath rejected valid path %q: %v", tc.path, err) + } + }) + } +} diff --git a/Resources/mole/cmd/analyze/format.go b/Resources/mole/cmd/analyze/format.go index 371539b..26d589e 100644 --- a/Resources/mole/cmd/analyze/format.go +++ b/Resources/mole/cmd/analyze/format.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/format_test.go b/Resources/mole/cmd/analyze/format_test.go index 65a8333..96241f3 100644 --- a/Resources/mole/cmd/analyze/format_test.go +++ b/Resources/mole/cmd/analyze/format_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -20,7 +22,7 @@ func TestRuneWidth(t *testing.T) { {"CJK ideograph", '語', 2}, {"Full-width number", '1', 2}, {"ASCII space", ' ', 1}, - {"Tab", '\t', 1}, + {"Tab", ' ', 1}, } for _, tt := range tests { diff --git a/Resources/mole/cmd/analyze/heap.go b/Resources/mole/cmd/analyze/heap.go index 0b4a5a5..919ad1a 100644 --- a/Resources/mole/cmd/analyze/heap.go +++ b/Resources/mole/cmd/analyze/heap.go @@ -1,3 +1,5 @@ +//go:build darwin + package main // entryHeap is a min-heap of dirEntry used to keep Top N largest entries. diff --git a/Resources/mole/cmd/analyze/heap_test.go b/Resources/mole/cmd/analyze/heap_test.go index 77408fc..12a1341 100644 --- a/Resources/mole/cmd/analyze/heap_test.go +++ b/Resources/mole/cmd/analyze/heap_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/insights.go b/Resources/mole/cmd/analyze/insights.go new file mode 100644 index 0000000..e38393f --- /dev/null +++ b/Resources/mole/cmd/analyze/insights.go @@ -0,0 +1,177 @@ +//go:build darwin + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// createInsightEntries returns the list of hidden-space insight entries +// to show in the overview screen alongside the standard directory entries. +func createInsightEntries() []dirEntry { + home := os.Getenv("HOME") + if home == "" { + return nil + } + + var entries []dirEntry + + // iOS Backups — ~/Library/Application Support/MobileSync/Backup + backupPath := filepath.Join(home, "Library", "Application Support", "MobileSync", "Backup") + if info, err := os.Stat(backupPath); err == nil && info.IsDir() { + entries = append(entries, dirEntry{ + Name: "iOS Backups", + Path: backupPath, + IsDir: true, + Size: -1, + }) + } + + // Old Downloads — ~/Downloads (files older than 90 days) + downloadsPath := filepath.Join(home, "Downloads") + if info, err := os.Stat(downloadsPath); err == nil && info.IsDir() { + entries = append(entries, dirEntry{ + Name: "Old Downloads (90d+)", + Path: downloadsPath, + IsDir: true, + Size: -1, + }) + } + + // Cleanable paths — things mo clean can remove or the user can safely delete. + // System Caches (~Library/Caches) is intentionally omitted here because the + // specific cache subdirectories below are already its children; listing both + // would double-count the same bytes. + cleanablePaths := []struct { + name string + path string + }{ + // Universal (everyone has these) + {"System Logs", filepath.Join(home, "Library", "Logs")}, + {"Homebrew Cache", filepath.Join(home, "Library", "Caches", "Homebrew")}, + + // Developer-specific (only shown if path exists) + {"Xcode DerivedData", filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")}, + {"Xcode Simulators", filepath.Join(home, "Library", "Developer", "CoreSimulator", "Devices")}, + {"Xcode Archives", filepath.Join(home, "Library", "Developer", "Xcode", "Archives")}, + {"Spotify Cache", filepath.Join(home, "Library", "Application Support", "Spotify", "PersistentCache")}, + {"JetBrains Cache", filepath.Join(home, "Library", "Caches", "JetBrains")}, + {"Docker Data", filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data")}, + {"pip Cache", filepath.Join(home, "Library", "Caches", "pip")}, + {"Gradle Cache", filepath.Join(home, ".gradle", "caches")}, + {"CocoaPods Cache", filepath.Join(home, "Library", "Caches", "CocoaPods")}, + } + for _, c := range cleanablePaths { + if info, err := os.Stat(c.path); err == nil && info.IsDir() { + entries = append(entries, dirEntry{ + Name: c.name, + Path: c.path, + IsDir: true, + Size: -1, + }) + } + } + + return entries +} + +// measureInsightSize measures the size of a path. +// Old Downloads is treated specially: only files older than 90 days are counted. +func measureInsightSize(path string) (int64, error) { + home := os.Getenv("HOME") + + if home != "" && path == filepath.Join(home, "Downloads") { + return measureOldDownloads(path, 90) + } + + return measureOverviewSize(path) +} + +// measureOldDownloads calculates total size of files in a directory +// that haven't been modified in the given number of days. +func measureOldDownloads(dir string, daysOld int) (int64, error) { + cutoff := time.Now().AddDate(0, 0, -daysOld) + var total int64 + + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + + for _, entry := range entries { + // Skip hidden files. + if strings.HasPrefix(entry.Name(), ".") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + if info.ModTime().Before(cutoff) { + if entry.IsDir() { + // Use du for directories. + if size, err := getDirSizeFast(filepath.Join(dir, entry.Name())); err == nil { + total += size + } + } else { + total += info.Size() + } + } + } + + return total, nil +} + +// insightIcon returns an appropriate icon for an overview entry. +func insightIcon(entry dirEntry) string { + switch entry.Name { + case "iOS Backups": + return "📱" + case "Old Downloads (90d+)": + return "📥" + case "Homebrew Cache", "pip Cache", "CocoaPods Cache", "Gradle Cache", "Spotify Cache", "JetBrains Cache": + return "💾" + case "System Logs": + return "📋" + case "Xcode DerivedData", "Xcode Archives": + return "🔨" + case "Xcode Simulators": + return "📲" + case "Docker Data": + return "🐳" + default: + return "📁" + } +} + +// getDirSizeFast measures directory size using du. +func getDirSizeFast(path string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "du", "-sk", path) + output, err := cmd.Output() + if err != nil { + return 0, err + } + + fields := strings.Fields(string(output)) + if len(fields) == 0 { + return 0, nil + } + + kb, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return 0, err + } + + return kb * 1024, nil +} diff --git a/Resources/mole/cmd/analyze/insights_test.go b/Resources/mole/cmd/analyze/insights_test.go new file mode 100644 index 0000000..bafaa5a --- /dev/null +++ b/Resources/mole/cmd/analyze/insights_test.go @@ -0,0 +1,109 @@ +//go:build darwin + +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCreateInsightEntries(t *testing.T) { + entries := createInsightEntries() + // Should return at least some entries on a real Mac. + // iOS Backups may not exist, but Old Downloads and Mail Data likely do. + if len(entries) == 0 { + t.Log("No insight entries found (some paths may not exist on this machine)") + } + + // Verify all entries have required fields. + for _, e := range entries { + if e.Name == "" { + t.Error("insight entry has empty Name") + } + if e.Path == "" { + t.Error("insight entry has empty Path") + } + if e.Size != -1 { + t.Errorf("insight entry %q should have Size=-1 (pending), got %d", e.Name, e.Size) + } + if !e.IsDir { + t.Errorf("insight entry %q should be a directory", e.Name) + } + } +} + +func TestInsightIcon(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"iOS Backups", "📱"}, + {"Old Downloads (90d+)", "📥"}, + {"Homebrew Cache", "💾"}, + {"System Logs", "📋"}, + {"Xcode Simulators", "📲"}, + {"Docker Data", "🐳"}, + {"Home", "📁"}, + {"Applications", "📁"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := insightIcon(dirEntry{Name: tt.name}) + if got != tt.want { + t.Errorf("insightIcon(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestMeasureOldDownloads(t *testing.T) { + // Create a temp directory with old and new files. + dir := t.TempDir() + + // Create an old file (set mtime to 100 days ago). + oldFile := filepath.Join(dir, "old.txt") + if err := os.WriteFile(oldFile, []byte("old content here"), 0644); err != nil { + t.Fatal(err) + } + oldTime := time.Now().AddDate(0, 0, -100) + os.Chtimes(oldFile, oldTime, oldTime) + + // Create a new file. + newFile := filepath.Join(dir, "new.txt") + if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil { + t.Fatal(err) + } + + size, err := measureOldDownloads(dir, 90) + if err != nil { + t.Fatalf("measureOldDownloads: %v", err) + } + + if size == 0 { + t.Error("expected non-zero size for old files") + } + + // Size should be approximately the size of old.txt (16 bytes) but not new.txt. + if size > 1024 { + t.Errorf("size %d seems too large for a 16-byte file", size) + } +} + +func TestMeasureInsightSizeFallsBackToOverview(t *testing.T) { + // For a non-Downloads path, measureInsightSize should use measureOverviewSize. + dir := t.TempDir() + testFile := filepath.Join(dir, "test.dat") + if err := os.WriteFile(testFile, make([]byte, 4096), 0644); err != nil { + t.Fatal(err) + } + + size, err := measureInsightSize(dir) + if err != nil { + t.Fatalf("measureInsightSize: %v", err) + } + if size == 0 { + t.Error("expected non-zero size") + } +} diff --git a/Resources/mole/cmd/analyze/json.go b/Resources/mole/cmd/analyze/json.go index 1c2ab44..46227f3 100644 --- a/Resources/mole/cmd/analyze/json.go +++ b/Resources/mole/cmd/analyze/json.go @@ -6,25 +6,38 @@ import ( "encoding/json" "fmt" "os" + "sort" "sync/atomic" + "time" ) type jsonOutput struct { - Path string `json:"path"` - Entries []jsonEntry `json:"entries"` - TotalSize int64 `json:"total_size"` - TotalFiles int64 `json:"total_files"` + Path string `json:"path"` + Overview bool `json:"overview"` + Entries []jsonEntry `json:"entries"` + LargeFiles []jsonFileEntry `json:"large_files,omitempty"` + TotalSize int64 `json:"total_size"` + TotalFiles int64 `json:"total_files,omitempty"` } type jsonEntry struct { - Name string `json:"name"` - Path string `json:"path"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + Insight bool `json:"insight,omitempty"` + Cleanable bool `json:"cleanable,omitempty"` + LastAccess string `json:"last_access,omitempty"` +} + +type jsonFileEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` } func runJSONMode(path string, isOverview bool) { - result := performScanForJSON(path) + result := performScanForJSON(path, isOverview) encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") @@ -34,48 +47,109 @@ func runJSONMode(path string, isOverview bool) { } } -func performScanForJSON(path string) jsonOutput { +func performScanForJSON(path string, isOverview bool) jsonOutput { + if isOverview { + return performOverviewScanForJSON(path) + } + return performDirectoryScanForJSON(path) +} + +func performDirectoryScanForJSON(path string) jsonOutput { var filesScanned, dirsScanned, bytesScanned int64 currentPath := &atomic.Value{} currentPath.Store("") - items, err := os.ReadDir(path) + result, err := scanPathConcurrentAllEntries(path, &filesScanned, &dirsScanned, &bytesScanned, currentPath) if err != nil { - fmt.Fprintf(os.Stderr, "failed to read directory: %v\n", err) + fmt.Fprintf(os.Stderr, "failed to scan directory: %v\n", err) os.Exit(1) } - var entries []jsonEntry - var totalSize int64 + return jsonOutput{ + Path: path, + Overview: false, + Entries: jsonEntriesFromDirEntries(result.Entries, false, nil), + LargeFiles: jsonFileEntriesFromFileEntries(result.LargeFiles), + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + } +} - for _, item := range items { - fullPath := path + "/" + item.Name() - var size int64 +func performOverviewScanForJSON(path string) jsonOutput { + overviewEntries := createOverviewEntries() + insightPaths := make(map[string]bool, len(createInsightEntries())) + for _, insight := range createInsightEntries() { + insightPaths[insight.Path] = true + } - if item.IsDir() { - size = calculateDirSizeFast(fullPath, &filesScanned, &dirsScanned, &bytesScanned, currentPath) + var totalSize int64 + entries := make([]dirEntry, 0, len(overviewEntries)) + for _, entry := range overviewEntries { + var ( + size int64 + err error + ) + + if cached, cacheErr := loadOverviewCachedSize(entry.Path); cacheErr == nil && cached > 0 { + size = cached + } else if insightPaths[entry.Path] { + size, err = measureInsightSize(entry.Path) } else { - info, err := item.Info() - if err == nil { - size = info.Size() - atomic.AddInt64(&filesScanned, 1) - atomic.AddInt64(&bytesScanned, size) - } + size, err = measureOverviewSize(entry.Path) + } + + if err == nil { + entry.Size = size } - totalSize += size - entries = append(entries, jsonEntry{ - Name: item.Name(), - Path: fullPath, - Size: size, - IsDir: item.IsDir(), - }) + // Match the TUI: omit scanned insight/tool entries that ended up empty. + if entry.Size == 0 { + continue + } + totalSize += entry.Size + entries = append(entries, entry) } + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Size > entries[j].Size + }) + return jsonOutput{ - Path: path, - Entries: entries, - TotalSize: totalSize, - TotalFiles: atomic.LoadInt64(&filesScanned), + Path: path, + Overview: true, + Entries: jsonEntriesFromDirEntries(entries, true, insightPaths), + TotalSize: totalSize, + } +} + +func jsonEntriesFromDirEntries(entries []dirEntry, isOverview bool, insightPaths map[string]bool) []jsonEntry { + output := make([]jsonEntry, 0, len(entries)) + for _, entry := range entries { + item := jsonEntry{ + Name: entry.Name, + Path: entry.Path, + Size: entry.Size, + IsDir: entry.IsDir, + Cleanable: entry.IsDir && isCleanableDir(entry.Path), + } + + if isOverview { + item.Insight = insightPaths[entry.Path] + } + + if !entry.LastAccess.IsZero() { + item.LastAccess = entry.LastAccess.UTC().Format(time.RFC3339) + } + + output = append(output, item) + } + return output +} + +func jsonFileEntriesFromFileEntries(files []fileEntry) []jsonFileEntry { + output := make([]jsonFileEntry, 0, len(files)) + for _, f := range files { + output = append(output, jsonFileEntry(f)) } + return output } diff --git a/Resources/mole/cmd/analyze/json_test.go b/Resources/mole/cmd/analyze/json_test.go new file mode 100644 index 0000000..53a3f0d --- /dev/null +++ b/Resources/mole/cmd/analyze/json_test.go @@ -0,0 +1,101 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPerformScanForJSONIncludesAllEntriesAndLargeFiles(t *testing.T) { + root := t.TempDir() + + totalFiles := maxEntries + 6 + for i := 0; i < totalFiles-1; i++ { + path := filepath.Join(root, fmt.Sprintf("small-%02d.txt", i)) + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write small file %d: %v", i, err) + } + } + + hugeFile := filepath.Join(root, "huge.bin") + if err := os.WriteFile(hugeFile, make([]byte, 2<<20), 0o644); err != nil { + t.Fatalf("write huge file: %v", err) + } + + result := performScanForJSON(root, false) + + if result.Overview { + t.Fatalf("expected non-overview JSON result") + } + if got := len(result.Entries); got != totalFiles { + t.Fatalf("expected %d entries, got %d", totalFiles, got) + } + if result.TotalFiles != int64(totalFiles) { + t.Fatalf("expected %d total files, got %d", totalFiles, result.TotalFiles) + } + if len(result.LargeFiles) == 0 { + t.Fatalf("expected large_files to include the large file") + } + + foundHuge := false + for _, file := range result.LargeFiles { + if file.Name == "huge.bin" && file.Path == hugeFile { + foundHuge = true + break + } + } + if !foundHuge { + t.Fatalf("expected huge.bin in large_files, got %#v", result.LargeFiles) + } +} + +func TestJSONEntriesFromDirEntriesIncludesMetadata(t *testing.T) { + oldAccess := time.Now().AddDate(0, 0, -120) + + entries := jsonEntriesFromDirEntries([]dirEntry{ + { + Name: "old.bin", + Path: "/tmp/old.bin", + Size: 42, + IsDir: false, + LastAccess: oldAccess, + }, + { + Name: "node_modules", + Path: "/tmp/project/node_modules", + Size: 128, + IsDir: true, + }, + }, false, nil) + + if entries[0].LastAccess == "" { + t.Fatalf("expected last_access to be populated") + } + if entries[1].Cleanable != true { + t.Fatalf("expected node_modules entry to be marked cleanable") + } +} + +func TestJSONEntriesFromDirEntriesMarksOverviewInsights(t *testing.T) { + entry := dirEntry{ + Name: "Old Downloads (90d+)", + Path: "/tmp/test-home/Downloads", + Size: 256, + IsDir: true, + } + + entries := jsonEntriesFromDirEntries([]dirEntry{entry}, true, map[string]bool{ + entry.Path: true, + }) + + if len(entries) != 1 { + t.Fatalf("expected one entry, got %d", len(entries)) + } + if !entries[0].Insight { + t.Fatalf("expected entry to be marked as insight") + } +} diff --git a/Resources/mole/cmd/analyze/main.go b/Resources/mole/cmd/analyze/main.go index c8ed030..eb9d7b0 100644 --- a/Resources/mole/cmd/analyze/main.go +++ b/Resources/mole/cmd/analyze/main.go @@ -12,6 +12,7 @@ import ( "slices" "sort" "sync/atomic" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -43,12 +44,13 @@ type scanResult struct { } type cacheEntry struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - ModTime time.Time - ScanTime time.Time + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + ModTime time.Time + ScanTime time.Time + NeedsRefresh bool } type historyEntry struct { @@ -62,6 +64,7 @@ type historyEntry struct { LargeSelected int LargeOffset int Dirty bool + NeedsRefresh bool IsOverview bool } @@ -125,6 +128,8 @@ type model struct { largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) totalFiles int64 // Total files found in current/last scan lastTotalFiles int64 // Total files from previous scan (for progress bar) + diskFree int64 // Free disk space for the analyzed volume + viewNeedsRefresh bool } func (m model) inOverviewMode() bool { @@ -181,11 +186,17 @@ func newModel(path string, isOverview bool) model { currentPath.Store("") var overviewFilesScanned, overviewDirsScanned, overviewBytesScanned int64 overviewCurrentPath := "" + var diskFreeBytes int64 + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err == nil { + diskFreeBytes = int64(stat.Bavail) * int64(stat.Bsize) + } m := model{ path: path, selected: 0, status: "Preparing scan...", + diskFree: diskFreeBytes, scanning: !isOverview, filesScanned: &filesScanned, dirsScanned: &dirsScanned, @@ -246,6 +257,9 @@ func createOverviewEntries() []dirEntry { dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1}, ) + // Hidden space insights — paths that silently accumulate disk usage. + entries = append(entries, createInsightEntries()...) + return entries } @@ -355,6 +369,9 @@ func (m model) scanCmd(path string) tea.Cmd { TotalSize: cached.TotalSize, TotalFiles: cached.TotalFiles, } + if cached.NeedsRefresh { + return scanResultMsg{path: path, result: result, err: nil, stale: true} + } return scanResultMsg{path: path, result: result, err: nil} } @@ -458,6 +475,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case scanResultMsg: if msg.path != "" && msg.path != m.path { + if msg.err == nil { + filteredEntries := filterNonEmptyEntries(msg.result.Entries) + result := msg.result + result.Entries = filteredEntries + m.cache[msg.path] = historyEntryFromScanResult(msg.path, result, m.cache[msg.path], msg.stale) + } return m, nil } m.scanning = false @@ -465,19 +488,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = fmt.Sprintf("Scan failed: %v", msg.err) return m, nil } - filteredEntries := make([]dirEntry, 0, len(msg.result.Entries)) - for _, e := range msg.result.Entries { - if e.Size > 0 { - filteredEntries = append(filteredEntries, e) - } - } + filteredEntries := filterNonEmptyEntries(msg.result.Entries) + result := msg.result + result.Entries = filteredEntries m.entries = filteredEntries m.largeFiles = msg.result.LargeFiles m.totalSize = msg.result.TotalSize m.totalFiles = msg.result.TotalFiles + m.viewNeedsRefresh = msg.stale m.clampEntrySelection() m.clampLargeSelection() - m.cache[m.path] = cacheSnapshot(m) + m.cache[m.path] = historyEntryFromScanResult(m.path, result, m.cache[m.path], msg.stale) if m.totalSize > 0 { if m.overviewSizeCache == nil { m.overviewSizeCache = make(map[string]int64) @@ -612,20 +633,22 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.deleteConfirm = false m.deleteTarget = nil return m, nil + case "ctrl+c": + return m, tea.Quit default: return m, nil } } switch msg.String() { - case "q", "ctrl+c", "Q": + case "q", "Q", "ctrl+c": return m, tea.Quit case "esc": if m.showLargeFiles { m.showLargeFiles = false return m, nil } - return m, tea.Quit + return m.goBack() case "up", "k", "K": if m.showLargeFiles { if m.largeSelected > 0 { @@ -635,7 +658,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } else if len(m.entries) > 0 && m.selected > 0 { - m.selected-- + next := m.selected - 1 + for next > 0 && m.entries[next].Size == 0 { + next-- + } + m.selected = next if m.selected < m.offset { m.offset = m.selected } @@ -650,7 +677,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { - m.selected++ + next := m.selected + 1 + for next < len(m.entries)-1 && m.entries[next].Size == 0 { + next++ + } + m.selected = next viewport := calculateViewport(m.height, false) if m.selected >= m.offset+viewport { m.offset = m.selected - viewport + 1 @@ -666,53 +697,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showLargeFiles = false return m, nil } - if len(m.history) == 0 { - if !m.inOverviewMode() { - return m, m.switchToOverviewMode() - } - return m, nil - } - last := m.history[len(m.history)-1] - m.history = m.history[:len(m.history)-1] - m.path = last.Path - m.selected = last.Selected - m.offset = last.EntryOffset - m.largeSelected = last.LargeSelected - m.largeOffset = last.LargeOffset - m.isOverview = last.IsOverview - if last.Dirty { - // On overview return, refresh cached entries. - if last.IsOverview { - m.hydrateOverviewEntries() - m.totalSize = sumKnownEntrySizes(m.entries) - m.status = "Ready" - m.scanning = false - if nextPendingOverviewIndex(m.entries) >= 0 { - m.overviewScanning = true - return m, m.scheduleOverviewScans() - } - return m, nil - } - m.status = "Scanning..." - m.scanning = true - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - m.entries = last.Entries - m.largeFiles = last.LargeFiles - m.totalSize = last.TotalSize - m.clampEntrySelection() - m.clampLargeSelection() - if len(m.entries) == 0 { - m.selected = 0 - } else if m.selected >= len(m.entries) { - m.selected = len(m.entries) - 1 - } - if m.selected < 0 { - m.selected = 0 - } - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - m.scanning = false - return m, nil + return m.goBack() case "r", "R": m.multiSelected = make(map[string]bool) m.largeMultiSelected = make(map[string]bool) @@ -775,18 +760,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } for path := range m.largeMultiSelected { go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() + _ = safeOpen(p, false) }(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() + _ = safeOpen(path, false) }(selected.Path) m.status = fmt.Sprintf("Opening %s...", selected.Name) } @@ -800,18 +781,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } for path := range m.multiSelected { go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() + _ = safeOpen(p, false) }(path) } m.status = fmt.Sprintf("Opening %d items...", count) } else { selected := m.entries[m.selected] go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() + _ = safeOpen(path, false) }(selected.Path) m.status = fmt.Sprintf("Opening %s...", selected.Name) } @@ -829,18 +806,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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() + _ = safeOpen(p, true) }(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() + _ = safeOpen(path, true) }(selected.Path) m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) } @@ -854,22 +827,37 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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() + _ = safeOpen(p, true) }(path) } m.status = fmt.Sprintf("Showing %d items in Finder...", count) } else { selected := m.entries[m.selected] go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() + _ = safeOpen(path, true) }(selected.Path) m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) } } + case "p", "P": + // Quick Look preview (single file only, no multi-select). + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = safePreview(path) + }(selected.Path) + m.status = fmt.Sprintf("Previewing %s...", selected.Name) + } + } else if len(m.entries) > 0 { + selected := m.entries[m.selected] + if !selected.IsDir { + go func(path string) { + _ = safePreview(path) + }(selected.Path) + m.status = fmt.Sprintf("Previewing %s...", selected.Name) + } + } case " ": // Toggle multi-select (paths as keys). if m.showLargeFiles { @@ -978,6 +966,73 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m model) goBack() (tea.Model, tea.Cmd) { + if len(m.history) == 0 { + if !m.inOverviewMode() { + return m, m.switchToOverviewMode() + } + return m, tea.Quit + } + + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.path = last.Path + m.selected = last.Selected + m.offset = last.EntryOffset + m.largeSelected = last.LargeSelected + m.largeOffset = last.LargeOffset + m.isOverview = last.IsOverview + if last.Dirty { + // On overview return, refresh cached entries. + if last.IsOverview { + m.hydrateOverviewEntries() + m.totalSize = sumKnownEntrySizes(m.entries) + m.status = "Ready" + m.scanning = false + if nextPendingOverviewIndex(m.entries) >= 0 { + m.overviewScanning = true + return m, m.scheduleOverviewScans() + } + return m, nil + } + m.status = "Scanning..." + m.scanning = true + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + m.entries = last.Entries + m.largeFiles = last.LargeFiles + m.totalSize = last.TotalSize + m.totalFiles = last.TotalFiles + m.viewNeedsRefresh = last.NeedsRefresh + m.clampEntrySelection() + m.clampLargeSelection() + if len(m.entries) == 0 { + m.selected = 0 + } else if m.selected >= len(m.entries) { + m.selected = len(m.entries) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + if last.NeedsRefresh { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + m.scanning = false + return m, nil +} + func (m *model) switchToOverviewMode() tea.Cmd { m.isOverview = true m.path = "/" @@ -1014,6 +1069,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { m.status = "Scanning..." m.scanning = true m.isOverview = false + m.viewNeedsRefresh = false m.multiSelected = make(map[string]bool) m.largeMultiSelected = make(map[string]bool) @@ -1029,12 +1085,21 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { m.largeFiles = slices.Clone(cached.LargeFiles) m.totalSize = cached.TotalSize m.totalFiles = cached.TotalFiles + m.viewNeedsRefresh = cached.NeedsRefresh m.selected = cached.Selected m.offset = cached.EntryOffset m.largeSelected = cached.LargeSelected m.largeOffset = cached.LargeOffset m.clampEntrySelection() m.clampLargeSelection() + if cached.NeedsRefresh { + m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path)) + m.scanning = true + if m.totalFiles > 0 { + m.lastTotalFiles = m.totalFiles + } + return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd()) + } m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) m.scanning = false return m, nil @@ -1163,7 +1228,7 @@ func (m *model) removePathFromView(path string) { func scanOverviewPathCmd(path string, index int) tea.Cmd { return func() tea.Msg { - size, err := measureOverviewSize(path) + size, err := measureInsightSize(path) return overviewSizeMsg{ Path: path, Index: index, @@ -1172,3 +1237,27 @@ func scanOverviewPathCmd(path string, index int) tea.Cmd { } } } + +// safeOpen executes 'open' command with path validation. +func safeOpen(path string, reveal bool) error { + if err := validatePath(path); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + args := []string{path} + if reveal { + args = []string{"-R", path} + } + return exec.CommandContext(ctx, "open", args...).Run() +} + +// safePreview opens the file with the default macOS application. +func safePreview(path string) error { + if err := validatePath(path); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + return exec.CommandContext(ctx, "open", path).Run() +} diff --git a/Resources/mole/cmd/analyze/main_stub.go b/Resources/mole/cmd/analyze/main_stub.go new file mode 100644 index 0000000..89bd0e0 --- /dev/null +++ b/Resources/mole/cmd/analyze/main_stub.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "analyze is only supported on macOS") + os.Exit(1) +} diff --git a/Resources/mole/cmd/analyze/scanner.go b/Resources/mole/cmd/analyze/scanner.go index f22387b..c43812d 100644 --- a/Resources/mole/cmd/analyze/scanner.go +++ b/Resources/mole/cmd/analyze/scanner.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( @@ -10,6 +12,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strconv" "strings" "sync" @@ -59,6 +62,14 @@ func trySend[T any](ch chan<- T, item T, timeout time.Duration) bool { } func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { + return scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, true, maxEntries) +} + +func scanPathConcurrentAllEntries(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { + return scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, true, 0) +} + +func scanPathConcurrentWithOptions(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, useSpotlight bool, entryLimit int) (scanResult, error) { children, err := os.ReadDir(root) if err != nil { return scanResult{}, err @@ -67,10 +78,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in var total int64 var localFilesScanned int64 var localBytesScanned int64 + var subtreeFilesScanned atomic.Int64 + + collectAllEntries := entryLimit <= 0 + var collectedEntries []dirEntry - // Keep Top N heaps. + // Keep Top N heaps when a limit is requested. entriesHeap := &entryHeap{} - heap.Init(entriesHeap) + if !collectAllEntries { + heap.Init(entriesHeap) + } largeFilesHeap := &largeFileHeap{} heap.Init(largeFilesHeap) @@ -95,7 +112,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in go func() { defer collectorWg.Done() for entry := range entryChan { - if entriesHeap.Len() < maxEntries { + if collectAllEntries { + collectedEntries = append(collectedEntries, entry) + continue + } + + if entriesHeap.Len() < entryLimit { heap.Push(entriesHeap, entry) } else if entry.Size > (*entriesHeap)[0].Size { heap.Pop(entriesHeap) @@ -171,21 +193,22 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - var size int64 + result := scanResult{} if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 { - size = cached - } else if cached, err := loadCacheFromDisk(path); err == nil { - size = cached.TotalSize + result.TotalSize = cached } else { - size = calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + result = scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + } + atomic.AddInt64(&total, result.TotalSize) + if result.TotalFiles > 0 { + subtreeFilesScanned.Add(result.TotalFiles) } - atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ Name: name, Path: path, - Size: size, + Size: result.TotalSize, IsDir: true, LastAccess: time.Time{}, }, 100*time.Millisecond) @@ -229,14 +252,17 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in defer wg.Done() defer func() { <-sem }() - size := calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) + result := scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + atomic.AddInt64(&total, result.TotalSize) + if result.TotalFiles > 0 { + subtreeFilesScanned.Add(result.TotalFiles) + } atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ Name: name, Path: path, - Size: size, + Size: result.TotalSize, IsDir: true, LastAccess: time.Time{}, }, 100*time.Millisecond) @@ -286,9 +312,17 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in collectorWg.Wait() // Convert heaps to sorted slices (descending). - entries := make([]dirEntry, entriesHeap.Len()) - for i := len(entries) - 1; i >= 0; i-- { - entries[i] = heap.Pop(entriesHeap).(dirEntry) + var entries []dirEntry + if collectAllEntries { + entries = append(entries, collectedEntries...) + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Size > entries[j].Size + }) + } else { + entries = make([]dirEntry, entriesHeap.Len()) + for i := len(entries) - 1; i >= 0; i-- { + entries[i] = heap.Pop(entriesHeap).(dirEntry) + } } largeFiles := make([]fileEntry, largeFilesHeap.Len()) @@ -297,18 +331,63 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in } // Use Spotlight for large files when it expands the list. - if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) { - largeFiles = spotlightFiles + if useSpotlight { + if spotlightFiles := findLargeFilesWithSpotlight(root, spotlightMinFileSize); len(spotlightFiles) > len(largeFiles) { + largeFiles = spotlightFiles + } } return scanResult{ Entries: entries, LargeFiles: largeFiles, TotalSize: total, - TotalFiles: atomic.LoadInt64(filesScanned), + TotalFiles: localFilesScanned + subtreeFilesScanned.Load(), }, nil } +func publishLargeFiles(files []fileEntry, largeFileChan chan<- fileEntry) { + for _, file := range files { + trySend(largeFileChan, file, 100*time.Millisecond) + } +} + +func loadCachedSubdirResult(path string, largeFileChan chan<- fileEntry) (scanResult, bool) { + cached, err := loadCacheFromDisk(path) + if err != nil { + return scanResult{}, false + } + + result := scanResult{ + Entries: cached.Entries, + LargeFiles: cached.LargeFiles, + TotalSize: cached.TotalSize, + TotalFiles: cached.TotalFiles, + } + publishLargeFiles(result.LargeFiles, largeFileChan) + return result, true +} + +func scanSubdirWithCache(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) scanResult { + if cached, ok := loadCachedSubdirResult(root, largeFileChan); ok { + if cached.TotalFiles > 0 { + atomic.AddInt64(filesScanned, cached.TotalFiles) + } + if cached.TotalSize > 0 { + atomic.AddInt64(bytesScanned, cached.TotalSize) + } + return cached + } + + result, err := scanPathConcurrentWithOptions(root, filesScanned, dirsScanned, bytesScanned, currentPath, false, maxEntries) + if err == nil { + publishLargeFiles(result.LargeFiles, largeFileChan) + _ = saveCacheToDiskWithOptions(root, result, true) + return result + } + + return scanResult{TotalSize: calculateDirSizeConcurrent(root, largeFileChan, largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)} +} + func shouldFoldDirWithPath(name, path string) bool { if foldDirs[name] { return true @@ -341,7 +420,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - concurrency := min(runtime.NumCPU()*4, 64) + concurrency := min(runtime.NumCPU()*cpuMultiplier, maxWorkers) sem := make(chan struct{}, concurrency) var walk func(string) @@ -407,6 +486,16 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * // Use Spotlight (mdfind) to quickly find large files. func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { + // Validate root path. + if err := validatePath(root); err != nil { + return nil + } + + // Validate minSize is reasonable (non-negative and not excessively large). + if minSize < 0 || minSize > 1<<50 { // 1 PB max + return nil + } + query := fmt.Sprintf("kMDItemFSSize >= %d", minSize) ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout) @@ -610,12 +699,12 @@ func measureOverviewSize(path string) (int64, error) { excludePath = filepath.Join(home, "Library") } - if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 { + if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil { _ = storeOverviewSize(path, duSize) return duSize, nil } - if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil && logicalSize > 0 { + if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil { _ = storeOverviewSize(path, logicalSize) return logicalSize, nil } @@ -633,6 +722,16 @@ func getDirectorySizeFromDu(path string) (int64, error) { } func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) { + // Validate paths. + if err := validatePath(path); err != nil { + return 0, err + } + if excludePath != "" { + if err := validatePath(excludePath); err != nil { + return 0, err + } + } + runDuSize := func(target string) (int64, error) { if _, err := os.Stat(target); err != nil { return 0, err diff --git a/Resources/mole/cmd/analyze/scanner_test.go b/Resources/mole/cmd/analyze/scanner_test.go index 718d276..c5a7557 100644 --- a/Resources/mole/cmd/analyze/scanner_test.go +++ b/Resources/mole/cmd/analyze/scanner_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/test_helpers_test.go b/Resources/mole/cmd/analyze/test_helpers_test.go index 9490833..a84fe1a 100644 --- a/Resources/mole/cmd/analyze/test_helpers_test.go +++ b/Resources/mole/cmd/analyze/test_helpers_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package main import ( diff --git a/Resources/mole/cmd/analyze/view.go b/Resources/mole/cmd/analyze/view.go index d696969..ddfaecf 100644 --- a/Resources/mole/cmd/analyze/view.go +++ b/Resources/mole/cmd/analyze/view.go @@ -14,7 +14,11 @@ func (m model) View() string { fmt.Fprintln(&b) if m.inOverviewMode() { - fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset) + freeLabel := "" + if m.diskFree > 0 { + freeLabel = fmt.Sprintf(" %s(%s free)%s", colorGray, humanizeBytes(m.diskFree), colorReset) + } + fmt.Fprintf(&b, "%sAnalyze Disk%s%s\n", colorPurpleBold, colorReset, freeLabel) if m.overviewScanning { allPending := true for _, entry := range m.entries { @@ -168,10 +172,16 @@ func (m model) View() string { } totalSize := m.totalSize // Overview paths are short; fixed width keeps layout stable. - nameWidth := 20 + nameWidth := 22 + displayNum := 0 for idx, entry := range m.entries { - icon := "📁" + icon := insightIcon(entry) sizeVal := entry.Size + // Hide entries that have been scanned and are empty (standard dirs + // are never 0 bytes; only insight dirs in unused tool paths are). + if sizeVal == 0 { + continue + } barValue := max(sizeVal, 0) var percent float64 if totalSize > 0 && sizeVal >= 0 { @@ -214,7 +224,8 @@ func (m model) View() string { percentColor = colorCyan sizeColor = colorCyan } - displayIndex := idx + 1 + displayNum++ + displayIndex := displayNum var hintLabel string if entry.IsDir && isCleanableDir(entry.Path) { @@ -327,31 +338,31 @@ func (m model) View() string { fmt.Fprintln(&b) if m.inOverviewMode() { 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 | P Preview | F File | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) } 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 | P Preview | F File | Esc/Q Quit%s\n", colorGray, colorReset) } } else if m.showLargeFiles { 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) + fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | P Preview | F File | ⌫ Del %d | Esc Back | Q/Ctrl+C 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) + fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) } } else { largeFileCount := len(m.largeFiles) selectCount := len(m.multiSelected) 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) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | T Top %d | Esc Back | Q/Ctrl+C 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) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del %d | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, selectCount, colorReset) } } else { 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) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del | T Top %d | Esc Back | Q/Ctrl+C 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) + fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) } } } diff --git a/Resources/mole/cmd/status/main.go b/Resources/mole/cmd/status/main.go index a31a8ba..17de64b 100644 --- a/Resources/mole/cmd/status/main.go +++ b/Resources/mole/cmd/status/main.go @@ -21,7 +21,10 @@ var ( BuildTime = "" // Command-line flags - jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI") + jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI") + procCPUThreshold = flag.Float64("proc-cpu-threshold", 100, "alert when a process stays above this CPU percent") + procCPUWindow = flag.Duration("proc-cpu-window", 5*time.Minute, "continuous duration a process must exceed the CPU threshold") + procCPUAlerts = flag.Bool("proc-cpu-alerts", true, "enable persistent high-CPU process alerts") ) func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool { @@ -59,6 +62,21 @@ type model struct { catHidden bool // true = hidden, false = visible } +// padViewToHeight ensures the rendered frame always overwrites the full +// terminal region by padding with empty lines up to the current height. +func padViewToHeight(view string, height int) string { + if height <= 0 { + return view + } + + contentHeight := lipgloss.Height(view) + if contentHeight >= height { + return view + } + + return view + strings.Repeat("\n", height-contentHeight) +} + // getConfigPath returns the path to the status preferences file. func getConfigPath() string { home, err := os.UserHomeDir() @@ -101,11 +119,29 @@ func saveCatHidden(hidden bool) { func newModel() model { return model{ - collector: NewCollector(), + collector: NewCollector(processWatchOptionsFromFlags()), catHidden: loadCatHidden(), } } +func processWatchOptionsFromFlags() ProcessWatchOptions { + return ProcessWatchOptions{ + Enabled: *procCPUAlerts, + CPUThreshold: *procCPUThreshold, + Window: *procCPUWindow, + } +} + +func validateFlags() error { + if *procCPUThreshold < 0 { + return fmt.Errorf("--proc-cpu-threshold must be >= 0") + } + if *procCPUWindow <= 0 { + return fmt.Errorf("--proc-cpu-window must be > 0") + } + return nil +} + func (m model) Init() tea.Cmd { return tea.Batch(tickAfter(0), animTick()) } @@ -164,7 +200,9 @@ func (m model) View() string { } header, mole := renderHeader(m.metrics, m.errMessage, m.animFrame, termWidth, m.catHidden) + alertBar := renderProcessAlertBar(m.metrics.ProcessAlerts, termWidth) + var cardContent string if termWidth <= 80 { cardWidth := termWidth if cardWidth > 2 { @@ -179,27 +217,24 @@ func (m model) View() string { } rendered = append(rendered, renderCard(c, cardWidth, 0)) } - // Combine header, mole, and cards with consistent spacing - var content []string - content = append(content, header) - if mole != "" { - content = append(content, mole) - } - content = append(content, lipgloss.JoinVertical(lipgloss.Left, rendered...)) - return lipgloss.JoinVertical(lipgloss.Left, content...) + cardContent = lipgloss.JoinVertical(lipgloss.Left, rendered...) + } else { + cardWidth := max(24, termWidth/2-4) + cards := buildCards(m.metrics, cardWidth) + cardContent = renderTwoColumns(cards, termWidth) } - cardWidth := max(24, termWidth/2-4) - cards := buildCards(m.metrics, cardWidth) - twoCol := renderTwoColumns(cards, termWidth) // Combine header, mole, and cards with consistent spacing - var content []string - content = append(content, header) + parts := []string{header} + if alertBar != "" { + parts = append(parts, alertBar) + } if mole != "" { - content = append(content, mole) + parts = append(parts, mole) } - content = append(content, twoCol) - return lipgloss.JoinVertical(lipgloss.Left, content...) + parts = append(parts, cardContent) + output := lipgloss.JoinVertical(lipgloss.Left, parts...) + return padViewToHeight(output, m.height) } func (m model) collectCmd() tea.Cmd { @@ -225,7 +260,7 @@ func animTickWithSpeed(cpuUsage float64) tea.Cmd { // runJSONMode collects metrics once and outputs as JSON. func runJSONMode() { - collector := NewCollector() + collector := NewCollector(processWatchOptionsFromFlags()) // First collection initializes network state (returns nil for network) _, _ = collector.Collect() @@ -259,6 +294,10 @@ func runTUIMode() { func main() { flag.Parse() + if err := validateFlags(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(2) + } if shouldUseJSONOutput(*jsonOutput, os.Stdout) { runJSONMode() @@ -266,3 +305,13 @@ func main() { runTUIMode() } } + +func activeAlerts(alerts []ProcessAlert) []ProcessAlert { + var active []ProcessAlert + for _, alert := range alerts { + if alert.Status == "active" { + active = append(active, alert) + } + } + return active +} diff --git a/Resources/mole/cmd/status/main_test.go b/Resources/mole/cmd/status/main_test.go index 76f31cb..028c103 100644 --- a/Resources/mole/cmd/status/main_test.go +++ b/Resources/mole/cmd/status/main_test.go @@ -3,6 +3,7 @@ package main import ( "os" "testing" + "time" ) func TestShouldUseJSONOutput_ForceFlag(t *testing.T) { @@ -42,3 +43,50 @@ func TestShouldUseJSONOutput_NonTTYFile(t *testing.T) { t.Fatalf("expected file stdout to use JSON mode") } } + +func TestProcessWatchOptionsFromFlags(t *testing.T) { + oldThreshold := *procCPUThreshold + oldWindow := *procCPUWindow + oldAlerts := *procCPUAlerts + defer func() { + *procCPUThreshold = oldThreshold + *procCPUWindow = oldWindow + *procCPUAlerts = oldAlerts + }() + + *procCPUThreshold = 125 + *procCPUWindow = 2 * time.Minute + *procCPUAlerts = false + + opts := processWatchOptionsFromFlags() + if opts.CPUThreshold != 125 { + t.Fatalf("CPUThreshold = %v, want 125", opts.CPUThreshold) + } + if opts.Window != 2*time.Minute { + t.Fatalf("Window = %v, want 2m", opts.Window) + } + if opts.Enabled { + t.Fatal("Enabled = true, want false") + } +} + +func TestValidateFlags(t *testing.T) { + oldThreshold := *procCPUThreshold + oldWindow := *procCPUWindow + defer func() { + *procCPUThreshold = oldThreshold + *procCPUWindow = oldWindow + }() + + *procCPUThreshold = -1 + *procCPUWindow = 5 * time.Minute + if err := validateFlags(); err == nil { + t.Fatal("expected negative threshold to fail validation") + } + + *procCPUThreshold = 100 + *procCPUWindow = 0 + if err := validateFlags(); err == nil { + t.Fatal("expected zero window to fail validation") + } +} diff --git a/Resources/mole/cmd/status/metrics.go b/Resources/mole/cmd/status/metrics.go index 75ecd13..aaa8fd9 100644 --- a/Resources/mole/cmd/status/metrics.go +++ b/Resources/mole/cmd/status/metrics.go @@ -61,24 +61,29 @@ type MetricsSnapshot struct { Host string `json:"host"` Platform string `json:"platform"` Uptime string `json:"uptime"` + UptimeSeconds uint64 `json:"uptime_seconds"` Procs uint64 `json:"procs"` Hardware HardwareInfo `json:"hardware"` HealthScore int `json:"health_score"` // 0-100 system health score HealthScoreMsg string `json:"health_score_msg"` // Brief explanation - CPU CPUStatus `json:"cpu"` - GPU []GPUStatus `json:"gpu"` - Memory MemoryStatus `json:"memory"` - Disks []DiskStatus `json:"disks"` - DiskIO DiskIOStatus `json:"disk_io"` - Network []NetworkStatus `json:"network"` - NetworkHistory NetworkHistory `json:"network_history"` - Proxy ProxyStatus `json:"proxy"` - Batteries []BatteryStatus `json:"batteries"` - Thermal ThermalStatus `json:"thermal"` - Sensors []SensorReading `json:"sensors"` - Bluetooth []BluetoothDevice `json:"bluetooth"` - TopProcesses []ProcessInfo `json:"top_processes"` + CPU CPUStatus `json:"cpu"` + GPU []GPUStatus `json:"gpu"` + Memory MemoryStatus `json:"memory"` + Disks []DiskStatus `json:"disks"` + TrashSize uint64 `json:"trash_size"` + TrashApprox bool `json:"trash_approx"` + DiskIO DiskIOStatus `json:"disk_io"` + Network []NetworkStatus `json:"network"` + NetworkHistory NetworkHistory `json:"network_history"` + Proxy ProxyStatus `json:"proxy"` + Batteries []BatteryStatus `json:"batteries"` + Thermal ThermalStatus `json:"thermal"` + Sensors []SensorReading `json:"sensors"` + Bluetooth []BluetoothDevice `json:"bluetooth"` + TopProcesses []ProcessInfo `json:"top_processes"` + ProcessWatch ProcessWatchConfig `json:"process_watch"` + ProcessAlerts []ProcessAlert `json:"process_alerts"` } type HardwareInfo struct { @@ -96,9 +101,12 @@ type DiskIOStatus struct { } type ProcessInfo struct { - Name string `json:"name"` - CPU float64 `json:"cpu"` - Memory float64 `json:"memory"` + PID int `json:"pid"` + PPID int `json:"ppid"` + Name string `json:"name"` + Command string `json:"command"` + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` } type CPUStatus struct { @@ -176,6 +184,7 @@ type BatteryStatus struct { type ThermalStatus struct { CPUTemp float64 `json:"cpu_temp"` GPUTemp float64 `json:"gpu_temp"` + BatteryTemp float64 `json:"battery_temp"` // Battery temperature in Celsius when exposed by AppleSmartBattery FanSpeed int `json:"fan_speed"` FanCount int `json:"fan_count"` SystemPower float64 `json:"system_power"` // System power consumption in Watts @@ -215,13 +224,19 @@ type Collector struct { cachedGPU []GPUStatus prevDiskIO disk.IOCountersStat lastDiskAt time.Time + + watchMu sync.Mutex + processWatch ProcessWatchConfig + processWatcher *ProcessWatcher } -func NewCollector() *Collector { +func NewCollector(options ProcessWatchOptions) *Collector { return &Collector{ - prevNet: make(map[string]net.IOCountersStat), - rxHistoryBuf: NewRingBuffer(NetworkHistorySize), - txHistoryBuf: NewRingBuffer(NetworkHistorySize), + prevNet: make(map[string]net.IOCountersStat), + rxHistoryBuf: NewRingBuffer(NetworkHistorySize), + txHistoryBuf: NewRingBuffer(NetworkHistorySize), + processWatch: options.SnapshotConfig(), + processWatcher: NewProcessWatcher(options), } } @@ -250,14 +265,12 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { sensorStats []SensorReading gpuStats []GPUStatus btStats []BluetoothDevice - topProcs []ProcessInfo + allProcs []ProcessInfo ) // Helper to launch concurrent collection. collect := func(fn func() error) { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer func() { if r := recover(); r != nil { errMu.Lock() @@ -279,13 +292,16 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { } errMu.Unlock() } - }() + }) } // Launch independent collection tasks. collect(func() (err error) { cpuStats, err = collectCPU(); return }) collect(func() (err error) { memStats, err = collectMemory(); return }) collect(func() (err error) { diskStats, err = collectDisks(); return }) + var trashSize uint64 + var trashApprox bool + collect(func() (err error) { trashSize, trashApprox = collectTrashSize(); return nil }) collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil }) collect(func() (err error) { netStats, err = c.collectNetwork(now); return }) collect(func() (err error) { proxyStats = collectProxy(); return nil }) @@ -305,7 +321,7 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { } return nil }) - collect(func() (err error) { topProcs = collectTopProcesses(); return nil }) + collect(func() (err error) { allProcs, err = collectProcesses(); return }) // Wait for all to complete. wg.Wait() @@ -319,13 +335,22 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { } hwInfo := c.cachedHW - score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) + score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats, batteryStats, hostInfo.Uptime) + topProcs := topProcesses(allProcs, 5) + + var processAlerts []ProcessAlert + c.watchMu.Lock() + if c.processWatcher != nil { + processAlerts = c.processWatcher.Update(now, allProcs) + } + c.watchMu.Unlock() return MetricsSnapshot{ CollectedAt: now, Host: hostInfo.Hostname, Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion), Uptime: formatUptime(hostInfo.Uptime), + UptimeSeconds: hostInfo.Uptime, Procs: hostInfo.Procs, Hardware: hwInfo, HealthScore: score, @@ -334,22 +359,26 @@ func (c *Collector) Collect() (MetricsSnapshot, error) { GPU: gpuStats, Memory: memStats, Disks: diskStats, + TrashSize: trashSize, + TrashApprox: trashApprox, DiskIO: diskIO, Network: netStats, NetworkHistory: NetworkHistory{ RxHistory: c.rxHistoryBuf.Slice(), TxHistory: c.txHistoryBuf.Slice(), }, - Proxy: proxyStats, - Batteries: batteryStats, - Thermal: thermalStats, - Sensors: sensorStats, - Bluetooth: btStats, - TopProcesses: topProcs, + Proxy: proxyStats, + Batteries: batteryStats, + Thermal: thermalStats, + Sensors: sensorStats, + Bluetooth: btStats, + TopProcesses: topProcs, + ProcessWatch: c.processWatch, + ProcessAlerts: processAlerts, }, mergeErr } -func runCmd(ctx context.Context, name string, args ...string) (string, error) { +var runCmd = func(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.Output() if err != nil { @@ -358,7 +387,7 @@ func runCmd(ctx context.Context, name string, args ...string) (string, error) { return string(output), nil } -func commandExists(name string) bool { +var commandExists = func(name string) bool { if name == "" { return false } diff --git a/Resources/mole/cmd/status/metrics_battery.go b/Resources/mole/cmd/status/metrics_battery.go index c28e319..1d1bbeb 100644 --- a/Resources/mole/cmd/status/metrics_battery.go +++ b/Resources/mole/cmd/status/metrics_battery.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "os" "path/filepath" "runtime" @@ -195,86 +196,105 @@ func collectThermal() ThermalStatus { ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancelPower() if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil { - for line := range strings.Lines(out) { - line = strings.TrimSpace(line) + powerThermal := parseAppleSmartBatteryThermal(out) + thermal.BatteryTemp = powerThermal.BatteryTemp + thermal.SystemPower = powerThermal.SystemPower + thermal.AdapterPower = powerThermal.AdapterPower + thermal.BatteryPower = powerThermal.BatteryPower + } - // 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 - } - } + // Do not synthesize CPU temperature from battery sensors or cpu_thermal_level. + // Those values are not CPU-package temperatures and produce false overheating data. + return thermal +} - // 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 - } - } +func parseAppleSmartBatteryThermal(out string) ThermalStatus { + var thermal ThermalStatus + + for line := range strings.Lines(out) { + line = strings.TrimSpace(line) + + // AppleSmartBattery reports battery temperature in centi-degrees Celsius. + if _, after, found := strings.Cut(line, "\"Temperature\" = "); found { + valStr := strings.TrimSpace(after) + if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 { + thermal.BatteryTemp = float64(tempRaw) / 100.0 } + } - // System power consumption (mW -> W). - if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found { + // 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 powerMW, err := strconv.ParseFloat(valStr, 64); err == nil { - // SystemPower should always be positive, reject invalid values - if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W - thermal.SystemPower = powerMW / 1000.0 - } + if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 { + thermal.AdapterPower = watts } } + } - // Battery power (mW -> W, positive = discharging, negative = charging). - if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) + // 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 { + // SystemPower should always be positive, reject invalid values + if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W + thermal.SystemPower = powerMW / 1000.0 + } + } + } - var powerMW float64 - var parsed bool + // Battery power (mW -> W, positive = discharging, negative = charging). + if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found { + valStr := strings.TrimSpace(after) + valStr, _, _ = strings.Cut(valStr, ",") + valStr, _, _ = strings.Cut(valStr, "}") + valStr = strings.TrimSpace(valStr) - // Strategy 1: Try parsing as a signed integer first. - // This handles standard positive values and explicit negative strings like "-12345". - if valInt, err := strconv.ParseInt(valStr, 10, 64); err == nil { - powerMW = float64(valInt) - parsed = true - } else if valUint, err := strconv.ParseUint(valStr, 10, 64); err == nil { - // Strategy 2: Try parsing as an unsigned integer (Two's Complement). - // ioreg often returns negative values as huge uint64 numbers (e.g. 2^64 - 100). - // Casting such a uint64 to int64 correctly restores the negative value. - powerMW = float64(int64(valUint)) - parsed = true - } + var powerMW float64 + var parsed bool - if parsed { - // Validate reasonable battery power range: -200W to 200W - if powerMW > -200000 && powerMW < 200000 { - thermal.BatteryPower = powerMW / 1000.0 + // Strategy 1: Try parsing as a signed integer first. + // This handles standard positive values and explicit negative strings like "-12345". + if valInt, err := strconv.ParseInt(valStr, 10, 64); err == nil { + powerMW = float64(valInt) + parsed = true + } else if valUint, err := strconv.ParseUint(valStr, 10, 64); err == nil { + // Strategy 2: Try parsing as an unsigned integer (Two's Complement). + // ioreg often returns negative values as huge uint64 numbers (e.g. 2^64 - 100). + // Explicitly handle two's complement rather than relying on an unchecked cast. + var signed int64 + if valUint <= math.MaxInt64 { + // Fits in positive int64 range directly. + signed = int64(valUint) + } else { + // Interpret as negative two's complement value. + // For a uint64 v > MaxInt64, the corresponding negative int64 is: + // -(^v + 1) where ^ is bitwise NOT in 64 bits. + negMag := ^valUint + 1 + // negMag now holds the magnitude of the negative value as uint64. + if negMag <= math.MaxInt64 { + signed = -int64(negMag) + } else { + // Magnitude too large to represent; skip this parsing strategy. + goto skipUintParse } } + powerMW = float64(signed) + parsed = true + skipUintParse: } - } - } - // Fallback: thermal level proxy. - if thermal.CPUTemp == 0 { - ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel2() - out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level") - if err == nil { - level, _ := strconv.Atoi(strings.TrimSpace(out2)) - if level >= 0 { - thermal.CPUTemp = 45 + float64(level)*0.5 + if parsed { + // Validate reasonable battery power range: -200W to 200W + if powerMW > -200000 && powerMW < 200000 { + thermal.BatteryPower = powerMW / 1000.0 + } } } } diff --git a/Resources/mole/cmd/status/metrics_battery_test.go b/Resources/mole/cmd/status/metrics_battery_test.go new file mode 100644 index 0000000..e66aac6 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_battery_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "math" + "testing" +) + +func TestParseAppleSmartBatteryThermalKeepsBatteryTemperatureOutOfCPUTemp(t *testing.T) { + out := ` + | | "Temperature" = 3055 + | | "SystemPowerIn"=19967 + | | "BatteryPower"=13654 + | | "AdapterDetails" = {"Watts"=96} +` + + thermal := parseAppleSmartBatteryThermal(out) + + if thermal.CPUTemp != 0 { + t.Fatalf("expected cpu temp to stay unset, got %v", thermal.CPUTemp) + } + if math.Abs(thermal.BatteryTemp-30.55) > 0.001 { + t.Fatalf("expected battery temp 30.55, got %v", thermal.BatteryTemp) + } + if math.Abs(thermal.SystemPower-19.967) > 0.001 { + t.Fatalf("expected system power 19.967W, got %v", thermal.SystemPower) + } + if thermal.AdapterPower != 96 { + t.Fatalf("expected adapter power 96W, got %v", thermal.AdapterPower) + } + if math.Abs(thermal.BatteryPower-13.654) > 0.001 { + t.Fatalf("expected battery power 13.654W, got %v", thermal.BatteryPower) + } +} + +func TestParseAppleSmartBatteryThermalParsesTwosComplementBatteryPower(t *testing.T) { + out := ` + | | "BatteryPower"=18446744073709539271 +` + + thermal := parseAppleSmartBatteryThermal(out) + + if math.Abs(thermal.BatteryPower-(-12.345)) > 0.001 { + t.Fatalf("expected battery power -12.345W, got %v", thermal.BatteryPower) + } +} diff --git a/Resources/mole/cmd/status/metrics_disk.go b/Resources/mole/cmd/status/metrics_disk.go index da14f4d..31638ae 100644 --- a/Resources/mole/cmd/status/metrics_disk.go +++ b/Resources/mole/cmd/status/metrics_disk.go @@ -4,9 +4,14 @@ import ( "context" "errors" "fmt" + "io/fs" + "os" + "path/filepath" "runtime" "sort" + "strconv" "strings" + "sync" "time" "github.com/shirou/gopsutil/v4/disk" @@ -22,6 +27,23 @@ var skipDiskMounts = map[string]bool{ "/dev": true, } +var skipDiskFSTypes = map[string]bool{ + "afpfs": true, + "autofs": true, + "cifs": true, + "devfs": true, + "fuse": true, + "fuseblk": true, + "fusefs": true, + "macfuse": true, + "nfs": true, + "osxfuse": true, + "procfs": true, + "smbfs": true, + "tmpfs": true, + "webdav": true, +} + func collectDisks() ([]DiskStatus, error) { partitions, err := disk.Partitions(false) if err != nil { @@ -34,17 +56,7 @@ func collectDisks() ([]DiskStatus, error) { seenVolume = make(map[string]bool) ) for _, part := range partitions { - if strings.HasPrefix(part.Device, "/dev/loop") { - continue - } - if skipDiskMounts[part.Mountpoint] { - continue - } - if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { - continue - } - // Skip /private mounts. - if strings.HasPrefix(part.Mountpoint, "/private/") { + if shouldSkipDiskPartition(part) { continue } baseDevice := baseDeviceName(part.Device) @@ -58,21 +70,31 @@ func collectDisks() ([]DiskStatus, error) { if err != nil || usage.Total == 0 { continue } + total := usage.Total + if runtime.GOOS == "darwin" { + total = correctDiskTotalBytes(part.Mountpoint, total) + } // Skip <1GB volumes. - if usage.Total < 1<<30 { + if total < 1<<30 { continue } // Use size-based dedupe key for shared pools. - volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total) + volKey := fmt.Sprintf("%s:%d", part.Fstype, total) if seenVolume[volKey] { continue } + used := usage.Used + usedPercent := usage.UsedPercent + if runtime.GOOS == "darwin" && strings.ToLower(part.Fstype) == "apfs" { + used, usedPercent = correctAPFSDiskUsage(part.Mountpoint, total, usage.Used) + } + disks = append(disks, DiskStatus{ Mount: part.Mountpoint, Device: part.Device, - Used: usage.Used, - Total: usage.Total, - UsedPercent: usage.UsedPercent, + Used: used, + Total: total, + UsedPercent: usedPercent, Fstype: part.Fstype, }) seenDevice[baseDevice] = true @@ -97,11 +119,45 @@ func collectDisks() ([]DiskStatus, error) { return disks, nil } +func shouldSkipDiskPartition(part disk.PartitionStat) bool { + if strings.HasPrefix(part.Device, "/dev/loop") { + return true + } + if skipDiskMounts[part.Mountpoint] { + return true + } + if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") { + return true + } + if strings.HasPrefix(part.Mountpoint, "/private/") { + return true + } + + fstype := strings.ToLower(part.Fstype) + if skipDiskFSTypes[fstype] || strings.Contains(fstype, "fuse") { + return true + } + + // On macOS, local disks should come from /dev. This filters sshfs/macFUSE-style + // mounts that can mirror the root volume and show up as duplicate internal disks. + if runtime.GOOS == "darwin" && part.Device != "" && !strings.HasPrefix(part.Device, "/dev/") { + return true + } + + return false +} + var ( // External disk cache. lastDiskCacheAt time.Time diskTypeCache = make(map[string]bool) diskCacheTTL = 2 * time.Minute + + // Finder startup disk usage cache (macOS APFS purgeable-aware). + finderDiskCacheMu sync.Mutex + finderDiskCachedAt time.Time + finderDiskFree uint64 + finderDiskTotal uint64 ) func annotateDiskTypes(disks []DiskStatus) { @@ -179,6 +235,165 @@ func isExternalDisk(device string) (bool, error) { return external, nil } +// correctDiskTotalBytes uses diskutil's plist output when macOS reports a +// meaningfully different disk size than gopsutil. This fixes external APFS +// volumes that can show doubled capacities through statfs/gopsutil. +func correctDiskTotalBytes(mountpoint string, rawTotal uint64) uint64 { + if rawTotal == 0 || !commandExists("diskutil") { + return rawTotal + } + + diskutilTotal, err := getDiskutilTotalBytes(mountpoint) + if err != nil || diskutilTotal == 0 { + return rawTotal + } + + if uint64AbsDiff(rawTotal, diskutilTotal) > 1<<30 { + return diskutilTotal + } + + return rawTotal +} + +func getDiskutilTotalBytes(mountpoint string) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint) + if err != nil { + return 0, err + } + + // Prefer TotalSize, but keep older/plainer keys as fallbacks. + return extractPlistUint(out, "TotalSize", "DiskSize", "Size") +} + +// correctAPFSDiskUsage returns Finder-accurate used bytes and percent for an +// APFS volume, accounting for purgeable caches and APFS local snapshots that +// statfs incorrectly counts as "used". Uses a three-tier fallback: +// 1. Finder via osascript (startup disk only) — exact match with macOS Finder +// 2. diskutil APFSContainerFree — corrects APFS snapshot space +// 3. Raw gopsutil values — original statfs-based calculation +func correctAPFSDiskUsage(mountpoint string, total, rawUsed uint64) (used uint64, usedPercent float64) { + // Tier 1: Finder via osascript (startup disk at "/" only). + if mountpoint == "/" && commandExists("osascript") { + if finderFree, finderTotal, err := getFinderStartupDiskFreeBytes(); err == nil && + finderTotal > 0 && finderFree <= finderTotal { + used = finderTotal - finderFree + usedPercent = float64(used) / float64(finderTotal) * 100.0 + return + } + } + + // Tier 2: diskutil APFSContainerFree (corrects APFS local snapshots). + if commandExists("diskutil") { + if containerFree, err := getAPFSContainerFreeBytes(mountpoint); err == nil && containerFree <= total { + corrected := total - containerFree + // Only apply if it meaningfully differs (>1GB) from raw to avoid noise. + if rawUsed > corrected && rawUsed-corrected > 1<<30 { + used = corrected + usedPercent = float64(used) / float64(total) * 100.0 + return + } + } + } + + // Tier 3: fall back to raw gopsutil values. + return rawUsed, float64(rawUsed) / float64(total) * 100.0 +} + +// getAPFSContainerFreeBytes returns the APFS container free space (including +// purgeable snapshot space) by parsing `diskutil info -plist`. This corrects +// for APFS local snapshots which statfs counts as used. +func getAPFSContainerFreeBytes(mountpoint string) (uint64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "diskutil", "info", "-plist", mountpoint) + if err != nil { + return 0, err + } + + return extractPlistUint(out, "APFSContainerFree") +} + +// getFinderStartupDiskFreeBytes queries Finder via osascript for the startup +// disk free space. Finder's value includes purgeable caches and APFS snapshots, +// matching the "X GB of Y GB used" display. Results are cached for 2 minutes. +func getFinderStartupDiskFreeBytes() (free, total uint64, err error) { + finderDiskCacheMu.Lock() + defer finderDiskCacheMu.Unlock() + + if !finderDiskCachedAt.IsZero() && time.Since(finderDiskCachedAt) < diskCacheTTL { + return finderDiskFree, finderDiskTotal, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Single call returns both values as a comma-separated pair. + out, err := runCmd(ctx, "osascript", "-e", + `tell application "Finder" to return {free space of startup disk, capacity of startup disk}`) + if err != nil { + // Cache the failure timestamp so repeated calls within diskCacheTTL + // return immediately instead of each waiting the full 5s timeout. + finderDiskCachedAt = time.Now() + return 0, 0, err + } + + // Output format: "3.2489E+11, 4.9438E+11" or "324892202048, 494384795648" + parts := strings.SplitN(strings.TrimSpace(out), ",", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("unexpected osascript output: %q", out) + } + + freeF, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64) + totalF, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) + if err1 != nil || err2 != nil || freeF <= 0 || totalF <= 0 { + return 0, 0, fmt.Errorf("failed to parse osascript output: %q", out) + } + + finderDiskFree = uint64(freeF) + finderDiskTotal = uint64(totalF) + finderDiskCachedAt = time.Now() + return finderDiskFree, finderDiskTotal, nil +} + +func extractPlistUint(plist string, keys ...string) (uint64, error) { + for _, key := range keys { + marker := "" + key + "" + _, rest, found := strings.Cut(plist, marker) + if !found { + continue + } + + _, rest, found = strings.Cut(rest, "") + if !found { + continue + } + + value, _, found := strings.Cut(rest, "") + if !found { + continue + } + + parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse %s: %v", key, err) + } + return parsed, nil + } + + return 0, fmt.Errorf("%s not found", strings.Join(keys, "/")) +} + +func uint64AbsDiff(a, b uint64) uint64 { + if a > b { + return a - b + } + return b - a +} + func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { counters, err := disk.IOCounters() if err != nil || len(counters) == 0 { @@ -217,3 +432,34 @@ func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} } + +// collectTrashSize returns the total size in bytes of ~/.Trash and whether +// the result is approximate (true when the 2s timeout was reached). +func collectTrashSize() (uint64, bool) { + home, err := os.UserHomeDir() + if err != nil { + return 0, false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var total uint64 + trashPath := filepath.Join(home, ".Trash") + _ = filepath.WalkDir(trashPath, func(_ string, d fs.DirEntry, err error) error { + if ctx.Err() != nil { + return fs.SkipAll + } + if err != nil { + return nil + } + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + if !d.IsDir() { + if info, err := d.Info(); err == nil { + total += uint64(info.Size()) + } + } + return nil + }) + return total, ctx.Err() != nil +} diff --git a/Resources/mole/cmd/status/metrics_disk_test.go b/Resources/mole/cmd/status/metrics_disk_test.go new file mode 100644 index 0000000..24fed33 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_disk_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/shirou/gopsutil/v4/disk" +) + +func TestShouldSkipDiskPartition(t *testing.T) { + tests := []struct { + name string + part disk.PartitionStat + want bool + }{ + { + name: "keep local apfs root volume", + part: disk.PartitionStat{ + Device: "/dev/disk3s1s1", + Mountpoint: "/", + Fstype: "apfs", + }, + want: false, + }, + { + name: "skip macfuse mirror mount", + part: disk.PartitionStat{ + Device: "kaku-local:/", + Mountpoint: "/Users/tw93/Library/Caches/dev.kaku/sshfs/kaku-local", + Fstype: "macfuse", + }, + want: true, + }, + { + name: "skip smb share", + part: disk.PartitionStat{ + Device: "//server/share", + Mountpoint: "/Volumes/share", + Fstype: "smbfs", + }, + want: true, + }, + { + name: "skip system volume", + part: disk.PartitionStat{ + Device: "/dev/disk3s5", + Mountpoint: "/System/Volumes/Data", + Fstype: "apfs", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldSkipDiskPartition(tt.part); got != tt.want { + t.Fatalf("shouldSkipDiskPartition(%+v) = %v, want %v", tt.part, got, tt.want) + } + }) + } +} + +func TestExtractPlistUint(t *testing.T) { + t.Run("prefers first matching key", func(t *testing.T) { + raw := ` +TotalSize1099511627776 +DiskSize2199023255552 +` + + got, err := extractPlistUint(raw, "TotalSize", "DiskSize") + if err != nil { + t.Fatalf("extractPlistUint() error = %v", err) + } + if got != 1099511627776 { + t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("falls back to later keys", func(t *testing.T) { + raw := `DiskSize1099511627776` + + got, err := extractPlistUint(raw, "TotalSize", "DiskSize", "Size") + if err != nil { + t.Fatalf("extractPlistUint() error = %v", err) + } + if got != 1099511627776 { + t.Fatalf("extractPlistUint() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("returns error for malformed integer", func(t *testing.T) { + raw := `TotalSizeoops` + + if _, err := extractPlistUint(raw, "TotalSize"); err == nil { + t.Fatalf("extractPlistUint() expected parse error") + } + }) +} + +func TestCorrectDiskTotalBytes(t *testing.T) { + origRunCmd := runCmd + origCommandExists := commandExists + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + }) + + commandExists = func(name string) bool { + return name == "diskutil" + } + + t.Run("uses diskutil total when meaningfully different", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + if name != "diskutil" { + return "", errors.New("unexpected command") + } + return `TotalSize1099511627776`, nil + } + + got := correctDiskTotalBytes("/Volumes/Backup", 2199023255552) + if got != 1099511627776 { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(1099511627776)) + } + }) + + t.Run("keeps raw total for small differences", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return `TotalSize1000500000000`, nil + } + + const rawTotal = 1000000000000 + got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal) + if got != rawTotal { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal)) + } + }) + + t.Run("keeps raw total when diskutil fails", func(t *testing.T) { + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + return "", errors.New("diskutil failed") + } + + const rawTotal = 1099511627776 + got := correctDiskTotalBytes("/Volumes/FastSSD", rawTotal) + if got != rawTotal { + t.Fatalf("correctDiskTotalBytes() = %d, want %d", got, uint64(rawTotal)) + } + }) +} diff --git a/Resources/mole/cmd/status/metrics_health.go b/Resources/mole/cmd/status/metrics_health.go index 4bfd090..614d6d2 100644 --- a/Resources/mole/cmd/status/metrics_health.go +++ b/Resources/mole/cmd/status/metrics_health.go @@ -35,9 +35,21 @@ const ( // Disk IO (MB/s). ioNormalThreshold = 50.0 ioHighThreshold = 150.0 + + // Battery. + batteryCycleWarn = 500 + batteryCycleDanger = 900 + batteryCapWarn = 90 + batteryCapDanger = 80 + + // Uptime (seconds). + uptimeWarnDays = 7 + uptimeDangerDays = 14 + uptimeWarnSecs = uptimeWarnDays * 86400 + uptimeDangerSecs = uptimeDangerDays * 86400 ) -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, batteries []BatteryStatus, uptimeSecs uint64) (int, string) { score := 100.0 issues := []string{} @@ -123,6 +135,27 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d } score -= ioPenalty + // Battery health penalty (only when battery present). + if len(batteries) > 0 { + b := batteries[0] + _, sev := batteryHealthLabel(b.CycleCount, b.Capacity) + switch sev { + case "danger": + score -= 5 + issues = append(issues, "Battery Service Soon") + case "warn": + score -= 2 + } + } + + // Uptime penalty (long uptime without restart). + if uptimeSecs > uptimeDangerSecs { + score -= 3 + issues = append(issues, "Restart Recommended") + } else if uptimeSecs > uptimeWarnSecs { + score -= 1 + } + // Clamp score. if score < 0 { score = 0 @@ -153,6 +186,29 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d return int(score), msg } +// batteryHealthLabel returns a human-readable health label and severity based on cycle count and capacity. +// Severity is "ok", "warn", or "danger". +func batteryHealthLabel(cycles int, capacity int) (string, string) { + if cycles > batteryCycleDanger || (capacity > 0 && capacity < batteryCapDanger) { + return "Service Soon", "danger" + } + if cycles > batteryCycleWarn || (capacity > 0 && capacity < batteryCapWarn) { + return "Fair", "warn" + } + return "Healthy", "ok" +} + +// uptimeSeverity returns "ok", "warn", or "danger" based on uptime seconds. +func uptimeSeverity(secs uint64) string { + if secs > uptimeDangerSecs { + return "danger" + } + if secs > uptimeWarnSecs { + return "warn" + } + return "ok" +} + func formatUptime(secs uint64) string { days := secs / 86400 hours := (secs % 86400) / 3600 diff --git a/Resources/mole/cmd/status/metrics_health_test.go b/Resources/mole/cmd/status/metrics_health_test.go index b88df18..13ebc1b 100644 --- a/Resources/mole/cmd/status/metrics_health_test.go +++ b/Resources/mole/cmd/status/metrics_health_test.go @@ -12,6 +12,7 @@ func TestCalculateHealthScorePerfect(t *testing.T) { []DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5}, ThermalStatus{CPUTemp: 40}, + nil, 0, ) if score != 100 { @@ -29,6 +30,7 @@ func TestCalculateHealthScoreDetectsIssues(t *testing.T) { []DiskStatus{{UsedPercent: 95}}, DiskIOStatus{ReadRate: 120, WriteRate: 80}, ThermalStatus{CPUTemp: 90}, + nil, 0, ) if score >= 40 { @@ -160,7 +162,7 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal) + score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal, nil, 0) if score < tt.wantMin || score > tt.wantMax { t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax) } @@ -168,6 +170,77 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) { } } +func TestBatteryHealthLabel(t *testing.T) { + tests := []struct { + name string + cycles int + capacity int + label string + severity string + }{ + {"new battery", 100, 98, "Healthy", "ok"}, + {"moderate cycles", 600, 92, "Fair", "warn"}, + {"high cycles", 950, 85, "Service Soon", "danger"}, + {"low capacity", 200, 75, "Service Soon", "danger"}, + {"warn capacity", 200, 88, "Fair", "warn"}, + {"zero values", 0, 0, "Healthy", "ok"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + label, severity := batteryHealthLabel(tt.cycles, tt.capacity) + if label != tt.label { + t.Errorf("batteryHealthLabel(%d, %d) label = %q, want %q", tt.cycles, tt.capacity, label, tt.label) + } + if severity != tt.severity { + t.Errorf("batteryHealthLabel(%d, %d) severity = %q, want %q", tt.cycles, tt.capacity, severity, tt.severity) + } + }) + } +} + +func TestUptimeSeverity(t *testing.T) { + tests := []struct { + name string + secs uint64 + want string + }{ + {"fresh restart", 3600, "ok"}, + {"6 days", 6 * 86400, "ok"}, + {"8 days", 8 * 86400, "warn"}, + {"15 days", 15 * 86400, "danger"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := uptimeSeverity(tt.secs) + if got != tt.want { + t.Errorf("uptimeSeverity(%d) = %q, want %q", tt.secs, got, tt.want) + } + }) + } +} + +func TestHealthScoreBatteryPenalty(t *testing.T) { + base := func(batts []BatteryStatus, uptime uint64) int { + s, _ := calculateHealthScore( + CPUStatus{Usage: 10}, MemoryStatus{UsedPercent: 20}, + []DiskStatus{{UsedPercent: 30}}, DiskIOStatus{ReadRate: 5, WriteRate: 5}, + ThermalStatus{CPUTemp: 40}, batts, uptime, + ) + return s + } + + perfect := base(nil, 0) + withOldBattery := base([]BatteryStatus{{CycleCount: 950, Capacity: 75}}, 0) + withLongUptime := base(nil, 15*86400) + + if withOldBattery >= perfect { + t.Errorf("old battery should reduce score: got %d vs perfect %d", withOldBattery, perfect) + } + if withLongUptime >= perfect { + t.Errorf("long uptime should reduce score: got %d vs perfect %d", withLongUptime, perfect) + } +} + func TestFormatUptimeEdgeCases(t *testing.T) { tests := []struct { name string diff --git a/Resources/mole/cmd/status/metrics_process.go b/Resources/mole/cmd/status/metrics_process.go index b11f25c..9934bd1 100644 --- a/Resources/mole/cmd/status/metrics_process.go +++ b/Resources/mole/cmd/status/metrics_process.go @@ -2,52 +2,97 @@ package main import ( "context" + "fmt" "runtime" + "sort" "strconv" "strings" "time" ) -func collectTopProcesses() []ProcessInfo { +func collectProcesses() ([]ProcessInfo, error) { if runtime.GOOS != "darwin" { - return nil + return nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - // Use ps to get top processes by CPU. - out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r") + out, err := runCmd(ctx, "ps", "-Aceo", "pid=,ppid=,pcpu=,pmem=,comm=", "-r") if err != nil { - return nil + return nil, err } + return parseProcessOutput(out), nil +} +func parseProcessOutput(raw string) []ProcessInfo { var procs []ProcessInfo - i := 0 - for line := range strings.Lines(strings.TrimSpace(out)) { - if i == 0 { - i++ + for line := range strings.Lines(strings.TrimSpace(raw)) { + fields := strings.Fields(line) + if len(fields) < 5 { continue } - if i > 5 { - break + + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { + continue } - i++ - fields := strings.Fields(line) - if len(fields) < 3 { + ppid, _ := strconv.Atoi(fields[1]) + cpuVal, err := strconv.ParseFloat(fields[2], 64) + if err != nil { continue } - cpuVal, _ := strconv.ParseFloat(fields[0], 64) - memVal, _ := strconv.ParseFloat(fields[1], 64) - name := fields[len(fields)-1] + memVal, err := strconv.ParseFloat(fields[3], 64) + if err != nil { + continue + } + + command := strings.Join(fields[4:], " ") + if command == "" { + continue + } + name := command // Strip path from command name. if idx := strings.LastIndex(name, "/"); idx >= 0 { name = name[idx+1:] } procs = append(procs, ProcessInfo{ - Name: name, - CPU: cpuVal, - Memory: memVal, + PID: pid, + PPID: ppid, + Name: name, + Command: command, + CPU: cpuVal, + Memory: memVal, }) } return procs } + +func topProcesses(processes []ProcessInfo, limit int) []ProcessInfo { + if limit <= 0 || len(processes) == 0 { + return nil + } + + procs := make([]ProcessInfo, len(processes)) + copy(procs, processes) + sort.Slice(procs, func(i, j int) bool { + if procs[i].CPU != procs[j].CPU { + return procs[i].CPU > procs[j].CPU + } + if procs[i].Memory != procs[j].Memory { + return procs[i].Memory > procs[j].Memory + } + return procs[i].PID < procs[j].PID + }) + + if len(procs) > limit { + procs = procs[:limit] + } + return procs +} + +func formatProcessLabel(proc ProcessInfo) string { + if proc.Name != "" { + return fmt.Sprintf("%s (%d)", proc.Name, proc.PID) + } + return fmt.Sprintf("pid %d", proc.PID) +} diff --git a/Resources/mole/cmd/status/process_watch.go b/Resources/mole/cmd/status/process_watch.go new file mode 100644 index 0000000..819b881 --- /dev/null +++ b/Resources/mole/cmd/status/process_watch.go @@ -0,0 +1,150 @@ +package main + +import ( + "sort" + "time" +) + +type ProcessWatchOptions struct { + Enabled bool + CPUThreshold float64 + Window time.Duration +} + +type ProcessWatchConfig struct { + Enabled bool `json:"enabled"` + CPUThreshold float64 `json:"cpu_threshold"` + Window string `json:"window"` +} + +type ProcessAlert struct { + PID int `json:"pid"` + Name string `json:"name"` + Command string `json:"command,omitempty"` + CPU float64 `json:"cpu"` + Threshold float64 `json:"threshold"` + Window string `json:"window"` + TriggeredAt time.Time `json:"triggered_at"` + Status string `json:"status"` +} + +type trackedProcess struct { + info ProcessInfo + firstAbove time.Time + triggeredAt time.Time + currentAbove bool +} + +type processIdentity struct { + pid int + ppid int + command string +} + +type ProcessWatcher struct { + options ProcessWatchOptions + tracks map[processIdentity]*trackedProcess +} + +func NewProcessWatcher(options ProcessWatchOptions) *ProcessWatcher { + return &ProcessWatcher{ + options: options, + tracks: make(map[processIdentity]*trackedProcess), + } +} + +func (o ProcessWatchOptions) SnapshotConfig() ProcessWatchConfig { + return ProcessWatchConfig{ + Enabled: o.Enabled, + CPUThreshold: o.CPUThreshold, + Window: o.Window.String(), + } +} + +func (w *ProcessWatcher) Update(now time.Time, processes []ProcessInfo) []ProcessAlert { + if w == nil || !w.options.Enabled { + return nil + } + + seen := make(map[processIdentity]bool, len(processes)) + for _, proc := range processes { + if proc.PID <= 0 { + continue + } + key := processIdentity{ + pid: proc.PID, + ppid: proc.PPID, + command: proc.Command, + } + seen[key] = true + + track, ok := w.tracks[key] + if !ok { + track = &trackedProcess{} + w.tracks[key] = track + } + + track.info = proc + track.currentAbove = proc.CPU >= w.options.CPUThreshold + + if track.currentAbove { + if track.firstAbove.IsZero() { + track.firstAbove = now + } + if now.Sub(track.firstAbove) >= w.options.Window && track.triggeredAt.IsZero() { + track.triggeredAt = now + } + continue + } + + track.firstAbove = time.Time{} + track.triggeredAt = time.Time{} + } + + for pid := range w.tracks { + if !seen[pid] { + delete(w.tracks, pid) + } + } + + return w.Snapshot() +} + +func (w *ProcessWatcher) Snapshot() []ProcessAlert { + if w == nil || !w.options.Enabled { + return nil + } + + alerts := make([]ProcessAlert, 0, len(w.tracks)) + for _, track := range w.tracks { + if !track.currentAbove || track.triggeredAt.IsZero() { + continue + } + + alerts = append(alerts, ProcessAlert{ + PID: track.info.PID, + Name: track.info.Name, + Command: track.info.Command, + CPU: track.info.CPU, + Threshold: w.options.CPUThreshold, + Window: w.options.Window.String(), + TriggeredAt: track.triggeredAt, + Status: "active", + }) + } + + sort.Slice(alerts, func(i, j int) bool { + if alerts[i].Status != alerts[j].Status { + return alerts[i].Status == "active" + } + if !alerts[i].TriggeredAt.Equal(alerts[j].TriggeredAt) { + return alerts[i].TriggeredAt.Before(alerts[j].TriggeredAt) + } + if alerts[i].CPU != alerts[j].CPU { + return alerts[i].CPU > alerts[j].CPU + } + return alerts[i].PID < alerts[j].PID + }) + + return alerts +} diff --git a/Resources/mole/cmd/status/process_watch_test.go b/Resources/mole/cmd/status/process_watch_test.go new file mode 100644 index 0000000..6868c6b --- /dev/null +++ b/Resources/mole/cmd/status/process_watch_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestParseProcessOutput(t *testing.T) { + raw := strings.Join([]string{ + "123 1 145.2 10.1 /Applications/Visual Studio Code.app/Contents/MacOS/Electron", + "456 1 99.5 2.2 /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", + "bad line", + }, "\n") + + procs := parseProcessOutput(raw) + if len(procs) != 2 { + t.Fatalf("parseProcessOutput() len = %d, want 2", len(procs)) + } + + if procs[0].PID != 123 || procs[0].PPID != 1 { + t.Fatalf("unexpected pid/ppid: %+v", procs[0]) + } + if procs[0].Name != "Electron" { + t.Fatalf("unexpected process name %q", procs[0].Name) + } + if !strings.Contains(procs[0].Command, "Visual Studio Code.app") { + t.Fatalf("command path missing spaces: %q", procs[0].Command) + } +} + +func TestTopProcessesSortsByCPU(t *testing.T) { + procs := []ProcessInfo{ + {PID: 3, Name: "low", CPU: 20, Memory: 3}, + {PID: 1, Name: "high", CPU: 120, Memory: 1}, + {PID: 2, Name: "mid", CPU: 120, Memory: 8}, + } + + top := topProcesses(procs, 2) + if len(top) != 2 { + t.Fatalf("topProcesses() len = %d, want 2", len(top)) + } + if top[0].PID != 2 || top[1].PID != 1 { + t.Fatalf("unexpected order: %+v", top) + } +} + +func TestProcessWatcherTriggersAfterContinuousWindow(t *testing.T) { + base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC) + watcher := NewProcessWatcher(ProcessWatchOptions{ + Enabled: true, + CPUThreshold: 100, + Window: 5 * time.Minute, + }) + + proc := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}} + if alerts := watcher.Update(base, proc); len(alerts) != 0 { + t.Fatalf("unexpected early alerts: %+v", alerts) + } + if alerts := watcher.Update(base.Add(4*time.Minute), proc); len(alerts) != 0 { + t.Fatalf("unexpected early alerts at 4m: %+v", alerts) + } + alerts := watcher.Update(base.Add(5*time.Minute), proc) + if len(alerts) != 1 { + t.Fatalf("expected 1 alert after full window, got %+v", alerts) + } + if alerts[0].Status != "active" { + t.Fatalf("unexpected alert status %q", alerts[0].Status) + } +} + +func TestProcessWatcherResetsWhenUsageDrops(t *testing.T) { + base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC) + watcher := NewProcessWatcher(ProcessWatchOptions{ + Enabled: true, + CPUThreshold: 100, + Window: 5 * time.Minute, + }) + + high := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}} + low := []ProcessInfo{{PID: 42, Name: "stress", CPU: 30}} + + watcher.Update(base, high) + watcher.Update(base.Add(4*time.Minute), high) + if alerts := watcher.Update(base.Add(4*time.Minute+30*time.Second), low); len(alerts) != 0 { + t.Fatalf("expected reset after dip, got %+v", alerts) + } + if alerts := watcher.Update(base.Add(9*time.Minute), high); len(alerts) != 0 { + t.Fatalf("expected no alert after reset, got %+v", alerts) + } + if alerts := watcher.Update(base.Add(14*time.Minute), high); len(alerts) != 1 { + t.Fatalf("expected alert after second full window, got %+v", alerts) + } +} + +func TestProcessWatcherResetsOnPIDReuse(t *testing.T) { + base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC) + watcher := NewProcessWatcher(ProcessWatchOptions{ + Enabled: true, + CPUThreshold: 100, + Window: 2 * time.Minute, + }) + + firstProc := []ProcessInfo{{ + PID: 42, + PPID: 1, + Name: "stress", + Command: "/usr/bin/stress", + CPU: 140, + }} + secondProc := []ProcessInfo{{ + PID: 42, + PPID: 99, + Name: "node", + Command: "/usr/local/bin/node /tmp/server.js", + CPU: 135, + }} + + watcher.Update(base, firstProc) + if alerts := watcher.Update(base.Add(2*time.Minute), firstProc); len(alerts) != 1 { + t.Fatalf("expected first process to alert after window, got %+v", alerts) + } + + if alerts := watcher.Update(base.Add(3*time.Minute), secondProc); len(alerts) != 0 { + t.Fatalf("expected pid reuse to reset tracking, got %+v", alerts) + } + if alerts := watcher.Update(base.Add(5*time.Minute), secondProc); len(alerts) != 1 { + t.Fatalf("expected reused pid to alert only after its own window, got %+v", alerts) + } +} + +func TestRenderProcessAlertBar(t *testing.T) { + alerts := []ProcessAlert{ + {PID: 10, Name: "node", CPU: 150, Threshold: 100, Window: "5m0s", Status: "active"}, + {PID: 11, Name: "java", CPU: 130, Threshold: 100, Window: "5m0s", Status: "active"}, + } + + bar := renderProcessAlertBar(alerts, 120) + if !strings.Contains(bar, "ALERT") { + t.Fatalf("missing alert prefix: %q", bar) + } + if !strings.Contains(bar, "node (10)") { + t.Fatalf("missing lead process label: %q", bar) + } + if !strings.Contains(bar, "+1 more") { + t.Fatalf("missing additional alert count: %q", bar) + } + if strings.Contains(bar, "terminate") || strings.Contains(bar, "ignore") { + t.Fatalf("unexpected action text in read-only alert bar: %q", bar) + } +} + +func TestMetricsSnapshotJSONIncludesProcessWatch(t *testing.T) { + snapshot := MetricsSnapshot{ + ProcessWatch: ProcessWatchConfig{ + Enabled: true, + CPUThreshold: 100, + Window: "5m0s", + }, + ProcessAlerts: []ProcessAlert{{ + PID: 99, + Name: "node", + CPU: 140, + Threshold: 100, + Window: "5m0s", + Status: "active", + }}, + } + + data, err := json.Marshal(snapshot) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + out := string(data) + if !strings.Contains(out, "\"process_watch\"") { + t.Fatalf("missing process_watch in json: %s", out) + } + if !strings.Contains(out, "\"process_alerts\"") { + t.Fatalf("missing process_alerts in json: %s", out) + } +} diff --git a/Resources/mole/cmd/status/view.go b/Resources/mole/cmd/status/view.go index 217d53c..a2a8b0f 100644 --- a/Resources/mole/cmd/status/view.go +++ b/Resources/mole/cmd/status/view.go @@ -17,7 +17,12 @@ var ( okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040")) - primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) + primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9")) + alertBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2B1200")). + Background(lipgloss.Color("#FFD75F")). + Bold(true). + Padding(0, 1) ) const ( @@ -172,7 +177,16 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int optionalInfoParts = append(optionalInfoParts, m.Hardware.OSVersion) } if !compactHeader && m.Uptime != "" { - optionalInfoParts = append(optionalInfoParts, subtleStyle.Render("up "+m.Uptime)) + uptimeText := "up " + m.Uptime + switch uptimeSeverity(m.UptimeSeconds) { + case "danger": + uptimeText = dangerStyle.Render(uptimeText + " ↻") + case "warn": + uptimeText = warnStyle.Render(uptimeText) + default: + uptimeText = subtleStyle.Render(uptimeText) + } + optionalInfoParts = append(optionalInfoParts, uptimeText) } headLeft := title + " " + scoreText @@ -234,6 +248,35 @@ func getScoreStyle(score int) lipgloss.Style { } } +func renderProcessAlertBar(alerts []ProcessAlert, width int) string { + active := activeAlerts(alerts) + if len(active) == 0 { + return "" + } + + focus := active[0] + + text := fmt.Sprintf( + "ALERT %s at %.1f%% for %s (threshold %.1f%%)", + formatProcessLabel(ProcessInfo{PID: focus.PID, Name: focus.Name}), + focus.CPU, + focus.Window, + focus.Threshold, + ) + if len(active) > 1 { + text += fmt.Sprintf(" · +%d more", len(active)-1) + } + + return renderBanner(alertBarStyle, text, width) +} + +func renderBanner(style lipgloss.Style, text string, width int) string { + if width > 0 { + style = style.MaxWidth(width) + } + return style.Render(text) +} + func renderCPUCard(cpu CPUStatus, thermal ThermalStatus) cardData { var lines []string @@ -346,7 +389,7 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { return cardData{icon: iconMemory, title: "Memory", lines: lines} } -func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData { +func renderDiskCard(disks []DiskStatus, io DiskIOStatus, trashSize uint64, trashApprox bool) cardData { var lines []string if len(disks) == 0 { lines = append(lines, subtleStyle.Render("Collecting...")) @@ -365,7 +408,16 @@ func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData { addGroup("EXTR", external) if len(lines) == 0 { lines = append(lines, subtleStyle.Render("No disks detected")) + } else if len(disks) == 1 { + lines = append(lines, formatDiskMetaLine(disks[0])) + } + } + if trashSize > 0 { + prefix := "" + if trashApprox { + prefix = "~" } + lines = append(lines, fmt.Sprintf("%-6s %s%s", "Trash", prefix, humanBytesShort(trashSize))) } readBar := ioBar(io.ReadRate) writeBar := ioBar(io.WriteRate) @@ -398,8 +450,19 @@ func formatDiskLine(label string, d DiskStatus) string { } bar := progressBar(d.UsedPercent) used := humanBytesShort(d.Used) - total := humanBytesShort(d.Total) - return fmt.Sprintf("%-6s %s %5.1f%%, %s/%s", label, bar, d.UsedPercent, used, total) + free := uint64(0) + if d.Total > d.Used { + free = d.Total - d.Used + } + return fmt.Sprintf("%-6s %s %s used, %s free", label, bar, used, humanBytesShort(free)) +} + +func formatDiskMetaLine(d DiskStatus) string { + parts := []string{humanBytesShort(d.Total)} + if d.Fstype != "" { + parts = append(parts, strings.ToUpper(d.Fstype)) + } + return fmt.Sprintf("Total %s", strings.Join(parts, " · ")) } func ioBar(rate float64) string { @@ -435,7 +498,7 @@ func buildCards(m MetricsSnapshot, width int) []cardData { cards := []cardData{ renderCPUCard(m.CPU, m.Thermal), renderMemoryCard(m.Memory, width), - renderDiskCard(m.Disks, m.DiskIO), + renderDiskCard(m.Disks, m.DiskIO, m.TrashSize, m.TrashApprox), renderBatteryCard(m.Batteries, m.Thermal), renderProcessCard(m.TopProcesses), renderNetworkCard(m.Network, m.NetworkHistory, m.Proxy, width), @@ -594,15 +657,34 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { lines = append(lines, statusStyle.Render(statusText+statusIcon)) healthParts := []string{} - if b.Health != "" { + + // Battery health assessment label. + if b.CycleCount > 0 || b.Capacity > 0 { + label, severity := batteryHealthLabel(b.CycleCount, b.Capacity) + switch severity { + case "danger": + healthParts = append(healthParts, dangerStyle.Render(label)) + case "warn": + healthParts = append(healthParts, warnStyle.Render(label)) + default: + healthParts = append(healthParts, okStyle.Render(label)) + } + } else if b.Health != "" { healthParts = append(healthParts, b.Health) } + if b.CycleCount > 0 { - healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount)) + cycleText := fmt.Sprintf("%d cycles", b.CycleCount) + if b.CycleCount > batteryCycleDanger { + cycleText = dangerStyle.Render(cycleText) + } else if b.CycleCount > batteryCycleWarn { + cycleText = warnStyle.Render(cycleText) + } + healthParts = append(healthParts, cycleText) } - if thermal.CPUTemp > 0 { - tempText := colorizeTemp(thermal.CPUTemp) + "°C" // Reuse common color logic + if thermal.BatteryTemp > 0 { + tempText := "Battery " + colorizeTemp(thermal.BatteryTemp) + "°C" healthParts = append(healthParts, tempText) } diff --git a/Resources/mole/cmd/status/view_test.go b/Resources/mole/cmd/status/view_test.go index d49f72b..823eef0 100644 --- a/Resources/mole/cmd/status/view_test.go +++ b/Resources/mole/cmd/status/view_test.go @@ -749,29 +749,52 @@ func TestMiniBar(t *testing.T) { func TestFormatDiskLine(t *testing.T) { tests := []struct { - name string - label string - disk DiskStatus + name string + label string + disk DiskStatus + wantUsed string + wantFree string + wantNoSubstr string }{ { - name: "empty label defaults to DISK", - label: "", - disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + name: "empty label defaults to DISK", + label: "", + disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30}, + wantUsed: "100G used", + wantFree: "100G free", + wantNoSubstr: "%", + }, + { + name: "internal disk", + label: "INTR", + disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + wantUsed: "336G used", + wantFree: "164G free", + wantNoSubstr: "%", }, { - name: "internal disk", - label: "INTR", - disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30}, + name: "external disk", + label: "EXTR1", + disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + wantUsed: "850G used", + wantFree: "150G free", + wantNoSubstr: "%", }, { - name: "external disk", - label: "EXTR1", - disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30}, + name: "low usage", + label: "INTR", + disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + wantUsed: "15G used", + wantFree: "85G free", + wantNoSubstr: "%", }, { - name: "low usage", - label: "INTR", - disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30}, + name: "used exceeds total clamps free to zero", + label: "INTR", + disk: DiskStatus{UsedPercent: 110.0, Used: 110 << 30, Total: 100 << 30}, + wantUsed: "110G used", + wantFree: "0 free", + wantNoSubstr: "%", }, } @@ -786,9 +809,85 @@ func TestFormatDiskLine(t *testing.T) { if expectedLabel == "" { expectedLabel = "DISK" } - if !contains(got, expectedLabel) { + if !strings.Contains(got, expectedLabel) { t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel) } + if !strings.Contains(got, tt.wantUsed) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain used value %q", tt.label, got, tt.wantUsed) + } + if !strings.Contains(got, tt.wantFree) { + t.Errorf("formatDiskLine(%q, ...) = %q, should contain free value %q", tt.label, got, tt.wantFree) + } + if tt.wantNoSubstr != "" && strings.Contains(got, tt.wantNoSubstr) { + t.Errorf("formatDiskLine(%q, ...) = %q, should not contain %q", tt.label, got, tt.wantNoSubstr) + } + }) + } +} + +func TestRenderDiskCardAddsMetaLineForSingleDisk(t *testing.T) { + card := renderDiskCard([]DiskStatus{{ + UsedPercent: 28.4, + Used: 263 << 30, + Total: 926 << 30, + Fstype: "apfs", + }}, DiskIOStatus{ReadRate: 0, WriteRate: 0.1}, 0, false) + + if len(card.lines) != 4 { + t.Fatalf("renderDiskCard() single disk expected 4 lines, got %d", len(card.lines)) + } + + meta := stripANSI(card.lines[1]) + if meta != "Total 926G · APFS" { + t.Fatalf("renderDiskCard() single disk meta line = %q, want %q", meta, "Total 926G · APFS") + } +} + +func TestRenderDiskCardDoesNotAddMetaLineForMultipleDisks(t *testing.T) { + card := renderDiskCard([]DiskStatus{ + {UsedPercent: 28.4, Used: 263 << 30, Total: 926 << 30, Fstype: "apfs"}, + {UsedPercent: 50.0, Used: 500 << 30, Total: 1000 << 30, Fstype: "apfs"}, + }, DiskIOStatus{}, 0, false) + + if len(card.lines) != 4 { + t.Fatalf("renderDiskCard() multiple disks expected 4 lines, got %d", len(card.lines)) + } + + for _, line := range card.lines { + if stripANSI(line) == "Total 926G · APFS" || stripANSI(line) == "Total 1000G · APFS" { + t.Fatalf("renderDiskCard() multiple disks should not add meta line, got %q", line) + } + } +} + +func TestRenderDiskCardTrashLine(t *testing.T) { + disk := DiskStatus{UsedPercent: 50, Used: 500 << 30, Total: 1000 << 30, Fstype: "apfs"} + tests := []struct { + name string + trashSize uint64 + approx bool + wantLine string + }{ + {"no trash", 0, false, ""}, + {"1.5 GB exact", 1536 << 20, false, "Trash 2G"}, + {"approx 12 GB", 12 << 30, true, "Trash ~12G"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + card := renderDiskCard([]DiskStatus{disk}, DiskIOStatus{}, tt.trashSize, tt.approx) + found := "" + for _, line := range card.lines { + if s := stripANSI(line); len(s) > 5 && s[:5] == "Trash" { + found = s + break + } + } + if tt.wantLine == "" && found != "" { + t.Fatalf("expected no trash line, got %q", found) + } + if tt.wantLine != "" && found != tt.wantLine { + t.Fatalf("trash line = %q, want %q", found, tt.wantLine) + } }) } } @@ -1066,6 +1165,37 @@ func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) { } } +func TestModelViewPadsToTerminalHeight(t *testing.T) { + tests := []struct { + name string + width int + height int + }{ + {"narrow terminal", 60, 40}, + {"wide terminal", 120, 40}, + {"tall terminal", 120, 80}, + {"short terminal", 120, 10}, + {"zero height", 120, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := model{ + width: tt.width, + height: tt.height, + ready: true, + metrics: MetricsSnapshot{}, + } + + view := m.View() + got := lipgloss.Height(view) + if got < tt.height { + t.Errorf("View() height = %d, want >= %d (terminal height)", got, tt.height) + } + }) + } +} + func TestModelViewErrorRendersSingleMole(t *testing.T) { m := model{ width: 120, @@ -1102,16 +1232,3 @@ func stripANSI(s string) string { } return result.String() } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) -} - -func containsMiddle(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/Resources/mole/go.mod b/Resources/mole/go.mod index 153fbad..e2b2d82 100644 --- a/Resources/mole/go.mod +++ b/Resources/mole/go.mod @@ -1,15 +1,13 @@ module github.com/tw93/mole -go 1.24.2 - -toolchain go1.24.6 +go 1.25.0 require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/shirou/gopsutil/v4 v4.26.2 - golang.org/x/sync v0.19.0 + github.com/shirou/gopsutil/v4 v4.26.3 + golang.org/x/sync v0.20.0 ) require ( diff --git a/Resources/mole/go.sum b/Resources/mole/go.sum index 66dea7c..a5932db 100644 --- a/Resources/mole/go.sum +++ b/Resources/mole/go.sum @@ -53,8 +53,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= -github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -67,8 +67,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/Resources/mole/install.sh b/Resources/mole/install.sh index d5eeb1a..cb6f563 100755 --- a/Resources/mole/install.sh +++ b/Resources/mole/install.sh @@ -19,6 +19,7 @@ start_line_spinner() { return } local chars="|/-\\" + # shellcheck disable=SC1003 [[ -z "$chars" ]] && chars='|/-\\' local i=0 (while true; do @@ -302,6 +303,7 @@ write_install_channel_metadata() { local commit_hash="${2:-}" local metadata_file="$CONFIG_DIR/install_channel" + mkdir -p "$CONFIG_DIR" 2> /dev/null || return 1 local tmp_file tmp_file=$(mktemp "${CONFIG_DIR}/install_channel.XXXXXX") || return 1 { @@ -587,6 +589,7 @@ install_files() { if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then if needs_sudo; then log_admin "Admin access required for /usr/local/bin" + sudo -v fi # Atomic update: copy to temporary name first, then move diff --git a/Resources/mole/lib/check/all.sh b/Resources/mole/lib/check/all.sh index 4a6960a..46b422a 100644 --- a/Resources/mole/lib/check/all.sh +++ b/Resources/mole/lib/check/all.sh @@ -13,6 +13,11 @@ list_login_items() { return fi + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return + fi + local raw_items raw_items=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || echo "") [[ -z "$raw_items" || "$raw_items" == "missing value" ]] && return @@ -34,7 +39,8 @@ check_touchid_sudo() { if command -v is_whitelisted > /dev/null && is_whitelisted "check_touchid"; then return; fi # Check if Touch ID is configured for sudo local pam_file="/etc/pam.d/sudo" - if [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null; then + local pam_local="/etc/pam.d/sudo_local" + if grep -q "pam_tid.so" "$pam_file" "$pam_local" 2> /dev/null; then echo -e " ${GREEN}✓${NC} Touch ID Biometric authentication enabled" else # Check if Touch ID is supported @@ -136,8 +142,8 @@ check_firewall() { return fi - # Fall back to macOS built-in firewall check - local firewall_output=$(sudo /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "") + # Fall back to macOS built-in firewall check (no sudo needed for read-only query) + local firewall_output=$(/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2> /dev/null || echo "") if [[ "$firewall_output" == *"State = 1"* ]] || [[ "$firewall_output" == *"State = 2"* ]]; then echo -e " ${GREEN}✓${NC} Firewall Network protection enabled" else @@ -207,6 +213,7 @@ reset_brew_cache() { reset_softwareupdate_cache() { clear_cache_file "$CACHE_DIR/softwareupdate_list" SOFTWARE_UPDATE_LIST="" + SOFTWARE_UPDATE_LIST_LOADED="false" } reset_mole_cache() { @@ -228,19 +235,90 @@ is_cache_valid() { # Cache software update list to avoid calling softwareupdate twice SOFTWARE_UPDATE_LIST="" +SOFTWARE_UPDATE_LIST_LOADED="false" + +software_update_has_entries() { + printf '%s\n' "$1" | grep -qE '^[[:space:]]*\* Label:' +} + +is_macos_software_update_text() { + local text + text=$(printf '%s' "$1" | LC_ALL=C tr '[:upper:]' '[:lower:]') + + case "$text" in + *macos* | *background\ security\ improvement* | *rapid\ security\ response* | *security\ response*) + return 0 + ;; + esac + + return 1 +} + +get_first_macos_software_update_summary() { + printf '%s\n' "$1" | awk ' + /^\* Label:/ { + label=$0 + sub(/^[[:space:]]*\* Label: */, "", label) + next + } + /^[[:space:]]*Title:/ { + title=$0 + sub(/^[[:space:]]*Title: */, "", title) + sub(/, Version:.*/, "", title) + sub(/, Size:.*/, "", title) + combined=tolower(label " " title) + if (combined ~ /macos|background security improvement|rapid security response|security response/) { + print title + exit + } + } + ' +} get_software_updates() { local cache_file="$CACHE_DIR/softwareupdate_list" + if [[ "${SOFTWARE_UPDATE_LIST_LOADED:-false}" == "true" ]]; then + printf '%s\n' "$SOFTWARE_UPDATE_LIST" + return 0 + fi - # Optimized: Use defaults to check if updates are pending (much faster) - local pending_updates - pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0") + if is_cache_valid "$cache_file"; then + SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || true) + SOFTWARE_UPDATE_LIST_LOADED="true" + printf '%s\n' "$SOFTWARE_UPDATE_LIST" + return 0 + fi - if [[ "$pending_updates" -gt 0 ]]; then - echo "Updates Available" + local spinner_started=false + if [[ -t 1 && -z "${SOFTWAREUPDATE_SPINNER_SHOWN:-}" ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking system updates..." + spinner_started=true + export SOFTWAREUPDATE_SPINNER_SHOWN=1 + fi + + local output="" + local sw_status=0 + if output=$(run_with_timeout 10 softwareupdate -l --no-scan 2> /dev/null); then + SOFTWARE_UPDATE_LIST="$output" + ensure_user_file "$cache_file" + printf '%s' "$SOFTWARE_UPDATE_LIST" > "$cache_file" 2> /dev/null || true else - echo "" + sw_status=$? + SOFTWARE_UPDATE_LIST="" + if [[ -f "$cache_file" ]]; then + SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || true) + fi + if [[ -n "${MO_DEBUG:-}" ]]; then + echo "[DEBUG] softwareupdate preload exit status: $sw_status" >&2 + fi + fi + + if [[ "$spinner_started" == "true" ]]; then + stop_inline_spinner fi + + SOFTWARE_UPDATE_LIST_LOADED="true" + printf '%s\n' "$SOFTWARE_UPDATE_LIST" } check_homebrew_updates() { @@ -285,17 +363,26 @@ check_homebrew_updates() { spinner_started=true fi - if formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null); then - : - else - formula_status=$? - fi - - if cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null); then - : - else - cask_status=$? - fi + local _brew_formula_tmp _brew_cask_tmp + _brew_formula_tmp=$(mktemp_file "brew_formula") + _brew_cask_tmp=$(mktemp_file "brew_cask") + ( + run_with_timeout 8 brew outdated --formula --quiet > "$_brew_formula_tmp" 2> /dev/null + echo $? > "${_brew_formula_tmp}.status" + ) & + local _formula_pid=$! + ( + run_with_timeout 8 brew outdated --cask --quiet > "$_brew_cask_tmp" 2> /dev/null + echo $? > "${_brew_cask_tmp}.status" + ) & + local _cask_pid=$! + wait "$_formula_pid" 2> /dev/null || true + wait "$_cask_pid" 2> /dev/null || true + formula_outdated=$(cat "$_brew_formula_tmp" 2> /dev/null || true) + cask_outdated=$(cat "$_brew_cask_tmp" 2> /dev/null || true) + formula_status=$(cat "${_brew_formula_tmp}.status" 2> /dev/null || echo "1") + cask_status=$(cat "${_brew_cask_tmp}.status" 2> /dev/null || echo "1") + rm -f "$_brew_formula_tmp" "$_brew_cask_tmp" "${_brew_formula_tmp}.status" "${_brew_cask_tmp}.status" 2> /dev/null || true if [[ "$spinner_started" == "true" ]]; then stop_inline_spinner @@ -304,8 +391,12 @@ check_homebrew_updates() { if [[ $formula_status -eq 0 || $cask_status -eq 0 ]]; then formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}') cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}') - ensure_user_file "$cache_file" - printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true + # Only cache when both calls succeeded; partial results (one side failed) + # must not be written as zeros — next run should retry the failed side. + if [[ $formula_status -eq 0 && $cask_status -eq 0 ]]; then + ensure_user_file "$cache_file" + printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true + fi elif [[ $formula_status -eq 124 || $cask_status -eq 124 ]]; then printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check timed out" return @@ -346,52 +437,30 @@ check_macos_update() { # Check whitelist if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi - # Fast check using system preferences local updates_available="false" - if [[ $(get_software_updates) == "Updates Available" ]]; then - 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 macos_update_summary="" + local sw_output="" + sw_output=$(get_software_updates) - local 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 + if [[ -n "${MO_DEBUG:-}" ]]; then + echo "[DEBUG] softwareupdate cached output lines: $(printf '%s\n' "$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 + if software_update_has_entries "$sw_output"; then + macos_update_summary=$(get_first_macos_software_update_summary "$sw_output") + if [[ -n "$macos_update_summary" ]] || is_macos_software_update_text "$sw_output"; then + updates_available="true" fi fi export MACOS_UPDATE_AVAILABLE="$updates_available" if [[ "$updates_available" == "true" ]]; then - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available" + if [[ -n "$macos_update_summary" ]]; then + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "$macos_update_summary" + else + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available" + fi else printf " ${GREEN}✓${NC} %-12s %s\n" "macOS" "System up to date" fi @@ -490,7 +559,7 @@ get_appstore_update_labels() { sub(/^[[:space:]]*\* Label: */, "", label) sub(/,.*/, "", label) lower=tolower(label) - if (index(lower, "macos") == 0) { + if (lower !~ /macos|background security improvement|rapid security response|security response/) { print label } } @@ -504,7 +573,7 @@ get_macos_update_labels() { sub(/^[[:space:]]*\* Label: */, "", label) sub(/,.*/, "", label) lower=tolower(label) - if (index(lower, "macos") != 0) { + if (lower ~ /macos|background security improvement|rapid security response|security response/) { print label } } @@ -698,9 +767,100 @@ check_swap_usage() { fi } +check_disk_smart() { + # Check whitelist + if command -v is_whitelisted > /dev/null && is_whitelisted "check_disk_smart"; then return; fi + + if ! command -v diskutil > /dev/null 2>&1; then + return + fi + + local boot_disk smart_status + boot_disk=$(diskutil info / 2> /dev/null | awk -F: '/Part of Whole/ {gsub(/^[ \t]+/, "", $2); print $2}') + [[ -z "$boot_disk" ]] && return + smart_status=$(diskutil info "$boot_disk" 2> /dev/null | awk -F: '/SMART Status/ {gsub(/^[ \t]+/, "", $2); print $2}') + + if [[ -z "$smart_status" ]]; then + return + fi + + if [[ "$smart_status" == "Verified" ]]; then + echo -e " ${GREEN}✓${NC} Disk Health SMART Verified" + elif [[ "$smart_status" == "Failing" ]]; then + echo -e " ${RED}✗${NC} Disk Health ${RED}SMART Failing — back up immediately${NC}" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Health ${YELLOW}SMART: ${smart_status}${NC}" + fi +} + +check_orphan_launch_agents() { + if command -v is_whitelisted > /dev/null && is_whitelisted "check_orphan_launch_agents"; then return; fi + + local -a search_dirs orphans=() + IFS=: read -r -a search_dirs <<< "${MOLE_LAUNCH_AGENT_DIRS:-$HOME/Library/LaunchAgents:/Library/LaunchAgents}" + + local plist label program + for dir in "${search_dirs[@]}"; do + [[ -d "$dir" ]] || continue + while IFS= read -r -d '' plist; do + label=$(basename "$plist" .plist) + [[ "$label" == com.apple.* ]] && continue + program=$(/usr/bin/plutil -extract Program raw -o - "$plist" 2> /dev/null || + /usr/bin/plutil -extract ProgramArguments.0 raw -o - "$plist" 2> /dev/null) + [[ "$program" == /* && ! -e "$program" ]] && orphans+=("$label") + done < <(find "$dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + done + + local count=${#orphans[@]} + if [[ $count -eq 0 ]]; then + echo -e " ${GREEN}✓${NC} Launch Agents None orphaned" + return + fi + + local s="" + ((count > 1)) && s="s" + echo -e " ${GRAY}${ICON_WARNING}${NC} Launch Agents ${YELLOW}${count} orphan${s}${NC}" + local preview="${orphans[0]}" + ((count > 1)) && preview="${preview}, ${orphans[1]}" + ((count > 2)) && preview="${preview}, ${orphans[2]}" + ((count > 3)) && preview="${preview} +$((count - 3))" + echo -e " ${GRAY}${preview}${NC}" +} + check_brew_health() { # Check whitelist if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi + + if ! command -v brew > /dev/null 2>&1; then + return + fi + + # Detect taps with no installed formulae or casks. + local -a stale_taps=() + local installed + installed=$(run_with_timeout 5 brew list --full-name 2> /dev/null || true) + local tap + while IFS= read -r tap; do + [[ -z "$tap" ]] && continue + # Skip the core taps — they are always needed. + [[ "$tap" == "homebrew/core" || "$tap" == "homebrew/cask" ]] && continue + if ! printf '%s\n' "$installed" | grep -q "^${tap}/"; then + stale_taps+=("$tap") + fi + done < <(run_with_timeout 5 brew tap 2> /dev/null) + + local n=${#stale_taps[@]} + if [[ $n -eq 0 ]]; then + echo -e " ${GREEN}✓${NC} Brew Taps All taps in use" + else + local s="" + ((n > 1)) && s="s" + echo -e " ${GRAY}${ICON_WARNING}${NC} Brew Taps ${YELLOW}${n} unused tap${s}${NC}" + local preview="${stale_taps[0]}" + ((n > 1)) && preview="${preview}, ${stale_taps[1]}" + ((n > 2)) && preview="${preview} +$((n - 2))" + echo -e " ${GRAY}${preview}${NC}" + fi } check_system_health() { @@ -709,6 +869,9 @@ check_system_health() { check_memory_usage check_swap_usage check_login_items + check_disk_smart + check_orphan_launch_agents + check_brew_health check_cache_size # Time Machine check is optional; skip by default to avoid noise on systems without backups } diff --git a/Resources/mole/lib/check/dev_environment.sh b/Resources/mole/lib/check/dev_environment.sh new file mode 100644 index 0000000..28f63b3 --- /dev/null +++ b/Resources/mole/lib/check/dev_environment.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# Dev Environment Checks Module +# Surfaces developer-relevant system health information. + +# ============================================================================ +# Helper Functions +# ============================================================================ + +_extract_major_minor() { + printf '%s' "$1" | sed -E 's/^[^0-9]*//' | grep -oE '^[0-9]+\.[0-9]+' +} + +# ============================================================================ +# Dev Environment Checks +# ============================================================================ + +check_launch_agents() { + # Check whitelist + if command -v is_whitelisted > /dev/null && is_whitelisted "check_launch_agents"; then return; fi + + local agents_dir="$HOME/Library/LaunchAgents" + + if [[ ! -d "$agents_dir" ]]; then + echo -e " ${GREEN}✓${NC} Launch Agents All healthy" + return + fi + + local broken_count=0 + local -a broken_labels=() + + for plist in "$agents_dir"/*.plist; do + [[ -f "$plist" ]] || continue + + local label + label=$(basename "$plist" .plist) + + local binary="" + binary=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$plist" 2> /dev/null || true) + if [[ -z "$binary" ]]; then + binary=$(/usr/libexec/PlistBuddy -c "Print :Program" "$plist" 2> /dev/null || true) + fi + + if [[ -n "$binary" && ! -e "$binary" ]]; then + broken_count=$((broken_count + 1)) + broken_labels+=("$label") + fi + done + + if [[ $broken_count -eq 0 ]]; then + echo -e " ${GREEN}✓${NC} Launch Agents All healthy" + else + printf " ${GRAY}%s${NC} %-14s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Launch Agents" "${broken_count} broken" + + local preview_limit=3 + ((preview_limit > broken_count)) && preview_limit=$broken_count + + local detail="" + for ((i = 0; i < preview_limit; i++)); do + if [[ $i -eq 0 ]]; then + detail="${broken_labels[$i]}" + else + detail="${detail}, ${broken_labels[$i]}" + fi + done + + if ((broken_count > preview_limit)); then + local remaining=$((broken_count - preview_limit)) + detail="${detail} +${remaining}" + fi + + printf " ${GRAY}%s${NC}\n" "$detail" + fi +} + +check_dev_tools() { + # Check whitelist + if command -v is_whitelisted > /dev/null && is_whitelisted "check_dev_tools"; then return; fi + + local -a tools=(git node python3 brew go xcode-select) + local -a found=() + + for tool in "${tools[@]}"; do + if command -v "$tool" > /dev/null 2>&1; then + found+=("$tool") + fi + done + + if [[ ${#found[@]} -eq 0 ]]; then + echo -e " ${GREEN}✓${NC} Dev Tools None detected" + else + local found_list + found_list=$(printf '%s, ' "${found[@]}") + found_list="${found_list%, }" + echo -e " ${GREEN}✓${NC} Dev Tools ${#found[@]} found (${found_list})" + fi +} + +check_version_mismatches() { + # Check whitelist + if command -v is_whitelisted > /dev/null && is_whitelisted "check_version_mismatches"; then return; fi + + local -a conflicts=() + + # Check psql client vs postgres server + if command -v psql > /dev/null 2>&1 && command -v postgres > /dev/null 2>&1; then + local psql_ver postgres_ver + psql_ver=$(_extract_major_minor "$(psql --version 2> /dev/null || true)") + postgres_ver=$(_extract_major_minor "$(postgres --version 2> /dev/null || true)") + if [[ -n "$psql_ver" && -n "$postgres_ver" && "$psql_ver" != "$postgres_ver" ]]; then + conflicts+=("psql ${psql_ver} vs server ${postgres_ver}") + fi + fi + + # Check python3 vs pyenv + if command -v python3 > /dev/null 2>&1 && command -v pyenv > /dev/null 2>&1; then + local python_ver pyenv_ver + python_ver=$(_extract_major_minor "$(python3 --version 2> /dev/null || true)") + pyenv_ver=$(pyenv version 2> /dev/null | awk '{print $1}' || true) + if [[ -n "$pyenv_ver" && "$pyenv_ver" != "system" ]]; then + pyenv_ver=$(_extract_major_minor "$pyenv_ver") + if [[ -n "$python_ver" && -n "$pyenv_ver" && "$python_ver" != "$pyenv_ver" ]]; then + conflicts+=("python3 ${python_ver} vs pyenv ${pyenv_ver}") + fi + fi + fi + + if [[ ${#conflicts[@]} -eq 0 ]]; then + echo -e " ${GREEN}✓${NC} Versions No conflicts" + else + local description + description=$(printf '%s; ' "${conflicts[@]}") + description="${description%; }" + printf " ${GRAY}%s${NC} %-14s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Versions" "$description" + fi +} + +check_all_dev_environment() { + echo -e "${BLUE}${ICON_ARROW}${NC} Dev Environment" + check_launch_agents + check_dev_tools + check_version_mismatches +} diff --git a/Resources/mole/lib/check/health_json.sh b/Resources/mole/lib/check/health_json.sh index cdda7fa..8b118e4 100644 --- a/Resources/mole/lib/check/health_json.sh +++ b/Resources/mole/lib/check/health_json.sh @@ -130,18 +130,29 @@ EOF items+=('fix_broken_configs|Broken Config Repair|Fix corrupted preferences files|true') items+=('network_optimization|Network Cache Refresh|Optimize DNS cache & restart mDNSResponder|true') - # Advanced optimizations (high value, auto-run with safety checks) + # Advanced optimizations (auto-run, non-destructive or regenerated by macOS) items+=('sqlite_vacuum|Database Optimization|Compress SQLite databases for Mail, Safari & Messages (skips if apps are running)|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 (skips if browsers are running)|true') items+=('dock_refresh|Dock Refresh|Fix broken icons and visual glitches in the Dock|true') + items+=('prevent_network_dsstore|Prevent Finder .DS_Store|Set a persistent Finder preference to stop writing .DS_Store on SMB/AFP/NFS and USB volumes|true') - # System performance optimizations (new) + # System performance optimizations (auto-run, non-destructive) 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') + items+=('periodic_maintenance|Periodic Maintenance|Run macOS daily/weekly/monthly maintenance scripts if stale|true') + items+=('shared_file_list_repair|Shared File Lists|Repair corrupted Finder favorites and recent documents|true') + items+=('disk_verify|Disk Health|Verify filesystem integrity|true') + items+=('login_items_audit|Login Items|Audit login items for broken entries|true') + + # System database cleanup (auto-run, low risk) + items+=('quarantine_cleanup|Quarantine Database Cleanup|Clear Gatekeeper download tracking history|true') + items+=('launch_agents_cleanup|Launch Agents Cleanup|Remove broken LaunchAgents whose binaries no longer exist|true') + items+=('notification_cleanup|Notifications|Clean old delivered notifications to reduce database bloat|true') + items+=('coreduet_cleanup|Usage Data|Clean old usage tracking data|true') # Removed high-risk optimizations: # - startup_items_cleanup: Risk of deleting legitimate app helpers diff --git a/Resources/mole/lib/clean/app_caches.sh b/Resources/mole/lib/clean/app_caches.sh index d99ebea..b3d364b 100644 --- a/Resources/mole/lib/clean/app_caches.sh +++ b/Resources/mole/lib/clean/app_caches.sh @@ -1,6 +1,61 @@ #!/bin/bash # User GUI Applications Cleanup Module (desktop apps, media, utilities). set -euo pipefail +# Xcode DerivedData cleanup with project count and size reporting. +# Fully regenerated on next build — safe to remove. +clean_xcode_derived_data() { + local dd_dir="$HOME/Library/Developer/Xcode/DerivedData" + + [[ -d "$dd_dir" ]] || return 0 + + # Skip while Xcode is running to avoid build failures. + if pgrep -x "Xcode" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData cleanup" + return 0 + fi + + # Count projects (each subdirectory is a project build). + local -a projects=() + while IFS= read -r -d '' dir; do + projects+=("$dir") + done < <(command find "$dd_dir" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null || true) + + local project_count=${#projects[@]} + [[ $project_count -eq 0 ]] && return 0 + + # Calculate total size. + local size_kb=0 + size_kb=$(du -skP "$dd_dir" 2> /dev/null | awk '{print $1}') || size_kb=0 + local size_human + size_human=$(bytes_to_human "$((size_kb * 1024))") + + local project_label="projects" + [[ $project_count -eq 1 ]] && project_label="project" + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode DerivedData · ${project_count} ${project_label}, ${size_human}" + note_activity + return 0 + fi + + # Remove all project build dirs using safe_remove. + local removed=0 + for dir in "${projects[@]}"; do + if safe_remove "$dir" "true"; then + removed=$((removed + 1)) + fi + done + + if [[ $removed -gt 0 ]]; then + local line_color + line_color=$(cleanup_result_color_kb "$size_kb" 2> /dev/null || echo "$GREEN") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode DerivedData · ${project_count} ${project_label}, ${line_color}${size_human}${NC}" + files_cleaned=$((${files_cleaned:-0} + removed)) + total_size_cleaned=$((${total_size_cleaned:-0} + size_kb)) + total_items=$((${total_items:-0} + removed)) + note_activity + fi +} # Xcode and iOS tooling. clean_xcode_tools() { # Skip DerivedData/Archives while Xcode is running. @@ -8,15 +63,66 @@ clean_xcode_tools() { if pgrep -x "Xcode" > /dev/null 2>&1; then xcode_running=true fi - safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache" - safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files" + # Skip Simulator caches/temp files while Simulator is running to avoid crashes. + local simulator_running=false + if pgrep -x "Simulator" > /dev/null 2>&1; then + simulator_running=true + fi + if [[ "$simulator_running" == "false" ]]; then + safe_clean ~/Library/Developer/CoreSimulator/Caches/* "Simulator cache" + safe_clean ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/* "Simulator temp files" + safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs" + # Remove unavailable simulator devices (not supported by the current Xcode SDK). + # run_with_timeout guards against xcrun blocking when only CLT is installed + # (can launch an invisible install dialog or wait on CoreSimulator XPC indefinitely). + if command -v xcrun > /dev/null 2>&1; then + local unavail_count + local unavailable_devices_output="" + + # Tests may mock xcrun as a shell function. Timeout wrappers execute + # in a separate process and cannot reliably invoke exported functions. + # Prefer direct function invocation in that case. + if declare -F xcrun > /dev/null 2>&1; then + unavailable_devices_output=$(xcrun simctl list devices unavailable 2> /dev/null || true) + else + unavailable_devices_output=$(run_with_timeout 2 xcrun simctl list devices unavailable 2> /dev/null || true) + if [[ -z "$unavailable_devices_output" ]]; then + unavailable_devices_output=$(xcrun simctl list devices unavailable 2> /dev/null || true) + fi + fi + unavail_count=$(printf '%s\n' "$unavailable_devices_output" | command awk '/\([0-9A-F-]{36}\)/ { count++ } END { print count+0 }') + [[ "$unavail_count" =~ ^[0-9]+$ ]] || unavail_count=0 + if [[ "$unavail_count" -gt 0 ]]; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Unavailable simulators · would delete ${unavail_count} devices" + else + # Capture exit code so a timeout (124) or simctl error + # is reported instead of falsely echoing SUCCESS. + local _delete_rc=0 + if declare -F xcrun > /dev/null 2>&1; then + xcrun simctl delete unavailable > /dev/null 2>&1 || _delete_rc=$? + else + run_with_timeout 5 xcrun simctl delete unavailable > /dev/null 2>&1 || _delete_rc=$? + fi + if [[ $_delete_rc -eq 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Unavailable simulators · deleted ${unavail_count} devices" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Unavailable simulators · simctl delete failed (exit=${_delete_rc})" + debug_log "xcrun simctl delete unavailable returned $_delete_rc" + fi + fi + note_activity + fi + fi + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Simulator is running, skipping Simulator cache/temp/log cleanup" + fi 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/watchOS\ Device\ Logs/* "watchOS device logs" - safe_clean ~/Library/Logs/CoreSimulator/* "CoreSimulator logs" safe_clean ~/Library/Developer/Xcode/Products/* "Xcode build products" if [[ "$xcode_running" == "false" ]]; then - safe_clean ~/Library/Developer/Xcode/DerivedData/* "Xcode derived data" + clean_xcode_derived_data safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives" safe_clean ~/Library/Developer/Xcode/DocumentationCache/* "Xcode documentation cache" safe_clean ~/Library/Developer/Xcode/DocumentationIndex/* "Xcode documentation index" @@ -31,6 +137,8 @@ clean_code_editors() { 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/Caches/com.sublimetext.*/* "Sublime Text cache" + safe_clean ~/Library/Caches/Zed/* "Zed cache" + safe_clean ~/Library/Logs/Zed/* "Zed logs" } # Communication apps. clean_communication_apps() { @@ -40,6 +148,7 @@ clean_communication_apps() { safe_clean ~/Library/Caches/us.zoom.xos/* "Zoom cache" safe_clean ~/Library/Caches/com.tencent.xinWeChat/* "WeChat cache" safe_clean ~/Library/Caches/ru.keepcoder.Telegram/* "Telegram cache" + safe_clean ~/Library/Caches/com.microsoft.teams2/* "Microsoft Teams cache" safe_clean ~/Library/Caches/net.whatsapp.WhatsApp/* "WhatsApp cache" safe_clean ~/Library/Caches/com.skype.skype/* "Skype cache" @@ -65,6 +174,13 @@ clean_ai_apps() { safe_clean ~/Library/Caches/com.openai.chat/* "ChatGPT cache" safe_clean ~/Library/Caches/com.anthropic.claudefordesktop/* "Claude desktop cache" safe_clean ~/Library/Logs/Claude/* "Claude logs" + safe_clean ~/Library/Logs/com.openai.codex/* "Codex CLI logs" + # Codex (OpenAI, Electron) + safe_clean ~/Library/Application\ Support/Codex/Cache/* "Codex cache" + safe_clean ~/Library/Application\ Support/Codex/Code\ Cache/* "Codex code cache" + safe_clean ~/Library/Application\ Support/Codex/GPUCache/* "Codex GPU cache" + safe_clean ~/Library/Application\ Support/Codex/DawnGraphiteCache/* "Codex Dawn cache" + safe_clean ~/Library/Application\ Support/Codex/DawnWebGPUCache/* "Codex WebGPU cache" } # Design and creative tools. clean_design_tools() { @@ -74,13 +190,13 @@ clean_design_tools() { safe_clean ~/Library/Caches/com.adobe.*/* "Adobe app caches" safe_clean ~/Library/Caches/com.figma.Desktop/* "Figma cache" safe_clean ~/Library/Application\ Support/Adobe/Common/Media\ Cache\ Files/* "Adobe media cache files" - # Raycast cache is protected (clipboard history, images). } # Video editing tools. clean_video_tools() { 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.blackmagic-design.DaVinciResolve/* "DaVinci Resolve cache" + safe_clean ~/Movies/CacheClip/* "DaVinci Resolve CacheClip" safe_clean ~/Library/Caches/com.adobe.PremierePro.*/* "Premiere Pro cache" } # 3D and CAD tools. @@ -99,22 +215,23 @@ clean_productivity_apps() { safe_clean ~/Library/Caches/com.filo.client/* "Filo cache" safe_clean ~/Library/Caches/com.flomoapp.mac/* "Flomo cache" safe_clean ~/Library/Application\ Support/Quark/Cache/videoCache/* "Quark video cache" + safe_clean ~/Library/Containers/com.ranchero.NetNewsWire-Evergreen/Data/Library/Caches/* "NetNewsWire cache" + safe_clean ~/Library/Containers/com.ideasoncanvas.mindnode/Data/Library/Caches/* "MindNode cache" + safe_clean ~/.cache/kaku/* "Kaku cache" } # Music/media players (protect Spotify offline music). clean_media_players() { local spotify_cache="$HOME/Library/Caches/com.spotify.client" local spotify_data="$HOME/Library/Application Support/Spotify" local has_offline_music=false - # Heuristics: offline DB or large cache. - if [[ -f "$spotify_data/PersistentCache/Storage/offline.bnk" ]] || + # offline.bnk exists even with no offline downloads; only treat it as evidence + # when it has real content (>1 KB). Encrypted track blobs (*.file) are reliable. + local bnk_file="$spotify_data/PersistentCache/Storage/offline.bnk" + local bnk_size=0 + [[ -f "$bnk_file" ]] && bnk_size=$(stat -f%z "$bnk_file" 2> /dev/null || echo 0) + if [[ $bnk_size -gt 1024 ]] || [[ -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 - elif [[ -d "$spotify_cache" ]]; then - local cache_size_kb - cache_size_kb=$(get_path_size_kb "$spotify_cache") - if [[ $cache_size_kb -ge 512000 ]]; then - has_offline_music=true - fi fi if [[ "$has_offline_music" == "true" ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Spotify cache protected · offline music detected" @@ -146,6 +263,8 @@ clean_video_players() { safe_clean ~/Library/Caches/tv.danmaku.bili/* "Bilibili cache" safe_clean ~/Library/Caches/com.douyu.*/* "Douyu cache" safe_clean ~/Library/Caches/com.huya.*/* "Huya cache" + safe_clean ~/Library/Caches/smart.stremio*/* "Stremio cache" + safe_clean ~/Library/Application\ Support/stremio/stremio-server/stremio-cache/* "Stremio server cache" } # Download managers. clean_download_managers() { @@ -179,6 +298,11 @@ clean_gaming_platforms() { safe_clean ~/.lunarclient/logs/* "Lunar Client logs" safe_clean ~/.lunarclient/offline/*/logs/* "Lunar Client offline logs" safe_clean ~/.lunarclient/offline/files/*/logs/* "Lunar Client offline file logs" + safe_clean ~/Library/Caches/net.pcsx2.PCSX2/* "PCSX2 cache" + safe_clean ~/Library/Application\ Support/PCSX2/cache/* "PCSX2 shader cache" + safe_clean ~/Library/Logs/PCSX2/* "PCSX2 logs" + safe_clean ~/Library/Caches/net.rpcs3.rpcs3/* "RPCS3 cache" + safe_clean ~/Library/Application\ Support/rpcs3/logs/* "RPCS3 logs" } # Translation/dictionary apps. clean_translation_apps() { @@ -210,11 +334,27 @@ clean_shell_utils() { safe_clean ~/.wget-hsts "wget HSTS cache" safe_clean ~/.cacher/logs/* "Cacher logs" safe_clean ~/.kite/logs/* "Kite logs" + safe_clean ~/Library/Caches/dev.warp.Warp-Stable/* "Warp cache" + safe_clean ~/Library/Logs/warp.log "Warp log" + safe_clean ~/Library/Caches/SentryCrash/Warp/* "Warp Sentry crash reports" + safe_clean ~/Library/Caches/com.mitchellh.ghostty/* "Ghostty cache" } # Input methods and system utilities. clean_system_utils() { safe_clean ~/Library/Caches/com.runjuu.Input-Source-Pro/* "Input Source Pro cache" safe_clean ~/Library/Caches/macos-wakatime.WakaTime/* "WakaTime cache" + # WeType input method (image and dict update cache, not engine or user dict) + safe_clean ~/Library/Application\ Support/WeType/com.onevcat.Kingfisher.ImageCache.WeType/* "WeType image cache" + safe_clean ~/Library/Application\ Support/WeType/DictUpdate/* "WeType dict update cache" + # mihomo-party proxy tool (Electron) + safe_clean ~/Library/Application\ Support/mihomo-party/Cache/* "mihomo-party cache" + safe_clean ~/Library/Application\ Support/mihomo-party/Code\ Cache/* "mihomo-party code cache" + safe_clean ~/Library/Application\ Support/mihomo-party/GPUCache/* "mihomo-party GPU cache" + safe_clean ~/Library/Application\ Support/mihomo-party/DawnGraphiteCache/* "mihomo-party Dawn cache" + safe_clean ~/Library/Application\ Support/mihomo-party/DawnWebGPUCache/* "mihomo-party WebGPU cache" + safe_clean ~/Library/Application\ Support/mihomo-party/logs/* "mihomo-party logs" + # Stash proxy tool + safe_clean ~/Library/Caches/ws.stash.app.mac/* "Stash cache" } # Note-taking apps. clean_note_apps() { @@ -229,6 +369,9 @@ clean_note_apps() { clean_launcher_apps() { safe_clean ~/Library/Caches/com.runningwithcrayons.Alfred/* "Alfred cache" safe_clean ~/Library/Caches/cx.c3.theunarchiver/* "The Unarchiver cache" + # Raycast: only clean network and FS caches; Clipboard subfolder contains user's clipboard history. + safe_clean ~/Library/Caches/com.raycast.macos/urlcache/* "Raycast URL cache" + safe_clean ~/Library/Caches/com.raycast.macos/fsCachedData/* "Raycast FS cache" } # Remote desktop tools. clean_remote_desktop() { @@ -240,8 +383,6 @@ clean_remote_desktop() { # Main entry for GUI app cleanup. clean_user_gui_applications() { stop_section_spinner - clean_xcode_tools - clean_code_editors clean_communication_apps clean_dingtalk clean_ai_apps diff --git a/Resources/mole/lib/clean/apps.sh b/Resources/mole/lib/clean/apps.sh index 75402f3..1b68847 100644 --- a/Resources/mole/lib/clean/apps.sh +++ b/Resources/mole/lib/clean/apps.sh @@ -36,7 +36,7 @@ clean_ds_store_tree() { total_bytes=$((total_bytes + size)) file_count=$((file_count + 1)) if [[ "$DRY_RUN" != "true" ]]; then - rm -f "$ds_file" 2> /dev/null || true + safe_remove "$ds_file" true 2> /dev/null || true fi if [[ $file_count -ge $MOLE_MAX_DS_STORE_FILES ]]; then break @@ -48,12 +48,14 @@ clean_ds_store_tree() { if [[ $file_count -gt 0 ]]; then local size_human size_human=$(bytes_to_human "$total_bytes") + local size_kb=$(((total_bytes + 1023) / 1024)) if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$file_count files, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}$file_count files, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} $label${NC}, ${line_color}$file_count files, $size_human${NC}" fi - local size_kb=$(((total_bytes + 1023) / 1024)) files_cleaned=$((files_cleaned + file_count)) total_size_cleaned=$((total_size_cleaned + size_kb)) total_items=$((total_items + 1)) @@ -112,7 +114,7 @@ scan_installed_apps() { local plist_path="$app_path/Contents/Info.plist" [[ ! -f "$plist_path" ]] && continue local bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$plist_path" 2> /dev/null || echo "") - if [[ -n "$bundle_id" ]]; then + if [[ -n "$bundle_id" && "$bundle_id" != "missing value" ]]; then echo "$bundle_id" count=$((count + 1)) fi @@ -123,8 +125,11 @@ scan_installed_apps() { done # Collect running apps and LaunchAgents to avoid false orphan cleanup. ( - 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" + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + 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' -e '/^missing value$/d' > "$scan_tmp_dir/running.txt" + fi # Fallback: lsappinfo is more reliable than osascript if command -v lsappinfo > /dev/null 2>&1; then run_with_timeout 3 lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true @@ -218,7 +223,8 @@ is_bundle_orphaned() { if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then # Initialize cache file if needed if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then - ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") + ensure_mole_temp_root + ORPHAN_MDFIND_CACHE_FILE=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole_mdfind_cache.XXXXXX") register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" fi @@ -232,7 +238,7 @@ is_bundle_orphaned() { else # Query mdfind with strict timeout (2 seconds max) local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") + app_exists=$(run_with_timeout 5 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") if [[ -n "$app_exists" ]]; then echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" return 1 @@ -274,7 +280,8 @@ is_claude_vm_bundle_orphaned() { fi if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then - ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") + ensure_mole_temp_root + ORPHAN_MDFIND_CACHE_FILE=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole_mdfind_cache.XXXXXX") register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" fi @@ -283,7 +290,7 @@ is_claude_vm_bundle_orphaned() { fi if ! grep -Fxq "NOTFOUND:$claude_bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$claude_bundle_id'" 2> /dev/null | head -1 || echo "") + app_exists=$(run_with_timeout 5 mdfind "kMDItemCFBundleIdentifier == '$claude_bundle_id'" 2> /dev/null | head -1 || echo "") if [[ -n "$app_exists" ]]; then echo "FOUND:$claude_bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" return 1 @@ -311,19 +318,31 @@ clean_orphaned_app_data() { local total_orphaned_kb=0 start_section_spinner "Scanning orphaned app resources..." - local claude_vm_bundle="$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" - if is_claude_vm_bundle_orphaned "$claude_vm_bundle" "$installed_bundles"; then - local claude_vm_size_kb - claude_vm_size_kb=$(get_path_size_kb "$claude_vm_bundle") - if [[ -n "$claude_vm_size_kb" && "$claude_vm_size_kb" != "0" ]]; then - if safe_clean "$claude_vm_bundle" "Orphaned Claude workspace VM"; then - orphaned_count=$((orphaned_count + 1)) - total_orphaned_kb=$((total_orphaned_kb + claude_vm_size_kb)) + # Dynamically discover Claude VM bundles (path may vary across versions). + local claude_support_dir="$HOME/Library/Application Support/Claude" + if [[ -d "$claude_support_dir" ]]; then + while IFS= read -r -d '' claude_vm_bundle; do + if is_claude_vm_bundle_orphaned "$claude_vm_bundle" "$installed_bundles"; then + if is_path_whitelisted "$claude_vm_bundle"; then + debug_log "Skipping whitelisted orphan: $claude_vm_bundle" + continue + fi + local claude_vm_size_kb + claude_vm_size_kb=$(get_path_size_kb "$claude_vm_bundle") + if [[ -n "$claude_vm_size_kb" && "$claude_vm_size_kb" != "0" ]]; then + if safe_clean "$claude_vm_bundle" "Orphaned Claude workspace VM"; then + orphaned_count=$((orphaned_count + 1)) + total_orphaned_kb=$((total_orphaned_kb + claude_vm_size_kb)) + fi + fi fi - fi + done < <(find "$claude_support_dir" -maxdepth 3 -name "*.bundle" -type d -print0 2> /dev/null || true) fi # CRITICAL: NEVER add LaunchAgents or LaunchDaemons (breaks login items/startup apps). + # CRITICAL: NEVER add Containers/ (managed by containermanagerd, stubs expected). + # CRITICAL: NEVER add Application Scripts/ (could break Shortcuts/Automator workflows). + # CRITICAL: NEVER add Group Containers/ (TeamID.BundleID names cause false-positive orphan checks). local -a resource_types=( "$HOME/Library/Caches|Caches|com.*:org.*:net.*:io.*" "$HOME/Library/Logs|Logs|com.*:org.*:net.*:io.*" @@ -331,6 +350,8 @@ clean_orphaned_app_data() { "$HOME/Library/WebKit|WebKit|com.*:org.*:net.*:io.*" "$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*" "$HOME/Library/Cookies|Cookies|*.binarycookies" + "$HOME/Library/Application Support|AppSupport|com.*:org.*:net.*:io.*" + "$HOME/Library/Preferences|Prefs|com.*:org.*:net.*:io.*" ) for resource_type in "${resource_types[@]}"; do IFS='|' read -r base_path label patterns <<< "$resource_type" @@ -369,7 +390,12 @@ clean_orphaned_app_data() { local bundle_id=$(basename "$match") bundle_id="${bundle_id%.savedState}" bundle_id="${bundle_id%.binarycookies}" + bundle_id="${bundle_id%.plist}" if is_bundle_orphaned "$bundle_id" "$match" "$installed_bundles"; then + if is_path_whitelisted "$match"; then + debug_log "Skipping whitelisted orphan: $match" + continue + fi local size_kb size_kb=$(get_path_size_kb "$match") if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then @@ -446,7 +472,8 @@ clean_orphaned_system_services() { if [[ -n "$bundle_id" ]] && [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] && [[ ${#bundle_id} -ge 5 ]]; then if [[ -z "$mdfind_cache_file" ]]; then - mdfind_cache_file=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") + ensure_mole_temp_root + mdfind_cache_file=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole_mdfind_cache.XXXXXX") register_temp_file "$mdfind_cache_file" fi @@ -455,7 +482,7 @@ clean_orphaned_system_services() { fi if ! grep -Fxq "NOTFOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then local app_found - app_found=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") + app_found=$(run_with_timeout 5 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") if [[ -n "$app_found" ]]; then echo "FOUND:$bundle_id" >> "$mdfind_cache_file" return 0 @@ -541,6 +568,7 @@ clean_orphaned_system_services() { # Skip Apple system files [[ "$filename" == com.apple.* ]] && continue + local matched_known=false for pattern_entry in "${known_orphan_patterns[@]}"; do local file_pattern="${pattern_entry%%:*}" local app_path="${pattern_entry#*:}" @@ -548,21 +576,53 @@ clean_orphaned_system_services() { # shellcheck disable=SC2053 if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then if _system_service_app_exists "$bundle_id" "$app_path"; then - continue + matched_known=true + break fi orphaned_files+=("$helper") local size_kb size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0") total_orphaned_kb=$((total_orphaned_kb + size_kb)) orphaned_count=$((orphaned_count + 1)) + matched_known=true break fi done + + # Generic detection: bundle-ID-style helpers not matched by hardcoded list. + # Privileged helpers are frequently registered via SMJobBless and ship + # *inside* the parent app bundle at Contents/Library/LaunchServices/, + # which Spotlight does not index. Use the shared resolver so we do not + # falsely flag Adobe / 1Password / Docker helpers as orphaned when their + # parent app is installed. See #733. + if [[ "$matched_known" == "false" ]] && [[ "$bundle_id" =~ ^(com|org|net|io)\. ]]; then + if ! bundle_has_installed_app "$bundle_id"; then + orphaned_files+=("$helper") + local size_kb + size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0") + total_orphaned_kb=$((total_orphaned_kb + size_kb)) + orphaned_count=$((orphaned_count + 1)) + fi + fi done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null) fi stop_section_spinner + # Drop whitelisted entries before reporting/cleaning. + if [[ $orphaned_count -gt 0 && ${#WHITELIST_PATTERNS[@]} -gt 0 ]]; then + local -a kept_files=() + for orphan_file in "${orphaned_files[@]}"; do + if is_path_whitelisted "$orphan_file"; then + debug_log "Skipping whitelisted orphan service: $orphan_file" + continue + fi + kept_files+=("$orphan_file") + done + orphaned_count=${#kept_files[@]} + orphaned_files=("${kept_files[@]}") + fi + # Report and clean if [[ $orphaned_count -gt 0 ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" @@ -571,7 +631,7 @@ clean_orphaned_system_services() { local filename filename=$(basename "$orphan_file") - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then debug_log "[DRY RUN] Would remove orphaned service: $orphan_file" else # Unload if it's a LaunchDaemon/LaunchAgent @@ -590,202 +650,20 @@ clean_orphaned_system_services() { else orphaned_kb_display="${total_orphaned_kb}KB" fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display" - note_activity + if [[ "${DRY_RUN:-false}" != "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display" + note_activity + fi fi } # ============================================================================ -# Orphaned LaunchAgent/LaunchDaemon Cleanup (Generic Detection) +# User LaunchAgents # ============================================================================ -# Extract program path from plist (supports both ProgramArguments and Program) -_extract_program_path() { - local plist="$1" - local program="" - - program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null) - if [[ -z "$program" ]]; then - program=$(plutil -extract Program raw "$plist" 2> /dev/null) - fi - - echo "$program" -} - -# Extract associated bundle identifier from plist -_extract_associated_bundle() { - local plist="$1" - local associated="" - - # Try array format first - associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null) - if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then - # Try string format - associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null) - # Filter out dict/array markers - if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then - associated="" - fi - fi - - echo "$associated" -} - -# Check if a LaunchAgent/LaunchDaemon is orphaned using multi-layer verification -# Returns 0 if orphaned, 1 if not orphaned -is_launch_item_orphaned() { - local plist="$1" - - # Layer 1: Check if program path exists - local program=$(_extract_program_path "$plist") - - # No program path - skip (not a standard launch item) - [[ -z "$program" ]] && return 1 - - # Program exists -> not orphaned - [[ -e "$program" ]] && return 1 - - # Layer 2: Check AssociatedBundleIdentifiers - local associated=$(_extract_associated_bundle "$plist") - if [[ -n "$associated" ]]; then - # Check if associated app exists via mdfind - if run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$associated'" 2> /dev/null | head -1 | grep -q .; then - return 1 # Associated app found -> not orphaned - fi - - # Extract vendor name from bundle ID (com.vendor.app -> vendor) - local vendor=$(echo "$associated" | cut -d'.' -f2) - if [[ -n "$vendor" ]] && [[ ${#vendor} -ge 3 ]]; then - # Check if any app from this vendor exists - if find /Applications ~/Applications -maxdepth 2 -iname "*${vendor}*" -type d 2> /dev/null | grep -iq "\.app"; then - return 1 # Vendor app exists -> not orphaned - fi - fi - fi - - # Layer 3: Check Application Support directory activity - if [[ "$program" =~ /Library/Application\ Support/([^/]+)/ ]]; then - local app_support_name="${BASH_REMATCH[1]}" - - # Check both user and system Application Support - for base in "$HOME/Library/Application Support" "/Library/Application Support"; do - local support_path="$base/$app_support_name" - if [[ -d "$support_path" ]]; then - # Check if there are files modified in last 7 days (active usage) - local recent_file=$(find "$support_path" -type f -mtime -7 2> /dev/null | head -1) - if [[ -n "$recent_file" ]]; then - return 1 # Active Application Support -> not orphaned - fi - fi - done - fi - - # Layer 4: Check if app name from program path exists - if [[ "$program" =~ /Applications/([^/]+)\.app/ ]]; then - local app_name="${BASH_REMATCH[1]}" - # Look for apps with similar names (case-insensitive) - if find /Applications ~/Applications -maxdepth 2 -iname "*${app_name}*" -type d 2> /dev/null | grep -iq "\.app"; then - return 1 # Similar app exists -> not orphaned - fi - fi - - # Layer 5: PrivilegedHelper special handling - if [[ "$program" =~ ^/Library/PrivilegedHelperTools/ ]]; then - local filename=$(basename "$plist") - local bundle_id="${filename%.plist}" - - # Extract app hint from bundle ID (com.vendor.app.helper -> vendor) - local app_hint=$(echo "$bundle_id" | sed 's/com\.//; s/\..*helper.*//') - - if [[ -n "$app_hint" ]] && [[ ${#app_hint} -ge 3 ]]; then - # Look for main app - if find /Applications ~/Applications -maxdepth 2 -iname "*${app_hint}*" -type d 2> /dev/null | grep -iq "\.app"; then - return 1 # Helper's main app exists -> not orphaned - fi - fi - fi - - # All checks failed -> likely orphaned - return 0 -} - -# Clean orphaned user-level LaunchAgents -# Only processes ~/Library/LaunchAgents (safer than system-level) +# User-level LaunchAgents are user-owned automation/configuration, not generic +# cleanup targets. `mo clean` must not delete them automatically. clean_orphaned_launch_agents() { - local launch_agents_dir="$HOME/Library/LaunchAgents" - - [[ ! -d "$launch_agents_dir" ]] && return 0 - - start_section_spinner "Scanning orphaned launch agents..." - - local -a orphaned_items=() - local total_orphaned_kb=0 - - # Scan user LaunchAgents - while IFS= read -r -d '' plist; do - local filename=$(basename "$plist") - - # Skip Apple's LaunchAgents - [[ "$filename" == com.apple.* ]] && continue - - local bundle_id="${filename%.plist}" - - # Check if orphaned using multi-layer verification - if is_launch_item_orphaned "$plist"; then - local size_kb=$(get_path_size_kb "$plist") - orphaned_items+=("$bundle_id|$plist") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - fi - done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) - - stop_section_spinner - - local orphaned_count=${#orphaned_items[@]} - - if [[ $orphaned_count -eq 0 ]]; then - return 0 - fi - - # Clean the orphaned items automatically - local removed_count=0 - local dry_run_count=0 - local is_dry_run=false - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - is_dry_run=true - fi - for item in "${orphaned_items[@]}"; do - IFS='|' read -r bundle_id plist_path <<< "$item" - - if [[ "$is_dry_run" == "true" ]]; then - dry_run_count=$((dry_run_count + 1)) - log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent" - continue - fi - - # Try to unload first (if currently loaded) - launchctl unload "$plist_path" 2> /dev/null || true - - # Remove the plist file - if safe_remove "$plist_path" false; then - removed_count=$((removed_count + 1)) - log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" - else - log_operation "clean" "FAILED" "$plist_path" "permission denied" - fi - done - - if [[ "$is_dry_run" == "true" ]]; then - if [[ $dry_run_count -gt 0 ]]; then - local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove $dry_run_count orphaned launch agent(s), ${cleaned_mb}MB" - note_activity - fi - else - if [[ $removed_count -gt 0 ]]; then - local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') - echo " ${GREEN}${ICON_SUCCESS}${NC} Removed $removed_count orphaned launch agent(s), ${cleaned_mb}MB" - note_activity - fi - fi + return 0 } diff --git a/Resources/mole/lib/clean/brew.sh b/Resources/mole/lib/clean/brew.sh index 202c45a..77b9c38 100644 --- a/Resources/mole/lib/clean/brew.sh +++ b/Resources/mole/lib/clean/brew.sh @@ -61,7 +61,7 @@ clean_homebrew() { local autoremove_exit=0 if [[ "$skip_cleanup" == "false" ]]; then brew_tmp_file=$(create_temp_file) - run_with_timeout "$timeout_seconds" brew cleanup > "$brew_tmp_file" 2>&1 & + run_with_timeout "$timeout_seconds" brew cleanup --prune=30 > "$brew_tmp_file" 2>&1 & brew_pid=$! fi autoremove_tmp_file=$(create_temp_file) diff --git a/Resources/mole/lib/clean/caches.sh b/Resources/mole/lib/clean/caches.sh index 72892ce..0c2ceb7 100644 --- a/Resources/mole/lib/clean/caches.sh +++ b/Resources/mole/lib/clean/caches.sh @@ -49,11 +49,17 @@ clean_service_worker_cache() { [[ ! -d "$cache_path" ]] && return 0 local cleaned_size=0 local protected_count=0 + # shellcheck disable=SC2016 while IFS= read -r cache_dir; do [[ ! -d "$cache_dir" ]] && continue # Extract a best-effort domain name from cache folder. 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=0 + local _du_out + if _du_out=$(run_with_timeout 5 du -skP "$cache_dir" 2> /dev/null); then + local _sz="${_du_out%%[^0-9]*}" + [[ "$_sz" =~ ^[0-9]+$ ]] && size="$_sz" + fi local is_protected=false for protected_domain in "${PROTECTED_SW_DOMAINS[@]}"; do if [[ "$domain" == *"$protected_domain"* ]]; then @@ -62,13 +68,21 @@ clean_service_worker_cache() { break fi done + # Service Worker cache dirs are keyed by origin hash, so they never + # match PROTECTED_SW_DOMAINS even when the user added Chrome SW paths + # to their whitelist. Honor the whitelist explicitly — otherwise MV3 + # extensions lose their registered workers mid-session. See #724. + if [[ "$is_protected" == "false" ]] && is_path_whitelisted "$cache_dir"; then + is_protected=true + protected_count=$((protected_count + 1)) + fi if [[ "$is_protected" == "false" ]]; then if [[ "$DRY_RUN" != "true" ]]; then safe_remove "$cache_dir" true || true fi cleaned_size=$((cleaned_size + size)) 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 "$1" -type d -depth 2 2>/dev/null || true' _ "$cache_path") if [[ $cleaned_size -gt 0 ]]; then local spinner_was_running=false if [[ -t 1 && -n "${INLINE_SPINNER_PID:-}" ]]; then @@ -76,11 +90,13 @@ clean_service_worker_cache() { spinner_was_running=true fi local cleaned_mb=$((cleaned_size / 1024)) + local line_color + line_color=$(cleanup_result_color_kb "$cleaned_size") if [[ "$DRY_RUN" != "true" ]]; then if [[ $protected_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB, ${protected_count} protected" + echo -e " ${line_color}${ICON_SUCCESS}${NC} $browser_name Service Worker${NC}, ${line_color}${cleaned_mb}MB${NC}, ${protected_count} protected" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $browser_name Service Worker, ${cleaned_mb}MB" + echo -e " ${line_color}${ICON_SUCCESS}${NC} $browser_name Service Worker${NC}, ${line_color}${cleaned_mb}MB${NC}" fi else echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean ${cleaned_mb}MB, ${protected_count} protected" @@ -117,6 +133,8 @@ project_cache_has_indicators() { # Discover candidate project roots without scanning the whole home directory. discover_project_cache_roots() { local -a roots=() + local -a unique_roots=() + local -a seen_identities=() local root for root in "${MOLE_PURGE_DEFAULT_SEARCH_PATHS[@]}"; do @@ -127,12 +145,18 @@ discover_project_cache_roots() { [[ -d "$root" ]] && roots+=("$root") done < <(mole_purge_read_paths_config "$HOME/.config/mole/purge_paths") + local _indicator_tmp + _indicator_tmp=$(create_temp_file) + local -a _indicator_pids=() + local _max_jobs + _max_jobs=$(get_optimal_parallel_jobs scan) + local dir local base for dir in "$HOME"/*/; do [[ -d "$dir" ]] || continue dir="${dir%/}" - base=$(basename "$dir") + base="${dir##*/}" case "$base" in .* | Library | Applications | Movies | Music | Pictures | Public) @@ -140,14 +164,38 @@ discover_project_cache_roots() { ;; esac - if project_cache_has_indicators "$dir" 5; then - roots+=("$dir") + (project_cache_has_indicators "$dir" 5 && echo "$dir" >> "$_indicator_tmp") & + _indicator_pids+=($!) + + if [[ ${#_indicator_pids[@]} -ge $_max_jobs ]]; then + wait "${_indicator_pids[0]}" 2> /dev/null || true + _indicator_pids=("${_indicator_pids[@]:1}") fi done + for _pid in "${_indicator_pids[@]}"; do + wait "$_pid" 2> /dev/null || true + done + + local _found_dir + while IFS= read -r _found_dir; do + [[ -n "$_found_dir" ]] && roots+=("$_found_dir") + done < "$_indicator_tmp" + rm -f "$_indicator_tmp" [[ ${#roots[@]} -eq 0 ]] && return 0 - printf '%s\n' "${roots[@]}" | LC_ALL=C sort -u + for root in "${roots[@]}"; do + local identity + identity=$(mole_path_identity "$root") + if [[ ${#seen_identities[@]} -gt 0 ]] && mole_identity_in_list "$identity" "${seen_identities[@]}"; then + continue + fi + + seen_identities+=("$identity") + unique_roots+=("$root") + done + + [[ ${#unique_roots[@]} -gt 0 ]] && printf '%s\n' "${unique_roots[@]}" } # Scan a project root for supported build caches while pruning heavy subtrees. @@ -159,7 +207,7 @@ scan_project_cache_root() { local -a find_args=( find -P "$root" -maxdepth 9 -mount - "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" ")" + "(" -name "Library" -o -name ".Trash" -o -name "node_modules" -o -name ".git" -o -name ".svn" -o -name ".hg" -o -name ".venv" -o -name "venv" -o -name ".pnpm-store" -o -name ".fvm" -o -name "DerivedData" -o -name "Pods" -o -name "miniconda3" -o -name "anaconda3" -o -name "miniforge3" -o -name "mambaforge" -o -name "site-packages" ")" -prune -o -type d "(" -name ".next" -o -name "__pycache__" -o -name ".dart_tool" ")" @@ -167,7 +215,26 @@ scan_project_cache_root() { ) local status=0 - run_with_timeout "$scan_timeout" "${find_args[@]}" >> "$output_file" 2> /dev/null || status=$? + local tmp_file + tmp_file=$(create_temp_file) + run_with_timeout "$scan_timeout" "${find_args[@]}" > "$tmp_file" 2> /dev/null || status=$? + + if [[ -s "$tmp_file" ]]; then + while IFS= read -r match_path; do + [[ -z "$match_path" ]] && continue + # Skip __pycache__ dirs with no .pyc/.pyo files (empty or already cleaned) + if [[ "${match_path##*/}" == "__pycache__" ]]; then + local has_bytecode + has_bytecode=$(find "$match_path" -maxdepth 1 \( -name '*.pyc' -o -name '*.pyo' \) 2> /dev/null | head -1) + [[ -z "$has_bytecode" ]] && continue + fi + local project_root="" + project_root=$(project_cache_group_root "$root" "$match_path") + [[ -z "$project_root" ]] && project_root="$root" + printf '%s\t%s\n' "$project_root" "$match_path" >> "$output_file" + done < "$tmp_file" + fi + rm -f "$tmp_file" if [[ $status -eq 124 ]]; then debug_log "Project cache scan timed out: $root" @@ -178,13 +245,203 @@ scan_project_cache_root() { return 0 } +project_cache_group_root() { + local scan_root="$1" + local cache_path="$2" + local candidate + + candidate=$(dirname "$cache_path") + while [[ -n "$candidate" && "$candidate" != "/" ]]; do + if mole_purge_is_project_root "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + [[ "$candidate" == "$scan_root" ]] && break + candidate=$(dirname "$candidate") + done + + printf '%s\n' "$scan_root" +} + +clean_project_cache_target() { + if [[ $# -lt 2 ]]; then + return 0 + fi + + local description="${*: -1}" + local -a target_paths=("${@:1:$#-1}") + + if declare -f safe_clean > /dev/null 2>&1; then + safe_clean "${target_paths[@]}" "$description" || true + return 0 + fi + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + return 0 + fi + + local target_path="" + for target_path in "${target_paths[@]}"; do + [[ -e "$target_path" ]] || continue + safe_remove "$target_path" true || true + done +} + +flush_python_group_if_needed() { + local group_root="$1" + local array_name="$2" + + local group_count=0 + eval 'group_count=${#'"$array_name"'[@]}' + [[ -z "$group_root" || "$group_count" -eq 0 ]] && return 0 + eval 'local -a group_dirs=( "${'"$array_name"'[@]}" )' + # shellcheck disable=SC2154 # group_dirs assigned via eval above + clean_python_bytecode_cache_group "$group_root" "${group_dirs[@]}" +} + +process_project_cache_matches() { + local matches_file="$1" + [[ -f "$matches_file" ]] || return 0 + + local current_python_root="" + local -a current_python_dirs=() + local record_root="" + local cache_dir="" + while IFS=$'\t' read -r record_root cache_dir; do + [[ -n "$record_root" && -n "$cache_dir" ]] || continue + case "${cache_dir##*/}" in + ".next") + flush_python_group_if_needed "$current_python_root" current_python_dirs + current_python_root="" + current_python_dirs=() + [[ -d "$cache_dir/cache" ]] && clean_project_cache_target "$cache_dir/cache"/* "Next.js build cache" || true + ;; + "__pycache__") + if [[ "$record_root" != "$current_python_root" && ${#current_python_dirs[@]} -gt 0 ]]; then + flush_python_group_if_needed "$current_python_root" current_python_dirs + current_python_dirs=() + fi + current_python_root="$record_root" + [[ -d "$cache_dir" ]] && current_python_dirs+=("$cache_dir") + ;; + ".dart_tool") + flush_python_group_if_needed "$current_python_root" current_python_dirs + current_python_root="" + current_python_dirs=() + if [[ -d "$cache_dir" ]]; then + clean_project_cache_target "$cache_dir" "Flutter build cache (.dart_tool)" || true + local build_dir="$(dirname "$cache_dir")/build" + if [[ -d "$build_dir" ]]; then + clean_project_cache_target "$build_dir" "Flutter build cache (build/)" || true + fi + fi + ;; + esac + done < <(LC_ALL=C sort -u "$matches_file" 2> /dev/null) + + flush_python_group_if_needed "$current_python_root" current_python_dirs +} + +clean_python_bytecode_cache_group() { + local project_root="$1" + shift + + local -a cache_dirs=("$@") + [[ ${#cache_dirs[@]} -eq 0 ]] && return 0 + + local display_root + display_root=$(basename "$project_root") + local total_size_kb=0 + local removed_count=0 + local skipped_count=0 + local -a dry_run_paths=() + local -a dry_run_sizes=() + + local cache_dir + for cache_dir in "${cache_dirs[@]}"; do + [[ -d "$cache_dir" ]] || continue + + if should_protect_path "$cache_dir"; then + skipped_count=$((skipped_count + 1)) + whitelist_skipped_count=$((${whitelist_skipped_count:-0} + 1)) + log_operation "clean" "SKIPPED" "$cache_dir" "protected" + continue + fi + + if is_path_whitelisted "$cache_dir"; then + skipped_count=$((skipped_count + 1)) + whitelist_skipped_count=$((${whitelist_skipped_count:-0} + 1)) + log_operation "clean" "SKIPPED" "$cache_dir" "whitelist" + continue + fi + + local size_kb + size_kb=$(get_path_size_kb "$cache_dir") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + if [[ "$DRY_RUN" == "true" ]]; then + if declare -f register_dry_run_cleanup_target > /dev/null 2>&1; then + register_dry_run_cleanup_target "$cache_dir" || continue + fi + dry_run_paths+=("$cache_dir") + dry_run_sizes+=("$size_kb") + else + if ! safe_remove "$cache_dir" true; then + continue + fi + fi + + total_size_kb=$((total_size_kb + size_kb)) + removed_count=$((removed_count + 1)) + done + + if [[ $removed_count -eq 0 ]]; then + return 0 + fi + + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + + if [[ "$DRY_RUN" == "true" ]]; then + if [[ -n "${EXPORT_LIST_FILE:-}" ]]; then + ensure_user_file "$EXPORT_LIST_FILE" + local i=0 + for ((i = 0; i < ${#dry_run_paths[@]}; i++)); do + local path="${dry_run_paths[i]}" + local path_size_kb="${dry_run_sizes[i]:-0}" + local path_size_human + path_size_human=$(bytes_to_human "$((path_size_kb * 1024))") + echo "${path} # ${path_size_human}" >> "$EXPORT_LIST_FILE" + done + fi + + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC}, ${YELLOW}${removed_count} dirs, ${size_human} dry, ${skipped_count} skipped${NC}" + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC}, ${YELLOW}${removed_count} dirs, ${size_human} dry${NC}" + fi + else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + if [[ $skipped_count -gt 0 ]]; then + echo -e " ${line_color}${ICON_SUCCESS}${NC} Python bytecode cache · ${display_root}${NC}, ${line_color}${removed_count} dirs, ${size_human}${NC}, ${skipped_count} skipped" + else + echo -e " ${line_color}${ICON_SUCCESS}${NC} Python bytecode cache · ${display_root}${NC}, ${line_color}${removed_count} dirs, ${size_human}${NC}" + fi + fi + + files_cleaned=$((${files_cleaned:-0} + removed_count)) + total_size_cleaned=$((${total_size_cleaned:-0} + total_size_kb)) + total_items=$((${total_items:-0} + 1)) + if declare -f note_activity > /dev/null 2>&1; then + note_activity + fi +} + # Next.js/Python/Flutter project caches scoped to discovered project roots. clean_project_caches() { stop_inline_spinner 2> /dev/null || true - local matches_tmp_file - matches_tmp_file=$(create_temp_file) - local -a scan_roots=() local root while IFS= read -r root; do @@ -199,30 +456,24 @@ clean_project_caches() { fi for root in "${scan_roots[@]}"; do - scan_project_cache_root "$root" "$matches_tmp_file" + local root_matches_file + root_matches_file=$(create_temp_file) + scan_project_cache_root "$root" "$root_matches_file" + + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + process_project_cache_matches "$root_matches_file" + rm -f "$root_matches_file" + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " + start_inline_spinner "Searching project caches..." + fi done if [[ -t 1 ]]; then stop_inline_spinner fi - - while IFS= read -r cache_dir; do - case "$(basename "$cache_dir")" in - ".next") - [[ -d "$cache_dir/cache" ]] && safe_clean "$cache_dir/cache"/* "Next.js build cache" || true - ;; - "__pycache__") - [[ -d "$cache_dir" ]] && safe_clean "$cache_dir"/* "Python bytecode cache" || true - ;; - ".dart_tool") - if [[ -d "$cache_dir" ]]; then - safe_clean "$cache_dir" "Flutter build cache (.dart_tool)" || true - local build_dir="$(dirname "$cache_dir")/build" - if [[ -d "$build_dir" ]]; then - safe_clean "$build_dir" "Flutter build cache (build/)" || true - fi - fi - ;; - esac - done < <(LC_ALL=C sort -u "$matches_tmp_file" 2> /dev/null) } diff --git a/Resources/mole/lib/clean/dev.sh b/Resources/mole/lib/clean/dev.sh index fb8eefd..f3d05ab 100644 --- a/Resources/mole/lib/clean/dev.sh +++ b/Resources/mole/lib/clean/dev.sh @@ -2,10 +2,25 @@ # Developer Tools Cleanup Module set -euo pipefail -# Tool cache helper (respects DRY_RUN). +# Tool cache helper (respects DRY_RUN and whitelist). +# Args: +# $1 = description (display name) +# $2 = cache path to check against whitelist (empty string to skip check) +# $3+ = command to run clean_tool_cache() { local description="$1" - shift + local cache_path="$2" + shift 2 + + if [[ -n "$cache_path" ]] && is_path_whitelisted "$cache_path"; then + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $description · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $description · skipped (whitelist)" + fi + return 0 + fi + if [[ "$DRY_RUN" != "true" ]]; then local command_succeeded=false if [[ -t 1 ]]; then @@ -31,8 +46,6 @@ clean_dev_npm() { local npm_cache_path="$npm_default_cache" if command -v npm > /dev/null 2>&1; then - clean_tool_cache "npm cache" npm cache clean --force - start_section_spinner "Checking npm cache path..." npm_cache_path=$(run_with_timeout 2 npm config get cache 2> /dev/null) || npm_cache_path="" stop_section_spinner @@ -41,6 +54,7 @@ clean_dev_npm() { npm_cache_path="$npm_default_cache" fi + clean_tool_cache "npm cache" "$npm_cache_path" npm cache clean --force note_activity fi @@ -75,11 +89,17 @@ clean_dev_npm() { 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 + + local pnpm_cache_check="$pnpm_default_store" + if [[ -n "$pnpm_store_path" && "$pnpm_store_path" == /* ]]; then + pnpm_cache_check="$pnpm_store_path" + fi + COREPACK_ENABLE_DOWNLOAD_PROMPT=0 clean_tool_cache "pnpm cache" "$pnpm_cache_check" pnpm store prune + if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" fi @@ -87,17 +107,83 @@ clean_dev_npm() { # pnpm not installed or not usable, just clean the default store directory safe_clean "$pnpm_default_store"/* "pnpm store" fi + local bun_default_cache="$HOME/.bun/install/cache" + local bun_cache_path="$bun_default_cache" + local bun_cache_cleaned=false + local bun_dry_run="${DRY_RUN:-false}" + if command -v bun > /dev/null 2>&1 && bun --version > /dev/null 2>&1; then + if [[ -t 1 ]]; then start_section_spinner "Checking bun cache path..."; fi + bun_cache_path=$(run_with_timeout 2 bun pm cache 2> /dev/null) || bun_cache_path="" + if [[ -t 1 ]]; then stop_section_spinner; fi + + if [[ -z "$bun_cache_path" || "$bun_cache_path" != /* ]]; then + bun_cache_path="$bun_default_cache" + fi + + local bun_protected=false + is_path_whitelisted "$bun_cache_path" && bun_protected=true + + if [[ "$bun_protected" == "true" ]]; then + if [[ "$bun_dry_run" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} bun cache · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} bun cache · skipped (whitelist)" + fi + bun_cache_cleaned=true + elif [[ "$bun_dry_run" != "true" ]]; then + if [[ -t 1 ]]; then + start_section_spinner "Cleaning bun cache..." + fi + if run_with_timeout 10 bun pm cache rm > /dev/null 2>&1; then + bun_cache_cleaned=true + fi + if [[ -t 1 ]]; then + stop_section_spinner + fi + if [[ "$bun_cache_cleaned" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} bun cache" + fi + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} bun cache · would clean" + bun_cache_cleaned=true + fi + + local bun_cache_path_normalized="${bun_cache_path%/}" + local bun_default_cache_normalized="${bun_default_cache%/}" + if [[ -d "$bun_cache_path_normalized" ]]; then + bun_cache_path_normalized=$(cd "$bun_cache_path_normalized" 2> /dev/null && pwd -P) || bun_cache_path_normalized="${bun_cache_path%/}" + fi + if [[ -d "$bun_default_cache_normalized" ]]; then + bun_default_cache_normalized=$(cd "$bun_default_cache_normalized" 2> /dev/null && pwd -P) || bun_default_cache_normalized="${bun_default_cache%/}" + fi + + if [[ "$bun_cache_path_normalized" != "$bun_default_cache_normalized" ]]; then + safe_clean "$bun_default_cache"/* "Orphaned bun cache" + fi + + # If bun pm cache rm fails, fall back to filesystem cleanup to avoid no-op. + if [[ "$bun_cache_cleaned" != "true" ]]; then + safe_clean "$bun_cache_path"/* "Bun cache" + fi + else + safe_clean "$bun_default_cache"/* "Bun cache" + fi + note_activity safe_clean ~/.tnpm/_cacache/* "tnpm cache directory" safe_clean ~/.tnpm/_logs/* "tnpm logs" safe_clean ~/.yarn/cache/* "Yarn cache" - safe_clean ~/.bun/install/cache/* "Bun cache" } # Python/pip ecosystem caches. clean_dev_python() { # Check pip3 is functional (not just macOS stub that triggers CLT install dialog) if command -v pip3 > /dev/null 2>&1 && pip3 --version > /dev/null 2>&1; then - clean_tool_cache "pip cache" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' + local pip_cache_path + pip_cache_path=$(run_with_timeout 2 pip3 cache dir 2> /dev/null) || pip_cache_path="" + if [[ -z "$pip_cache_path" || "$pip_cache_path" != /* ]]; then + pip_cache_path="$HOME/Library/Caches/pip" + fi + clean_tool_cache "pip cache" "$pip_cache_path" bash -c 'pip3 cache purge > /dev/null 2>&1 || true' note_activity fi safe_clean ~/.pyenv/cache/* "pyenv cache" @@ -136,16 +222,54 @@ clean_dev_go() { fi if [[ "$build_protected" != "true" && "$mod_protected" != "true" ]]; then - 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' elif [[ "$build_protected" == "true" ]]; then - clean_tool_cache "Go module cache" bash -c 'go clean -modcache > /dev/null 2>&1 || true' + clean_tool_cache "Go module cache" "" bash -c 'go clean -modcache > /dev/null 2>&1 || true' echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go build cache · skipped (whitelist)" else - clean_tool_cache "Go build cache" bash -c 'go clean -cache > /dev/null 2>&1 || true' + clean_tool_cache "Go build cache" "" bash -c 'go clean -cache > /dev/null 2>&1 || true' echo -e " ${GREEN}${ICON_SUCCESS}${NC} Go module cache · skipped (whitelist)" fi note_activity } + +get_mise_cache_path() { + if [[ -n "${MISE_CACHE_DIR:-}" && "${MISE_CACHE_DIR}" == /* ]]; then + echo "$MISE_CACHE_DIR" + return 0 + fi + + if command -v mise > /dev/null 2>&1; then + local mise_cache_path + mise_cache_path=$(run_with_timeout 2 mise cache path 2> /dev/null || echo "") + if [[ -n "$mise_cache_path" && "$mise_cache_path" == /* ]]; then + echo "$mise_cache_path" + return 0 + fi + fi + + echo "$HOME/Library/Caches/mise" +} + +clean_dev_mise() { + local mise_cache_path + mise_cache_path=$(get_mise_cache_path) + + if command -v mise > /dev/null 2>&1; then + if [[ "$DRY_RUN" != "true" ]]; then + clean_tool_cache "mise cache" "$mise_cache_path" bash -c 'mise cache clear > /dev/null 2>&1 || true' + note_activity + elif is_path_whitelisted "$mise_cache_path"; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} mise cache · would skip (whitelist)" + note_activity + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} mise cache · would clean" + note_activity + fi + fi + + safe_clean "$mise_cache_path"/* "mise cache" +} # Rust/cargo caches. clean_dev_rust() { safe_clean ~/.cargo/registry/cache/* "Rust cargo cache" @@ -190,22 +314,11 @@ check_rust_toolchains() { # Docker caches (guarded by daemon check). clean_dev_docker() { if command -v docker > /dev/null 2>&1; then - if [[ "$DRY_RUN" != "true" ]]; then - start_section_spinner "Checking Docker daemon..." - local docker_running=false - 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 - else - debug_log "Docker daemon not running, skipping Docker cache cleanup" - fi - else - note_activity - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Docker build cache · would clean" - fi + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} Docker unused data · skipped by default" + echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Review: docker system df${NC}" + echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Prune: docker system prune --filter until=720h${NC}" + debug_log "Docker daemon-managed cleanup skipped by default" fi safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" } @@ -213,7 +326,9 @@ clean_dev_docker() { clean_dev_nix() { if command -v nix-collect-garbage > /dev/null 2>&1; 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/store" nix-collect-garbage --delete-older-than 30d + elif is_path_whitelisted "/nix/store"; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would skip (whitelist)" else echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Nix garbage collection · would clean" fi @@ -399,7 +514,9 @@ clean_xcode_device_support() { done if [[ $removed_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${stale_size_human}" + local line_color + line_color=$(cleanup_result_color_kb "$stale_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} ${display_name} · removed ${removed_count} old versions, ${line_color}${stale_size_human}${NC}" note_activity fi fi @@ -615,10 +732,12 @@ clean_xcode_simulator_runtime_volumes() { if [[ $removed_count -gt 0 ]]; then local removed_human removed_human=$(bytes_to_human "$((removed_size_kb * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$removed_size_kb") if [[ $skipped_protected -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human}), skipped ${skipped_protected} protected" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC}), skipped ${skipped_protected} protected" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${removed_human})" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode runtime volumes · removed ${removed_count} (${line_color}${removed_human}${NC})" fi note_activity else @@ -646,9 +765,19 @@ clean_dev_mobile() { local -a unavailable_udids=() local unavailable_udid="" - # Check if simctl is accessible and working + # Check if simctl is accessible and working; timeout prevents hang when CLT-only. local simctl_available=true - if ! xcrun simctl list devices > /dev/null 2>&1; then + local simctl_probe_ok=false + if declare -F xcrun > /dev/null 2>&1; then + if xcrun simctl list devices > /dev/null 2>&1; then + simctl_probe_ok=true + fi + else + if run_with_timeout 2 xcrun simctl list devices > /dev/null 2>&1; then + simctl_probe_ok=true + fi + fi + if [[ "$simctl_probe_ok" != "true" ]]; then debug_log "simctl not accessible or CoreSimulator service not running" echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl not available" note_activity @@ -704,10 +833,12 @@ clean_dev_mobile() { removed_unavailable=0 fi + local line_color + line_color=$(cleanup_result_color_kb "$unavailable_size_kb") if ((removed_unavailable > 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${unavailable_size_human}" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${line_color}${unavailable_size_human}${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${unavailable_size_human}" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${line_color}${unavailable_size_human}${NC}" fi else stop_section_spinner @@ -753,7 +884,9 @@ clean_dev_mobile() { if ((manually_removed > 0)); then if ((manual_failed == 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${unavailable_size_human}" + local line_color + line_color=$(cleanup_result_color_kb "$unavailable_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${line_color}${unavailable_size_human}${NC}" else echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · partially cleaned ${manually_removed}/${#unavailable_udids[@]}, ${unavailable_size_human}" fi @@ -786,6 +919,7 @@ clean_dev_mobile() { safe_clean ~/.android/cache/* "Android SDK 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 ~/Library/Caches/org.swift.swiftpm/* "Swift package manager library cache" # Expo/React Native caches (preserve state.json which contains auth tokens). safe_clean ~/.expo/expo-go/* "Expo Go cache" safe_clean ~/.expo/android-apk-cache/* "Expo Android APK cache" @@ -914,10 +1048,76 @@ clean_dev_jetbrains_toolbox() { _restore_whitelist } + +# JetBrains IDE logs are safe to rebuild, unlike some cache subtrees that can +# invalidate IDE indexes and trigger expensive reindexing. +clean_dev_jetbrains_logs() { + safe_clean ~/Library/Logs/JetBrains/* "JetBrains IDE logs" +} + +# AI coding agents (Claude Code, Cursor Agent, etc.) auto-update but never +# remove previous versions, so ~/.local/share//versions accumulates +# hundreds of MB per release. Keep the most recently modified N entries +# (newest mtime == currently used) and remove the rest. Entries may be +# binaries (Claude Code) or directories (Cursor Agent), so we enumerate +# both with -type f and -type d. +clean_dev_ai_agents() { + local keep_previous="${MOLE_AI_AGENTS_KEEP:-1}" + [[ "$keep_previous" =~ ^[0-9]+$ ]] || keep_previous=1 + + local -a agent_specs=( + "$HOME/.local/share/claude/versions|Claude Code old version" + "$HOME/.local/share/cursor-agent/versions|Cursor Agent old version" + ) + + local spec + for spec in "${agent_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + + local -a entries=() + while IFS= read -r -d '' entry; do + local name + name=$(basename "$entry") + [[ "$name" == .* ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + entries+=("$entry") + done < <(command find "$versions_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print0 2> /dev/null) + + [[ ${#entries[@]} -le "$keep_previous" ]] && continue + + local -a sorted=() + while IFS= read -r line; do + sorted+=("${line#* }") + done < <( + local entry + for entry in "${entries[@]}"; do + local mtime + mtime=$(stat -f%m "$entry" 2> /dev/null || echo "0") + printf '%s %s\n' "$mtime" "$entry" + done | sort -rn + ) + + local idx=0 + local target + for target in "${sorted[@]}"; do + if [[ $idx -lt $keep_previous ]]; then + idx=$((idx + 1)) + continue + fi + safe_clean "$target" "$label" + note_activity + idx=$((idx + 1)) + done + done +} + # Other language tool caches. clean_dev_other_langs() { safe_clean ~/.bundle/cache/* "Ruby Bundler cache" - safe_clean ~/.composer/cache/* "PHP Composer cache" + safe_clean ~/.composer/cache/* "PHP Composer cache (legacy)" + safe_clean ~/Library/Caches/composer/* "PHP Composer cache" safe_clean ~/.nuget/packages/* "NuGet packages cache" # safe_clean ~/.pub-cache/* "Dart Pub cache" safe_clean ~/.cache/bazel/* "Bazel cache" @@ -979,6 +1179,21 @@ clean_dev_misc() { safe_clean ~/Library/Application\ Support/Claude/GPUCache/* "Claude GPU cache" safe_clean ~/Library/Application\ Support/Claude/DawnGraphiteCache/* "Claude Dawn cache" safe_clean ~/Library/Application\ Support/Claude/DawnWebGPUCache/* "Claude WebGPU cache" + safe_clean ~/Library/Application\ Support/Claude/sentry/* "Claude sentry cache" + safe_clean ~/Library/Application\ Support/Claude/pending-uploads/* "Claude pending uploads" + # Qoder (VS Code fork, Electron) + safe_clean ~/Library/Application\ Support/Qoder/Cache/* "Qoder cache" + safe_clean ~/Library/Application\ Support/Qoder/CachedData/* "Qoder cached data" + safe_clean ~/Library/Application\ Support/Qoder/CachedExtensionVSIXs/* "Qoder extension cache" + safe_clean ~/Library/Application\ Support/Qoder/Code\ Cache/* "Qoder code cache" + safe_clean ~/Library/Application\ Support/Qoder/GPUCache/* "Qoder GPU cache" + safe_clean ~/Library/Application\ Support/Qoder/DawnGraphiteCache/* "Qoder Dawn cache" + safe_clean ~/Library/Application\ Support/Qoder/DawnWebGPUCache/* "Qoder WebGPU cache" + safe_clean ~/Library/Application\ Support/Qoder/logs/* "Qoder logs" + # Prisma ORM engine binaries cache + safe_clean ~/.cache/prisma/* "Prisma cache" + # OpenCode AI tool cache + safe_clean ~/.cache/opencode/* "OpenCode cache" } # Shell and VCS leftovers. clean_dev_shell() { @@ -1024,7 +1239,19 @@ clean_dev_editors() { safe_clean ~/Library/Application\ Support/Code/DawnWebGPUCache/* "VS Code WebGPU cache" safe_clean ~/Library/Application\ Support/Code/GPUCache/* "VS Code GPU cache" safe_clean ~/Library/Application\ Support/Code/CachedExtensionVSIXs/* "VS Code extension cache" + clean_service_worker_cache "VS Code" "$HOME/Library/Application Support/Code/Service Worker/CacheStorage" + safe_clean ~/Library/Application\ Support/Code/Service\ Worker/ScriptCache/* "VS Code Service Worker ScriptCache" safe_clean ~/Library/Caches/Zed/* "Zed cache" + safe_clean ~/Library/Caches/copilot/* "GitHub Copilot cache" + safe_clean ~/.cache/vscode-ripgrep/* "VS Code ripgrep cache" + safe_clean ~/Library/Caches/Cursor/* "Cursor cache" + safe_clean ~/Library/Application\ Support/Cursor/CachedData/* "Cursor cached data" + safe_clean ~/Library/Application\ Support/Cursor/CachedExtensionVSIXs/* "Cursor extension cache" + safe_clean ~/Library/Application\ Support/Cursor/GPUCache/* "Cursor GPU cache" + safe_clean ~/Library/Application\ Support/Cursor/DawnGraphiteCache/* "Cursor Dawn cache" + safe_clean ~/Library/Application\ Support/Cursor/DawnWebGPUCache/* "Cursor WebGPU cache" + clean_service_worker_cache "Cursor" "$HOME/Library/Application Support/Cursor/Service Worker/CacheStorage" + safe_clean ~/Library/Application\ Support/Cursor/Service\ Worker/ScriptCache/* "Cursor Service Worker ScriptCache" } # Main developer tools cleanup sequence. clean_developer_tools() { @@ -1035,6 +1262,7 @@ clean_developer_tools() { clean_dev_npm clean_dev_python clean_dev_go + clean_dev_mise clean_dev_rust check_rust_toolchains clean_dev_docker @@ -1046,6 +1274,8 @@ clean_developer_tools() { clean_dev_mobile clean_dev_jvm clean_dev_jetbrains_toolbox + clean_dev_jetbrains_logs + clean_dev_ai_agents clean_dev_other_langs clean_dev_cicd clean_dev_database diff --git a/Resources/mole/lib/clean/hints.sh b/Resources/mole/lib/clean/hints.sh index f6538bf..ba49116 100644 --- a/Resources/mole/lib/clean/hints.sh +++ b/Resources/mole/lib/clean/hints.sh @@ -54,6 +54,76 @@ hint_get_path_size_kb_with_timeout() { printf '%s\n' "$size_kb" } +# shellcheck disable=SC2329 +hint_extract_launch_agent_program_path() { + local plist="$1" + local program="" + + program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null || echo "") + if [[ -z "$program" ]]; then + program=$(plutil -extract Program raw "$plist" 2> /dev/null || echo "") + fi + + printf '%s\n' "$program" +} + +# shellcheck disable=SC2329 +hint_extract_launch_agent_associated_bundle() { + local plist="$1" + local associated="" + + associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null || echo "") + if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then + associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null || echo "") + if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then + associated="" + fi + fi + + printf '%s\n' "$associated" +} + +# shellcheck disable=SC2329 +hint_is_app_scoped_launch_target() { + local program="$1" + + case "$program" in + /Applications/Setapp/*.app/* | \ + /Applications/*.app/* | \ + "$HOME"/Applications/*.app/* | \ + /Library/Input\ Methods/*.app/* | \ + /Library/PrivilegedHelperTools/*) + return 0 + ;; + esac + + return 1 +} + +# shellcheck disable=SC2329 +hint_is_system_binary() { + local program="$1" + + case "$program" in + /bin/* | /sbin/* | /usr/bin/* | /usr/sbin/* | /usr/libexec/*) + return 0 + ;; + esac + + return 1 +} + +# shellcheck disable=SC2329 +hint_launch_agent_bundle_exists() { + local bundle_id="$1" + + [[ -z "$bundle_id" ]] && return 1 + + # Delegate to the shared resolver so Spotlight misses (e.g. KeePassXC + # installed via Homebrew) fall back to a direct /Applications scan. See #732. + bundle_has_installed_app "$bundle_id" +} + # shellcheck disable=SC2329 record_project_artifact_hint() { local path="$1" @@ -351,3 +421,61 @@ show_project_artifact_hint_notice() { fi echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge" } + +# shellcheck disable=SC2329 +show_user_launch_agent_hint_notice() { + local launch_agents_dir="$HOME/Library/LaunchAgents" + [[ -d "$launch_agents_dir" ]] || return 0 + + local max_hits=3 + local -a labels=() + local -a reasons=() + local -a targets=() + local plist + + while IFS= read -r -d '' plist; do + local filename + filename=$(basename "$plist") + [[ "$filename" == com.apple.* ]] && continue + + local reason="" + local target="" + local program="" + local associated="" + + program=$(hint_extract_launch_agent_program_path "$plist") + if [[ -n "$program" ]] && hint_is_system_binary "$program"; then + continue + fi + if [[ -n "$program" ]] && hint_is_app_scoped_launch_target "$program" && [[ ! -e "$program" ]]; then + reason="Missing app/helper target" + target="${program/#$HOME/~}" + else + associated=$(hint_extract_launch_agent_associated_bundle "$plist") + if [[ -n "$associated" ]] && ! hint_launch_agent_bundle_exists "$associated"; then + reason="Associated app not found" + target="$associated" + fi + fi + + if [[ -n "$reason" ]]; then + labels+=("$filename") + reasons+=("$reason") + targets+=("$target") + if [[ ${#labels[@]} -ge $max_hits ]]; then + break + fi + fi + done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + + [[ ${#labels[@]} -eq 0 ]] && return 0 + + note_activity + + local i + for i in "${!labels[@]}"; do + echo -e " ${GREEN}${ICON_LIST}${NC} Potential stale login item: ${labels[$i]}" + echo -e " ${GRAY}${ICON_SUBLIST}${NC} ${reasons[$i]}: ${GRAY}${targets[$i]}${NC}" + done + echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: open ~/Library/LaunchAgents and remove only items you recognize" +} diff --git a/Resources/mole/lib/clean/project.sh b/Resources/mole/lib/clean/project.sh index c1a9ee7..70d5d38 100644 --- a/Resources/mole/lib/clean/project.sh +++ b/Resources/mole/lib/clean/project.sh @@ -26,6 +26,7 @@ readonly PURGE_CONFIG_FILE="$HOME/.config/mole/purge_paths" # Resolved search paths. PURGE_SEARCH_PATHS=() +PURGE_CATEGORY_FULL_PATHS_ARRAY=() # Project indicators for container detection. # Monorepo indicators (higher priority) @@ -74,7 +75,9 @@ discover_project_dirs() { for path in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do if [[ -d "$path" ]]; then - discovered+=("$path") + # Resolve to canonical casing to avoid duplicates on + # case-insensitive filesystems (macOS APFS). + discovered+=("$(mole_purge_resolve_path_case "$path")") fi done @@ -83,9 +86,11 @@ discover_project_dirs() { for dir in "$HOME"/*/; do [[ ! -d "$dir" ]] && continue dir="${dir%/}" # Remove trailing slash + # Resolve casing so that ~/code and ~/Code compare equal. + dir=$(mole_purge_resolve_path_case "$dir") local already_found=false - for existing in "${DEFAULT_PURGE_SEARCH_PATHS[@]}"; do + for existing in "${discovered[@]+"${discovered[@]}"}"; do if [[ "$dir" == "$existing" ]]; then already_found=true break @@ -98,27 +103,67 @@ discover_project_dirs() { fi done - printf '%s\n' "${discovered[@]}" | sort -u + printf '%s\n' "${discovered[@]+"${discovered[@]}"}" | sort -u } -# Save discovered paths to config. -save_discovered_paths() { +# Prepare purge config directory/file ownership when possible. +prepare_purge_config_path() { + ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")" + ensure_user_file "$PURGE_CONFIG_FILE" +} + +# Write purge config content atomically when possible. +write_purge_config() { + local header="$1" + shift local -a paths=("$@") - ensure_user_dir "$(dirname "$PURGE_CONFIG_FILE")" + prepare_purge_config_path - 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) + local tmp_file + tmp_file=$(mktemp_file "mole-purge-paths") || return 1 + + if ! cat > "$tmp_file" << EOF; then +$header EOF + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi - printf '\n' >> "$PURGE_CONFIG_FILE" - for path in "${paths[@]}"; do - # Convert $HOME to ~ for portability - path="${path/#$HOME/~}" - echo "$path" >> "$PURGE_CONFIG_FILE" - done + # Guard empty-array expansion under `set -u` on bash 3.2 (first-run case + # from `mo purge --paths` passes only the header with no paths). + if [[ ${#paths[@]} -gt 0 ]]; then + for path in "${paths[@]}"; do + # Convert $HOME to ~ for portability + path="${path/#$HOME/~}" + if ! printf '%s\n' "$path" >> "$tmp_file"; then + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi + done + fi + + if ! mv "$tmp_file" "$PURGE_CONFIG_FILE" 2> /dev/null; then + rm -f "$tmp_file" 2> /dev/null || true + return 1 + fi + + return 0 +} + +warn_purge_config_write_failure() { + [[ -t 1 ]] || return 0 + [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]] || return 0 + echo -e "${YELLOW}${ICON_WARNING}${NC} Could not save purge paths to ${PURGE_CONFIG_FILE/#$HOME/~}, using discovered paths for this run" >&2 +} + +# Save discovered paths to config. +save_discovered_paths() { + local -a paths=("$@") + write_purge_config "# 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) +" "${paths[@]}" } # Load purge paths from config or auto-discover @@ -141,10 +186,12 @@ load_purge_config() { 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 + if save_discovered_paths "${discovered[@]}"; then + if [[ -t 1 ]] && [[ -z "${_PURGE_DISCOVERY_SILENT:-}" ]]; then + echo -e "${GRAY}Found ${#discovered[@]} project directories, saved to config${NC}" >&2 + fi + else + warn_purge_config_write_failure fi else PURGE_SEARCH_PATHS=("${DEFAULT_PURGE_SEARCH_PATHS[@]}") @@ -155,6 +202,53 @@ load_purge_config() { # Initialize paths on script load. load_purge_config +format_purge_target_path() { + local path="$1" + echo "${path/#$HOME/~}" +} + +compact_purge_menu_path() { + local path="$1" + local max_width="${2:-0}" + + if ! [[ "$max_width" =~ ^[0-9]+$ ]] || [[ "$max_width" -lt 4 ]]; then + max_width=4 + fi + + local path_width + path_width=$(get_display_width "$path") + if [[ $path_width -le $max_width ]]; then + echo "$path" + return + fi + + local tail="" + local remainder="$path" + local prefix_width=3 + + while [[ "$remainder" == */* ]]; do + local segment="/${remainder##*/}" + remainder="${remainder%/*}" + + local candidate="${segment}${tail}" + local candidate_width + candidate_width=$(get_display_width "$candidate") + if [[ $((candidate_width + prefix_width)) -le $max_width ]]; then + tail="$candidate" + else + break + fi + done + + if [[ -n "$tail" ]]; then + echo "...${tail}" + return + fi + + local suffix_len=$((max_width - 3)) + echo "...${path: -$suffix_len}" +} + # Args: $1 - directory path # Determine whether a directory is a project root. # This is used to safely allow cleaning direct-child artifacts when @@ -199,7 +293,8 @@ is_safe_project_artifact() { # Must not be a direct child of the search root. local relative_path="${path#"$search_path"/}" - local depth=$(echo "$relative_path" | LC_ALL=C tr -cd '/' | wc -c) + local _rel_stripped="${relative_path//\//}" + local depth=$((${#relative_path} - ${#_rel_stripped})) if [[ $depth -lt 1 ]]; then # Allow direct-child artifacts only when the search path is itself # a project root (single-project mode). @@ -343,7 +438,7 @@ scan_purge_targets() { if [[ -n "$item" ]] && is_safe_project_artifact "$item" "$search_path"; then echo "$item" # Update scanning path to show current project directory - local project_dir=$(dirname "$item") + local project_dir="${item%/*}" echo "$project_dir" > "$stats_dir/purge_scanning" 2> /dev/null || true fi done < "$input_file" | filter_nested_artifacts | filter_protected_artifacts > "$output_file" @@ -360,15 +455,11 @@ scan_purge_targets() { debug_log "MO_USE_FIND=1: Forcing find instead of fd" use_find=true elif 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[*]}" - ))" + # Escape regex special characters in target names for fd patterns (single sed pass) + local _escaped_lines + _escaped_lines=$(printf '%s\n' "${PURGE_TARGETS[@]}" | sed -e 's/[][(){}.^$*+?|\\]/\\&/g') + local pattern + pattern="($(printf '%s\n' "$_escaped_lines" | sed -e 's/^/^/' -e 's/$/$/' | paste -sd '|' -))" local fd_args=( "--absolute-path" "--hidden" @@ -389,8 +480,12 @@ scan_purge_targets() { # Check if fd actually found anything - if empty, fallback to find if [[ -s "$output_file.raw" ]]; then debug_log "Using fd for scanning (found results)" - use_find=false process_scan_results "$output_file.raw" + if [[ -s "$output_file" ]]; then + use_find=false + else + debug_log "fd results became empty after filtering, falling back to find" + fi else debug_log "fd returned empty results, falling back to find" rm -f "$output_file.raw" @@ -460,14 +555,16 @@ filter_protected_artifacts() { # Check if a path was modified recently (safety check). is_recently_modified() { local path="$1" + local current_time="${2:-}" 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 - current_time=$(get_epoch_seconds) + if [[ -z "$current_time" || ! "$current_time" =~ ^[0-9]+$ ]]; then + current_time=$(get_epoch_seconds) + fi local age_seconds=$((current_time - mod_time)) local age_in_days=$((age_seconds / 86400)) if [[ $age_in_days -lt $age_days ]]; then @@ -542,7 +639,7 @@ select_purge_categories() { term_height=24 fi fi - local reserved=6 + local reserved=8 local available=$((term_height - reserved)) if [[ $available -lt 3 ]]; then echo 3 @@ -659,6 +756,7 @@ select_purge_categories() { printf "%s\n" "$clear_line" IFS=',' read -r -a recent_flags <<< "${PURGE_RECENT_CATEGORIES:-}" + IFS=',' read -r -a age_labels <<< "${PURGE_AGE_LABELS:-}" # Calculate visible range local end_index=$((top_index + visible_count)) @@ -668,7 +766,8 @@ select_purge_categories() { local checkbox="$ICON_EMPTY" [[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID" local recent_marker="" - [[ ${recent_flags[i]:-false} == "true" ]] && recent_marker=" ${GRAY}| Recent${NC}" + local _age="${age_labels[i]:-}" + [[ -n "$_age" ]] && recent_marker=" ${GRAY}| ${_age}${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" @@ -680,6 +779,17 @@ select_purge_categories() { # Keep one blank line between the list and footer tips. printf "%s\n" "$clear_line" + local current_index=$((top_index + cursor_pos)) + local current_full_path="" + local paths_len="${#PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}" + if [[ "$paths_len" -gt 0 && "$current_index" -lt "$paths_len" ]]; then + current_full_path="${PURGE_CATEGORY_FULL_PATHS_ARRAY[current_index]}" + fi + if [[ -n "$current_full_path" ]]; then + printf "%s${GRAY}Full path:${NC} %s\n" "$clear_line" "$current_full_path" + printf "%s\n" "$clear_line" + fi + # Adaptive footer hints — mirrors menu_paginated.sh pattern local _term_w _term_w=$(tput cols 2> /dev/null || echo 80) @@ -821,6 +931,7 @@ confirm_purge_cleanup() { local item_count="${1:-0}" local total_size_kb="${2:-0}" local unknown_count="${3:-0}" + local -a selected_paths=("${@:4}") [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0 [[ "$total_size_kb" =~ ^[0-9]+$ ]] || total_size_kb=0 @@ -839,6 +950,15 @@ confirm_purge_cleanup() { unknown_hint=", ${unknown_count} ${unknown_text}" fi + if [[ ${#selected_paths[@]} -gt 0 ]]; then + echo "" + echo -e "${GRAY}Selected paths:${NC}" + local selected_path="" + for selected_path in "${selected_paths[@]}"; do + echo " $selected_path" + done + fi + echo -ne "${PURPLE}${ICON_ARROW}${NC} Remove ${item_count} ${item_text}, ${size_display}${unknown_hint} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " drain_pending_input local key="" @@ -917,12 +1037,24 @@ clean_project_artifacts() { sleep 0.2 fi - # Collect all results + # Collect all results and deduplicate (overlapping search roots on + # case-insensitive filesystems can produce identical canonical paths). + # Uses a simple linear search instead of associative arrays for bash 3 compat. 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") + local _already_seen=false + local _existing + for _existing in "${all_found_items[@]+"${all_found_items[@]}"}"; do + if [[ "$_existing" == "$item" ]]; then + _already_seen=true + break + fi + done + if [[ "$_already_seen" == "false" ]]; then + all_found_items+=("$item") + fi fi done < "$scan_output" rm -f "$scan_output" @@ -941,8 +1073,10 @@ clean_project_artifacts() { return 2 # Special code: nothing to clean fi # Mark recently modified items (for default selection state) + local _now_epoch + _now_epoch=$(get_epoch_seconds) for item in "${all_found_items[@]}"; do - if is_recently_modified "$item"; then + if is_recently_modified "$item" "$_now_epoch"; then recently_modified+=("$item") fi # Add all items to safe_to_clean, let user choose @@ -952,11 +1086,38 @@ clean_project_artifacts() { if [[ -t 1 ]]; then start_inline_spinner "Calculating sizes..." fi + + # Pre-compute sizes in parallel with sliding-window throttle. + # Unbounded parallelism (all N at once) causes I/O contention on cold + # filesystem cache, making du timeout and display "unknown" sizes. + local -a _size_tmpfiles=() + local -a _size_pids=() + local _max_size_jobs + _max_size_jobs=$(get_optimal_parallel_jobs io) + + for _sz_item in "${safe_to_clean[@]}"; do + local _stmp + _stmp=$(mktemp) + register_temp_file "$_stmp" + _size_tmpfiles+=("$_stmp") + (get_dir_size_kb "$_sz_item" > "$_stmp" 2> /dev/null) & + _size_pids+=($!) + + if [[ ${#_size_pids[@]} -ge $_max_size_jobs ]]; then + wait "${_size_pids[0]}" 2> /dev/null || true + _size_pids=("${_size_pids[@]:1}") + fi + done + for _spid in "${_size_pids[@]+"${_size_pids[@]}"}"; do + wait "$_spid" 2> /dev/null || true + done + local -a menu_options=() local -a item_paths=() local -a item_sizes=() local -a item_size_unknown_flags=() local -a item_recent_flags=() + local -a item_age_labels=() # Helper to get project name from path # For ~/www/pake/src-tauri/target -> returns "pake" # For ~/work/code/MyProject/node_modules -> returns "MyProject" @@ -964,8 +1125,8 @@ clean_project_artifacts() { get_project_name() { local path="$1" - local current_dir - current_dir=$(dirname "$path") + local current_dir="${path%/*}" + [[ -z "$current_dir" ]] && current_dir="/" local monorepo_root="" local project_root="" @@ -998,20 +1159,23 @@ clean_project_artifacts() { # If we found project but still checking for monorepo above # (only stop if we're beyond reasonable depth) - local depth=$(echo "${current_dir#"$HOME"}" | LC_ALL=C tr -cd '/' | wc -c | tr -d ' ') + local _rel="${current_dir#"$HOME"}" + local _stripped="${_rel//\//}" + local depth=$((${#_rel} - ${#_stripped})) if [[ -n "$project_root" && $depth -lt 2 ]]; then break fi - current_dir=$(dirname "$current_dir") + local _parent="${current_dir%/*}" + current_dir="${_parent:-/}" done # Determine result: monorepo > project > fallback local result="" if [[ -n "$monorepo_root" ]]; then - result=$(basename "$monorepo_root") + result="${monorepo_root##*/}" elif [[ -n "$project_root" ]]; then - result=$(basename "$project_root") + result="${project_root##*/}" else # Fallback: first directory under search root local search_roots=() @@ -1024,14 +1188,16 @@ clean_project_artifacts() { root="${root%/}" if [[ -n "$root" && "$path" == "$root/"* ]]; then local relative_path="${path#"$root"/}" - result=$(echo "$relative_path" | cut -d'/' -f1) + result="${relative_path%%/*}" break fi done # Final fallback: use grandparent directory if [[ -z "$result" ]]; then - result=$(dirname "$(dirname "$path")" | xargs basename) + local _gp="${path%/*}" + _gp="${_gp%/*}" + result="${_gp##*/}" fi fi @@ -1045,8 +1211,8 @@ clean_project_artifacts() { get_project_path() { local path="$1" - local current_dir - current_dir=$(dirname "$path") + local current_dir="${path%/*}" + [[ -z "$current_dir" ]] && current_dir="/" local monorepo_root="" local project_root="" @@ -1078,12 +1244,15 @@ clean_project_artifacts() { fi # If we found project but still checking for monorepo above - local depth=$(echo "${current_dir#"$HOME"}" | LC_ALL=C tr -cd '/' | wc -c | tr -d ' ') + local _rel="${current_dir#"$HOME"}" + local _stripped="${_rel//\//}" + local depth=$((${#_rel} - ${#_stripped})) if [[ -n "$project_root" && $depth -lt 2 ]]; then break fi - current_dir=$(dirname "$current_dir") + local _parent="${current_dir%/*}" + current_dir="${_parent:-/}" done # Determine result: monorepo > project > fallback @@ -1094,7 +1263,7 @@ clean_project_artifacts() { result="$project_root" else # Fallback: use parent directory of artifact - result=$(dirname "$path") + result="${path%/*}" fi # Convert to ~ format for cleaner display @@ -1104,23 +1273,48 @@ clean_project_artifacts() { # Helper to get artifact display name # For duplicate artifact names within same project, include parent directory for context + # Uses pre-computed _cached_basenames and _cached_project_names arrays when available. get_artifact_display_name() { local path="$1" - local artifact_name=$(basename "$path") - local project_name=$(get_project_name "$path") - local parent_name=$(basename "$(dirname "$path")") + local artifact_name="${path##*/}" + local parent_name="${path%/*}" + parent_name="${parent_name##*/}" + + local project_name + if [[ -n "${_cached_project_names[*]+x}" ]]; then + # Fast path: use pre-computed cache + local _idx + project_name="" + for _idx in "${!safe_to_clean[@]}"; do + if [[ "${safe_to_clean[$_idx]}" == "$path" ]]; then + project_name="${_cached_project_names[$_idx]}" + break + fi + done + else + project_name=$(get_project_name "$path") + fi # Check if there are other items with same artifact name AND same project local has_duplicate=false - for other_item in "${safe_to_clean[@]}"; do - if [[ "$other_item" != "$path" && "$(basename "$other_item")" == "$artifact_name" ]]; then - # Same artifact name, check if same project - if [[ "$(get_project_name "$other_item")" == "$project_name" ]]; then + if [[ -n "${_cached_basenames[*]+x}" ]]; then + local _idx + for _idx in "${!safe_to_clean[@]}"; do + if [[ "${safe_to_clean[$_idx]}" != "$path" && "${_cached_basenames[$_idx]}" == "$artifact_name" && "${_cached_project_names[$_idx]}" == "$project_name" ]]; then has_duplicate=true break fi - fi - done + done + else + for other_item in "${safe_to_clean[@]}"; do + if [[ "$other_item" != "$path" && "${other_item##*/}" == "$artifact_name" ]]; then + if [[ "$(get_project_name "$other_item")" == "$project_name" ]]; then + has_duplicate=true + break + fi + fi + done + fi # If duplicate exists in same project and parent is not the project itself, show parent/artifact if [[ "$has_duplicate" == "true" && "$parent_name" != "$project_name" && "$parent_name" != "." && "$parent_name" != "/" ]]; then @@ -1157,31 +1351,56 @@ clean_project_artifacts() { fi [[ $available_width -lt $min_width ]] && available_width=$min_width - [[ $available_width -gt 60 ]] && available_width=60 fi # Truncate project path if needed local truncated_path - truncated_path=$(truncate_by_display_width "$project_path" "$available_width") + truncated_path=$(compact_purge_menu_path "$project_path" "$available_width") local current_width current_width=$(get_display_width "$truncated_path") - local char_count=${#truncated_path} + + # Get byte count for printf width calculation + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local byte_count=${#truncated_path} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding=$((available_width - current_width)) - local printf_width=$((char_count + padding)) + local printf_width=$((byte_count + padding)) # Format: "project_path size | artifact_type" printf "%-*s %9s | %-*s" "$printf_width" "$truncated_path" "$size_str" "$artifact_col" "$artifact_type" } + # Pre-compute basenames and project names once so get_artifact_display_name() + # can avoid repeated filesystem traversals during the O(N^2) duplicate check. + local -a _cached_basenames=() + local -a _cached_project_names=() + local -a _cached_project_paths=() + local _pre_idx + for _pre_idx in "${!safe_to_clean[@]}"; do + _cached_basenames[_pre_idx]="${safe_to_clean[$_pre_idx]##*/}" + _cached_project_names[_pre_idx]=$(get_project_name "${safe_to_clean[$_pre_idx]}") + _cached_project_paths[_pre_idx]=$(get_project_path "${safe_to_clean[$_pre_idx]}") + done + # Build menu options - one line per artifact - # Pass 1: collect data into parallel arrays (needed for pre-scan of widths) + # Pass 1: collect data into parallel arrays (needed for pre-scan of widths). + # Sizes are read from pre-computed results (parallel du calls launched above). local -a raw_project_paths=() local -a raw_artifact_types=() + local -a item_display_paths=() + local _sz_idx=0 for item in "${safe_to_clean[@]}"; do - local project_path - project_path=$(get_project_path "$item") + local project_path="${_cached_project_paths[$_sz_idx]}" local artifact_type artifact_type=$(get_artifact_display_name "$item") local size_raw - size_raw=$(get_dir_size_kb "$item") + size_raw=$(cat "${_size_tmpfiles[_sz_idx]}" 2> /dev/null || echo "0") + rm -f "${_size_tmpfiles[_sz_idx]}" 2> /dev/null || true + _sz_idx=$((_sz_idx + 1)) local size_kb=0 local size_human="" local size_unknown=false @@ -1211,9 +1430,24 @@ clean_project_artifacts() { raw_project_paths+=("$project_path") raw_artifact_types+=("$artifact_type") item_paths+=("$item") + item_display_paths+=("$(format_purge_target_path "$item")") item_sizes+=("$size_kb") item_size_unknown_flags+=("$size_unknown") item_recent_flags+=("$is_recent") + # Build human-readable age label (bash 3.2 compatible — no assoc arrays). + local _mod_time _age_secs _age_d + _mod_time=$(get_file_mtime "$item" 2> /dev/null || echo "0") + _age_secs=$((_now_epoch - _mod_time)) + _age_d=$((_age_secs / 86400)) + if [[ $_age_d -lt 1 ]]; then + item_age_labels+=("<1d") + elif [[ $_age_d -lt 30 ]]; then + item_age_labels+=("${_age_d}d") + elif [[ $_age_d -lt 365 ]]; then + item_age_labels+=("$((_age_d / 30))mo") + else + item_age_labels+=("$((_age_d / 365))y") + fi done # Pre-scan: find max path and artifact display widths (mirrors app_selector.sh approach) @@ -1236,7 +1470,7 @@ clean_project_artifacts() { [[ $max_artifact_width -lt 6 ]] && max_artifact_width=6 [[ $max_artifact_width -gt 17 ]] && max_artifact_width=17 - # Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | Recent"(9) = artifact_col + 26 + # Exact overhead: prefix(4) + space(1) + size(9) + " | "(3) + artifact_col + " | 11mo"(7) = artifact_col + 24 local fixed_overhead=$((max_artifact_width + 26)) local available_for_path=$((terminal_width - fixed_overhead)) @@ -1251,7 +1485,6 @@ clean_project_artifacts() { [[ $max_path_display_width -lt $min_path_width ]] && max_path_display_width=$min_path_width [[ $available_for_path -lt $max_path_display_width ]] && max_path_display_width=$available_for_path - [[ $max_path_display_width -gt 60 ]] && max_path_display_width=60 # Ensure path width is at least 5 on very narrow terminals [[ $max_path_display_width -lt 5 ]] && max_path_display_width=5 @@ -1291,6 +1524,8 @@ clean_project_artifacts() { local -a sorted_item_sizes=() local -a sorted_item_size_unknown_flags=() local -a sorted_item_recent_flags=() + local -a sorted_item_display_paths=() + local -a sorted_item_age_labels=() for idx in "${sorted_indices[@]}"; do sorted_menu_options+=("${menu_options[idx]}") @@ -1298,6 +1533,8 @@ clean_project_artifacts() { sorted_item_sizes+=("${item_sizes[idx]}") sorted_item_size_unknown_flags+=("${item_size_unknown_flags[idx]}") sorted_item_recent_flags+=("${item_recent_flags[idx]}") + sorted_item_display_paths+=("${item_display_paths[idx]}") + sorted_item_age_labels+=("${item_age_labels[idx]}") done # Replace original arrays with sorted versions @@ -1306,6 +1543,8 @@ clean_project_artifacts() { item_sizes=("${sorted_item_sizes[@]}") item_size_unknown_flags=("${sorted_item_size_unknown_flags[@]}") item_recent_flags=("${sorted_item_recent_flags[@]}") + item_display_paths=("${sorted_item_display_paths[@]}") + item_age_labels=("${sorted_item_age_labels[@]}") fi if [[ -t 1 ]]; then stop_inline_spinner @@ -1327,11 +1566,17 @@ clean_project_artifacts() { IFS=, echo "${item_recent_flags[*]-}" ) + export PURGE_AGE_LABELS=$( + IFS=, + echo "${item_age_labels[*]-}" + ) # Interactive selection (only if terminal is available) PURGE_SELECTION_RESULT="" + PURGE_CATEGORY_FULL_PATHS_ARRAY=("${item_display_paths[@]}") if [[ -t 0 ]]; then if ! select_purge_categories "${menu_options[@]}"; then - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + PURGE_CATEGORY_FULL_PATHS_ARRAY=() + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT return 1 fi else @@ -1347,12 +1592,14 @@ clean_project_artifacts() { echo "" echo -e "${GRAY}No items selected${NC}" printf '\n' - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + PURGE_CATEGORY_FULL_PATHS_ARRAY=() + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT return 0 fi IFS=',' read -r -a selected_indices <<< "$PURGE_SELECTION_RESULT" local selected_total_kb=0 local selected_unknown_count=0 + local -a selected_display_paths=() for idx in "${selected_indices[@]}"; do local selected_size_kb="${item_sizes[idx]:-0}" [[ "$selected_size_kb" =~ ^[0-9]+$ ]] || selected_size_kb=0 @@ -1360,16 +1607,19 @@ clean_project_artifacts() { if [[ "${item_size_unknown_flags[idx]:-false}" == "true" ]]; then selected_unknown_count=$((selected_unknown_count + 1)) fi + selected_display_paths+=("${item_display_paths[idx]}") done if [[ -t 0 ]]; then - if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count"; then + if ! confirm_purge_cleanup "${#selected_indices[@]}" "$selected_total_kb" "$selected_unknown_count" "${selected_display_paths[@]}"; then echo -e "${GRAY}Purge cancelled${NC}" printf '\n' - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + PURGE_CATEGORY_FULL_PATHS_ARRAY=() + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT return 1 fi fi + PURGE_CATEGORY_FULL_PATHS_ARRAY=() # Clean selected items echo "" @@ -1378,8 +1628,8 @@ clean_project_artifacts() { local dry_run_mode="${MOLE_DRY_RUN:-0}" for idx in "${selected_indices[@]}"; do local item_path="${item_paths[idx]}" - local artifact_type=$(basename "$item_path") - local project_path=$(get_project_path "$item_path") + local display_item_path + display_item_path=$(format_purge_target_path "$item_path") local size_kb="${item_sizes[idx]}" local size_unknown="${item_size_unknown_flags[idx]:-false}" local size_human @@ -1393,7 +1643,7 @@ clean_project_artifacts() { continue fi if [[ -t 1 ]]; then - start_inline_spinner "Cleaning $project_path/$artifact_type..." + start_inline_spinner "Cleaning $display_item_path..." fi local removal_recorded=false if [[ -e "$item_path" ]]; then @@ -1411,14 +1661,14 @@ clean_project_artifacts() { stop_inline_spinner if [[ "$removal_recorded" == "true" ]]; then if [[ "$dry_run_mode" == "1" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $display_item_path${NC}, ${GREEN}$size_human${NC}" else - echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" + echo -e "${GREEN}${ICON_SUCCESS}${NC} $display_item_path${NC}, ${GREEN}$size_human${NC}" fi fi fi done # Update count echo "$cleaned_count" > "$stats_dir/purge_count" - unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_SELECTION_RESULT + unset PURGE_CATEGORY_SIZES PURGE_RECENT_CATEGORIES PURGE_AGE_LABELS PURGE_SELECTION_RESULT } diff --git a/Resources/mole/lib/clean/purge_shared.sh b/Resources/mole/lib/clean/purge_shared.sh index 91ad19f..2e96294 100644 --- a/Resources/mole/lib/clean/purge_shared.sh +++ b/Resources/mole/lib/clean/purge_shared.sh @@ -42,6 +42,7 @@ readonly MOLE_PURGE_TARGETS=( "Pods" # CocoaPods ".cxx" # React Native Android NDK build cache ".expo" # Expo + ".build" # Swift Package Manager ) readonly MOLE_PURGE_DEFAULT_SEARCH_PATHS=( @@ -73,6 +74,7 @@ readonly MOLE_PURGE_PROJECT_INDICATORS=( "Gemfile" "composer.json" "pubspec.yaml" + "Package.swift" # Swift Package Manager "Makefile" "build.zig" "build.zig.zon" @@ -122,6 +124,19 @@ mole_purge_quick_hint_target_names() { done } +# Resolve a directory path to its canonical filesystem casing. +# On case-insensitive macOS (APFS), ~/Code and ~/code point to the same +# directory but with different display names. This function returns the +# real (on-disk) path so that string comparisons work correctly for dedup. +mole_purge_resolve_path_case() { + local path="$1" + if [[ -d "$path" ]]; then + (cd "$path" 2> /dev/null && pwd -P) || printf '%s\n' "$path" + else + printf '%s\n' "$path" + fi +} + mole_purge_read_paths_config() { local config_file="${1:-$HOME/.config/mole/purge_paths}" [[ -f "$config_file" ]] || return 0 @@ -132,6 +147,7 @@ mole_purge_read_paths_config() { line="${line%"${line##*[![:space:]]}"}" [[ -z "$line" || "$line" =~ ^# ]] && continue line="${line/#\~/$HOME}" + line=$(mole_purge_resolve_path_case "$line") printf '%s\n' "$line" done < "$config_file" } diff --git a/Resources/mole/lib/clean/system.sh b/Resources/mole/lib/clean/system.sh index 817964e..a1183d1 100644 --- a/Resources/mole/lib/clean/system.sh +++ b/Resources/mole/lib/clean/system.sh @@ -116,8 +116,11 @@ clean_deep_system() { fi fi # Clean macOS installer apps (e.g., "Install macOS Sequoia.app") - # Only remove installers older than 14 days and not currently running + # Only remove installers older than 14 days, not currently running, + # and not matching the currently installed macOS version (recovery safety). local installer_cleaned=0 + local current_macos_version="" + current_macos_version=$(sw_vers -productVersion 2> /dev/null | cut -d. -f1 || true) for installer_app in /Applications/Install\ macOS*.app; do [[ -d "$installer_app" ]] || continue local app_name @@ -127,6 +130,19 @@ clean_deep_system() { debug_log "Skipping $app_name: currently running" continue fi + # Skip if this installer matches the current macOS major version. + # Users may need it for recovery or reinstallation. + if [[ -n "$current_macos_version" ]]; then + local installer_plist="$installer_app/Contents/Info.plist" + if [[ -f "$installer_plist" ]]; then + local installer_version="" + installer_version=$(/usr/libexec/PlistBuddy -c "Print :DTPlatformVersion" "$installer_plist" 2> /dev/null | cut -d. -f1 || true) + if [[ -n "$installer_version" && "$installer_version" == *"$current_macos_version"* ]]; then + debug_log "Keeping $app_name: matches current macOS version ($current_macos_version)" + continue + fi + fi + fi # Check age (same 14-day threshold as /macOS Install Data) local mtime mtime=$(get_file_mtime "$installer_app") @@ -155,7 +171,7 @@ clean_deep_system() { if safe_sudo_remove "$cache_dir"; then code_sign_cleaned=$((code_sign_cleaned + 1)) fi - done < <(run_with_timeout 5 command find /private/var/folders -type d -name "*.code_sign_clone" -path "*/X/*" -print0 2> /dev/null || true) + done < <(run_with_timeout 5 command find /private/var/folders -maxdepth 5 -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" @@ -309,7 +325,9 @@ clean_time_machine_failed_backups() { continue fi if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${GREEN}$size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Incomplete backup: $backup_name${NC}, ${line_color}$size_human${NC}" tm_cleaned=$((tm_cleaned + 1)) files_cleaned=$((files_cleaned + 1)) total_size_cleaned=$((total_size_cleaned + size_kb)) @@ -360,7 +378,9 @@ clean_time_machine_failed_backups() { continue fi if tmutil delete "$inprogress_file" 2> /dev/null; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${GREEN}$size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${line_color}$size_human${NC}" tm_cleaned=$((tm_cleaned + 1)) files_cleaned=$((files_cleaned + 1)) total_size_cleaned=$((total_size_cleaned + size_kb)) diff --git a/Resources/mole/lib/clean/user.sh b/Resources/mole/lib/clean/user.sh index 60e2af9..8b0d2b1 100644 --- a/Resources/mole/lib/clean/user.sh +++ b/Resources/mole/lib/clean/user.sh @@ -11,21 +11,35 @@ clean_user_essentials() { if ! is_path_whitelisted "$HOME/.Trash"; then local trash_count local trash_count_status=0 - trash_count=$(run_with_timeout 3 osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null) || trash_count_status=$? + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null | + tr -dc '\0' | wc -c | tr -d ' ' || echo "0") + else + trash_count=$(run_with_timeout 3 osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null) || trash_count_status=$? + fi if [[ $trash_count_status -eq 124 ]]; then debug_log "Finder trash count timed out, using direct .Trash scan" - trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -exec printf '.' ';' 2> /dev/null | - wc -c | awk '{print $1}' || echo "0") + trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null | + tr -dc '\0' | wc -c | tr -d ' ' || echo "0") fi [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" if [[ "$DRY_RUN" == "true" ]]; then [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" elif [[ $trash_count -gt 0 ]]; then - if run_with_timeout 5 osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items" - note_activity + local emptied_via_finder=false + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + debug_log "Skipping Finder AppleScript in test mode" else + if run_with_timeout 5 osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then + emptied_via_finder=true + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items" + note_activity + fi + fi + if [[ "$emptied_via_finder" != "true" ]]; then debug_log "Finder trash empty failed or timed out, falling back to direct deletion" local cleaned_count=0 while IFS= read -r -d '' item; do @@ -71,6 +85,29 @@ _clean_recent_items() { safe_clean ~/Library/Preferences/com.apple.recentitems.plist "Recent items preferences" || true } +# Internal: Clean incomplete browser downloads, skipping files currently open. +_clean_incomplete_downloads() { + local -a patterns=( + "$HOME/Downloads/*.download" + "$HOME/Downloads/*.crdownload" + "$HOME/Downloads/*.part" + ) + local labels=("Safari incomplete downloads" "Chrome incomplete downloads" "Partial incomplete downloads") + local i=0 + for pattern in "${patterns[@]}"; do + local label="${labels[$i]}" + i=$((i + 1)) + for f in $pattern; do + [[ -e "$f" ]] || continue + if lsof -F n -- "$f" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Skipping active download: $(basename "$f")" + continue + fi + safe_clean "$f" "$label" || true + done + done +} + # Internal: Clean old mail downloads. _clean_mail_downloads() { local mail_age_days=${MOLE_MAIL_AGE_DAYS:-} @@ -120,7 +157,7 @@ _clean_mail_downloads() { if [[ $count -gt 0 ]]; then local cleaned_mb cleaned_mb=$(echo "$cleaned_kb" | awk '{printf "%.1f", $1/1024}' || echo "0.0") - echo " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments, about ${cleaned_mb}MB" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $count mail attachments older than ${mail_age_days}d, about ${cleaned_mb}MB" note_activity fi } @@ -156,6 +193,13 @@ clean_chrome_old_versions() { current_version="${current_version##*/}" [[ -n "$current_version" ]] || continue + # Verify the Current symlink target exists. If broken, skip to avoid + # accidentally deleting the active browser version. + if [[ ! -d "$versions_dir/$current_version" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome Current symlink is broken · skipping version cleanup" + continue + fi + local -a old_versions=() local dir name for dir in "$versions_dir"/*; do @@ -196,7 +240,9 @@ clean_chrome_old_versions() { if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Chrome old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) @@ -242,6 +288,13 @@ clean_edge_old_versions() { current_version="${current_version##*/}" [[ -n "$current_version" ]] || continue + # Verify the Current symlink target exists. If broken, skip to avoid + # accidentally deleting the active browser version. + if [[ ! -d "$versions_dir/$current_version" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Edge Current symlink is broken · skipping version cleanup" + continue + fi + local -a old_versions=() local dir name for dir in "$versions_dir"/*; do @@ -282,7 +335,9 @@ clean_edge_old_versions() { if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Edge old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) @@ -344,7 +399,96 @@ clean_edge_updater_old_versions() { if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${GREEN}${cleaned_count} dirs, $size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Edge updater old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}" + fi + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) + note_activity + fi +} + +# Remove old Brave Browser versions while keeping Current. +clean_brave_old_versions() { + local -a app_paths=( + "/Applications/Brave Browser.app" + "$HOME/Applications/Brave Browser.app" + ) + + # Match the exact Brave process name to avoid false positives + if pgrep -x "Brave Browser" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Brave Browser 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/Brave Browser 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 + + if [[ ! -d "$versions_dir/$current_version" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Brave Browser Current symlink is broken · skipping version cleanup" + continue + fi + + 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_count + 1)) + 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} Brave old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Brave old versions${NC}, ${line_color}${cleaned_count} dirs, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) @@ -427,20 +571,72 @@ clean_support_app_data() { safe_find_delete "$idle_assets_dir" "*" "$support_age_days" "f" || true fi - # Clean old aerial wallpaper videos (can be large, safe to remove). - safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/videos/* "Aerial wallpaper videos" + # Clean system-level idle/aerial screensaver videos (macOS re-downloads as needed). + local sys_idle_assets_dir="/Library/Application Support/com.apple.idleassetsd/Customer" + # Skip sudo operations during tests to avoid password prompts + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + if sudo test -d "$sys_idle_assets_dir" 2> /dev/null; then + safe_sudo_find_delete "$sys_idle_assets_dir" "*" "$support_age_days" "f" || true + fi + fi # Do not touch Messages attachments, only preview/sticker caches. - if pgrep -x "Messages" > /dev/null 2>&1; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Messages is running · preview cache cleanup skipped" - else - safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache" - safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache" - safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache" - fi + safe_clean ~/Library/Messages/StickerCache/* "Messages sticker cache" + safe_clean ~/Library/Messages/Caches/Previews/Attachments/* "Messages preview attachment cache" + safe_clean ~/Library/Messages/Caches/Previews/StickerCache/* "Messages preview sticker cache" } # App caches (merged: macOS system caches + Sandboxed apps). +cache_top_level_entry_count_capped() { + local dir="$1" + local cap="${2:-101}" + local count=0 + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + local item + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + count=$((count + 1)) + if ((count >= cap)); then + break + fi + done + + eval "$_nullglob_state" + eval "$_dotglob_state" + + [[ "$count" =~ ^[0-9]+$ ]] || count=0 + printf '%s\n' "$count" +} + +directory_has_entries() { + local dir="$1" + [[ -d "$dir" ]] || return 1 + + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + local item + for item in "$dir"/*; do + if [[ -e "$item" ]]; then + eval "$_nullglob_state" + eval "$_dotglob_state" + return 0 + fi + done + + eval "$_nullglob_state" + eval "$_dotglob_state" + return 1 +} + clean_app_caches() { start_section_spinner "Scanning app caches..." @@ -453,9 +649,7 @@ clean_app_caches() { 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 + _clean_incomplete_downloads safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true safe_clean ~/Library/IdentityCaches/* "Identity caches" || true safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true @@ -469,14 +663,30 @@ clean_app_caches() { # 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.mediaanalysisd/Data/tmp/* "Media analysis temp files" 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" + safe_clean ~/Library/Containers/com.apple.wallpaper.extension.aerials/Data/tmp/* "Wallpaper aerials temp files" + safe_clean ~/Library/Containers/com.apple.geod/Data/tmp/* "Geod temp files" + safe_clean ~/Library/Containers/com.apple.stocks/Data/Library/Caches/* "Stocks cache" + safe_clean ~/Library/Application\ Support/com.apple.wallpaper/aerials/thumbnails/* "Wallpaper aerials thumbnails" + safe_clean ~/Library/Caches/com.apple.helpd/* "macOS Help system cache" + safe_clean ~/Library/Caches/GeoServices/* "Maps geo tile cache" + safe_clean ~/Library/Containers/com.apple.AvatarUI.AvatarPickerMemojiPicker/Data/Library/Caches/* "Memoji picker cache" + safe_clean ~/Library/Containers/com.apple.AMPArtworkAgent/Data/Library/Caches/* "Music album art cache" + safe_clean ~/Library/Containers/com.apple.CoreDevice.CoreDeviceService/Data/Library/Caches/* "CoreDevice service cache" + safe_clean ~/Library/Containers/com.apple.NeptuneOneExtension/Data/Library/Caches/* "Apple Intelligence extension cache" + safe_clean ~/Library/Containers/com.apple.AppleMediaServicesUI.UtilityExtension/Data/tmp/* "Apple Media Services temp files" local containers_dir="$HOME/Library/Containers" [[ ! -d "$containers_dir" ]] && return 0 start_section_spinner "Scanning sandboxed apps..." local total_size=0 + local total_size_partial=false local cleaned_count=0 local found_any=false + local precise_size_limit="${MOLE_CONTAINER_CACHE_PRECISE_SIZE_LIMIT:-64}" + [[ "$precise_size_limit" =~ ^[0-9]+$ ]] || precise_size_limit=64 + local precise_size_used=0 local _ng_state _ng_state=$(shopt -p nullglob || true) @@ -488,12 +698,24 @@ clean_app_caches() { stop_section_spinner if [[ "$found_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} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}dry${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" + fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}cleaned${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${line_color}$size_human${NC}" + fi fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) @@ -509,31 +731,46 @@ process_container_cache() { local container_dir="$1" [[ -d "$container_dir" ]] || return 0 [[ -L "$container_dir" ]] && return 0 - local bundle_id - bundle_id=$(basename "$container_dir") + local bundle_id="${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" | LC_ALL=C tr '[:upper:]' '[:lower:]')"; then + if should_protect_data "$bundle_id"; then return 0 fi local cache_dir="$container_dir/Data/Library/Caches" [[ -d "$cache_dir" ]] || return 0 [[ -L "$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 item_count + item_count=$(cache_top_level_entry_count_capped "$cache_dir" 101) + [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0 + [[ "$item_count" -eq 0 ]] && return 0 + + if [[ "$item_count" -le 100 && "$precise_size_used" -lt "$precise_size_limit" ]]; then local size - size=$(get_path_size_kb "$cache_dir") + size=$(get_path_size_kb "$cache_dir" 2> /dev/null || echo "0") + [[ "$size" =~ ^[0-9]+$ ]] || size=0 total_size=$((total_size + size)) - found_any=true - cleaned_count=$((cleaned_count + 1)) - if [[ "$DRY_RUN" != "true" ]]; then - local item - while IFS= read -r -d '' item; do - [[ -e "$item" ]] || continue - safe_remove "$item" true || true - done < <(command find "$cache_dir" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - fi + precise_size_used=$((precise_size_used + 1)) + else + total_size_partial=true + fi + + found_any=true + cleaned_count=$((cleaned_count + 1)) + if [[ "$DRY_RUN" != "true" ]]; then + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + local item + for item in "$cache_dir"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true || true + done + eval "$_nullglob_state" + eval "$_dotglob_state" fi } @@ -541,23 +778,25 @@ process_container_cache() { clean_group_container_caches() { local group_containers_dir="$HOME/Library/Group Containers" [[ -d "$group_containers_dir" ]] || return 0 - if ! find "$group_containers_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + if ! directory_has_entries "$group_containers_dir"; then return 0 fi start_section_spinner "Scanning Group Containers..." local total_size=0 + local total_size_partial=false local cleaned_count=0 local found_any=false - # Collect all non-Apple container directories first - local -a containers=() local container_dir + local _nullglob_state + _nullglob_state=$(shopt -p nullglob || true) + shopt -s nullglob + for container_dir in "$group_containers_dir"/*; do [[ -d "$container_dir" ]] || continue [[ -L "$container_dir" ]] && continue - local container_id - container_id=$(basename "$container_dir") + local container_id="${container_dir##*/}" # Skip Apple-owned shared containers entirely. case "$container_id" in @@ -565,13 +804,6 @@ clean_group_container_caches() { continue ;; esac - containers+=("$container_dir") - done - - # Process each container's candidate directories - for container_dir in "${containers[@]}"; do - local container_id - container_id=$(basename "$container_dir") local normalized_id="$container_id" [[ "$normalized_id" == group.* ]] && normalized_id="${normalized_id#group.}" @@ -601,42 +833,56 @@ clean_group_container_caches() { continue fi - # Build non-protected candidate items for cleanup. - local -a items_to_clean=() local item - while IFS= read -r -d '' item; do - [[ -e "$item" ]] || continue - [[ -L "$item" ]] && continue - if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then - continue - else - items_to_clean+=("$item") - fi - done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - - [[ ${#items_to_clean[@]} -gt 0 ]] || continue + local quick_count + quick_count=$(cache_top_level_entry_count_capped "$candidate" 101) + [[ "$quick_count" =~ ^[0-9]+$ ]] || quick_count=0 + [[ "$quick_count" -eq 0 ]] && continue local candidate_size_kb=0 local candidate_changed=false - if [[ "$DRY_RUN" == "true" ]]; then - for item in "${items_to_clean[@]}"; do - local item_size - item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 - [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + if [[ "$quick_count" -gt 100 ]]; then + total_size_partial=true + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi candidate_changed=true - candidate_size_kb=$((candidate_size_kb + item_size)) + if [[ "$DRY_RUN" != "true" ]]; then + safe_remove "$item" true 2> /dev/null || true + fi done else - for item in "${items_to_clean[@]}"; do + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi local item_size item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 + if [[ "$DRY_RUN" == "true" ]]; then + candidate_changed=true + candidate_size_kb=$((candidate_size_kb + item_size)) + continue + fi if safe_remove "$item" true 2> /dev/null; then candidate_changed=true candidate_size_kb=$((candidate_size_kb + item_size)) fi done fi + eval "$_nullglob_state" + eval "$_dotglob_state" if [[ "$candidate_changed" == "true" ]]; then total_size=$((total_size + candidate_size_kb)) @@ -645,6 +891,185 @@ clean_group_container_caches() { fi done done + eval "$_nullglob_state" + + stop_section_spinner + + if [[ "$found_any" == "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}dry${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + fi + else + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}cleaned${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${line_color}$size_human${NC}" + fi + fi + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size)) + total_items=$((total_items + 1)) + note_activity + fi +} + +resolve_existing_path() { + local path="$1" + [[ -e "$path" ]] || return 1 + + if command -v realpath > /dev/null 2>&1; then + realpath "$path" 2> /dev/null && return 0 + fi + + local dir base + dir=$(cd -P "$(dirname "$path")" 2> /dev/null && pwd) || return 1 + base=$(basename "$path") + printf '%s/%s\n' "$dir" "$base" +} + +external_volume_root() { + printf '%s\n' "${MOLE_EXTERNAL_VOLUMES_ROOT:-/Volumes}" +} + +validate_external_volume_target() { + local target="$1" + local root + root=$(external_volume_root) + local resolved_root="$root" + if [[ -e "$root" ]]; then + resolved_root=$(resolve_existing_path "$root" 2> /dev/null || printf '%s\n' "$root") + fi + resolved_root="${resolved_root%/}" + + if [[ -z "$target" ]]; then + echo "Missing external volume path" >&2 + return 1 + fi + if [[ "$target" != /* ]]; then + echo "External volume path must be absolute: $target" >&2 + return 1 + fi + if [[ "$target" == "$root" || "$target" == "$resolved_root" ]]; then + echo "Refusing to clean the volumes root directly: $resolved_root" >&2 + return 1 + fi + if [[ -L "$target" ]]; then + echo "Refusing to clean symlinked volume path: $target" >&2 + return 1 + fi + + local resolved + resolved=$(resolve_existing_path "$target") || { + echo "External volume path does not exist: $target" >&2 + return 1 + } + + if [[ "$resolved" != "$resolved_root/"* ]]; then + echo "External volume path must be under $resolved_root: $resolved" >&2 + return 1 + fi + + local relative_path="${resolved#"$resolved_root"/}" + if [[ -z "$relative_path" || "$relative_path" == "$resolved" || "$relative_path" == */* ]]; then + echo "External cleanup only supports mounted paths directly under $resolved_root: $resolved" >&2 + return 1 + fi + + local disk_info="" + disk_info=$(run_with_timeout 2 command diskutil info "$resolved" 2> /dev/null || echo "") + if [[ -n "$disk_info" ]]; then + if echo "$disk_info" | grep -Eq 'Internal:[[:space:]]+Yes'; then + echo "Refusing to clean an internal volume: $resolved" >&2 + return 1 + fi + + local protocol="" + protocol=$(echo "$disk_info" | awk -F: '/Protocol:/ {gsub(/^[[:space:]]+/, "", $2); print $2; exit}') + case "$protocol" in + SMB | NFS | AFP | CIFS | WebDAV) + echo "Refusing to clean network volume protocol $protocol: $resolved" >&2 + return 1 + ;; + esac + fi + + printf '%s\n' "$resolved" +} + +clean_external_volume_target() { + local volume="$1" + [[ -d "$volume" ]] || return 1 + [[ -L "$volume" ]] && return 1 + + local -a top_level_targets=( + "$volume/.TemporaryItems" + "$volume/.Trashes" + "$volume/.Spotlight-V100" + "$volume/.fseventsd" + ) + local cleaned_count=0 + local total_size=0 + local found_any=false + local volume_name="${volume##*/}" + + start_section_spinner "Scanning external volume..." + + local target_path + for target_path in "${top_level_targets[@]}"; do + [[ -e "$target_path" ]] || continue + [[ -L "$target_path" ]] && continue + if should_protect_path "$target_path" 2> /dev/null || is_path_whitelisted "$target_path" 2> /dev/null; then + continue + fi + + local size_kb + size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + if [[ "$DRY_RUN" == "true" ]]; then + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + elif safe_remove "$target_path" true > /dev/null 2>&1; then + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + fi + done + + if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then + clean_ds_store_tree "$volume" "${volume_name} volume, .DS_Store" + fi + + while IFS= read -r -d '' metadata_file; do + [[ -e "$metadata_file" ]] || continue + if should_protect_path "$metadata_file" 2> /dev/null || is_path_whitelisted "$metadata_file" 2> /dev/null; then + continue + fi + + local size_kb + size_kb=$(get_path_size_kb "$metadata_file" 2> /dev/null || echo "0") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + if [[ "$DRY_RUN" == "true" ]]; then + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + elif safe_remove "$metadata_file" true > /dev/null 2>&1; then + found_any=true + cleaned_count=$((cleaned_count + 1)) + total_size=$((total_size + size_kb)) + fi + done < <(command find "$volume" -type f -name "._*" -print0 2> /dev/null || true) stop_section_spinner @@ -652,15 +1077,19 @@ clean_group_container_caches() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} External volume cleanup${NC}, ${YELLOW}${volume_name}, $size_human dry${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}" + local line_color + line_color=$(cleanup_result_color_kb "$total_size") + echo -e " ${line_color}${ICON_SUCCESS}${NC} External volume cleanup${NC}, ${line_color}${volume_name}, $size_human${NC}" fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) total_items=$((total_items + 1)) note_activity fi + + return 0 } # Browser caches (Safari/Chrome/Edge/Firefox). @@ -671,14 +1100,43 @@ clean_browsers() { 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/component_crx_cache/* "Chrome component CRX cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/ShaderCache/* "Chrome shader cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/GrShaderCache/* "Chrome GR shader cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/GraphiteDawnCache/* "Chrome Dawn cache" + local _chrome_profile + for _chrome_profile in "$HOME/Library/Application Support/Google/Chrome"/*/; do + clean_service_worker_cache "Chrome" "$_chrome_profile/Service Worker/CacheStorage" + safe_clean "$_chrome_profile"/Service\ Worker/ScriptCache/* "Chrome Service Worker ScriptCache" + done safe_clean ~/Library/Application\ Support/Google/GoogleUpdater/crx_cache/* "GoogleUpdater CRX cache" safe_clean ~/Library/Application\ Support/Google/GoogleUpdater/*.old "GoogleUpdater old files" safe_clean ~/Library/Caches/Chromium/* "Chromium cache" safe_clean ~/.cache/puppeteer/* "Puppeteer browser cache" safe_clean ~/Library/Caches/com.microsoft.edgemac/* "Edge cache" + # Arc Browser. safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" + safe_clean ~/Library/Application\ Support/Arc/*/GPUCache/* "Arc GPU cache" + safe_clean ~/Library/Application\ Support/Arc/ShaderCache/* "Arc shader cache" + safe_clean ~/Library/Application\ Support/Arc/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/GraphiteDawnCache/* "Arc Dawn cache" + local _arc_profile + for _arc_profile in "$HOME/Library/Application Support/Arc"/*/; do + clean_service_worker_cache "Arc" "$_arc_profile/Service Worker/CacheStorage" + safe_clean "$_arc_profile"/Service\ Worker/ScriptCache/* "Arc Service Worker ScriptCache" + done safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/Application\ Cache/* "Brave app cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GPUCache/* "Brave GPU cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/component_crx_cache/* "Brave component CRX cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/ShaderCache/* "Brave shader cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/GrShaderCache/* "Brave GR shader cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/GraphiteDawnCache/* "Brave Dawn cache" + local _brave_profile + for _brave_profile in "$HOME/Library/Application Support/BraveSoftware/Brave-Browser"/*/; do + clean_service_worker_cache "Brave" "$_brave_profile/Service Worker/CacheStorage" + safe_clean "$_brave_profile"/Service\ Worker/ScriptCache/* "Brave Service Worker ScriptCache" + done # Helium Browser. safe_clean ~/Library/Caches/net.imput.helium/* "Helium cache" safe_clean ~/Library/Application\ Support/net.imput.helium/*/GPUCache/* "Helium GPU cache" @@ -704,7 +1162,17 @@ clean_browsers() { safe_clean ~/Library/Caches/Firefox/* "Firefox cache" fi safe_clean ~/Library/Caches/com.operasoftware.Opera/* "Opera cache" + # Vivaldi Browser. safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GPUCache/* "Vivaldi GPU cache" + safe_clean ~/Library/Application\ Support/Vivaldi/ShaderCache/* "Vivaldi shader cache" + safe_clean ~/Library/Application\ Support/Vivaldi/GrShaderCache/* "Vivaldi GR shader cache" + safe_clean ~/Library/Application\ Support/Vivaldi/GraphiteDawnCache/* "Vivaldi Dawn cache" + local _vivaldi_profile + for _vivaldi_profile in "$HOME/Library/Application Support/Vivaldi"/*/; do + clean_service_worker_cache "Vivaldi" "$_vivaldi_profile/Service Worker/CacheStorage" + safe_clean "$_vivaldi_profile"/Service\ Worker/ScriptCache/* "Vivaldi Service Worker ScriptCache" + done safe_clean ~/Library/Caches/Comet/* "Comet cache" safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/zen/* "Zen cache" @@ -716,6 +1184,7 @@ clean_browsers() { clean_chrome_old_versions clean_edge_old_versions clean_edge_updater_old_versions + clean_brave_old_versions } # Cloud storage caches. @@ -732,7 +1201,13 @@ clean_cloud_storage() { # Office app caches. clean_office_applications() { safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" + safe_clean ~/Library/Containers/com.microsoft.Word/Data/Library/Caches/* "Microsoft Word container cache" + safe_clean ~/Library/Containers/com.microsoft.Word/Data/tmp/* "Microsoft Word temp files" + safe_clean ~/Library/Containers/com.microsoft.Word/Data/Library/Logs/* "Microsoft Word container logs" safe_clean ~/Library/Caches/com.microsoft.Excel "Microsoft Excel cache" + safe_clean ~/Library/Containers/com.microsoft.Excel/Data/Library/Caches/* "Microsoft Excel container cache" + safe_clean ~/Library/Containers/com.microsoft.Excel/Data/tmp/* "Microsoft Excel temp files" + safe_clean ~/Library/Containers/com.microsoft.Excel/Data/Library/Logs/* "Microsoft Excel container logs" safe_clean ~/Library/Caches/com.microsoft.Powerpoint "Microsoft PowerPoint cache" safe_clean ~/Library/Caches/com.microsoft.Outlook/* "Microsoft Outlook cache" safe_clean ~/Library/Caches/com.apple.iWork.* "Apple iWork cache" @@ -791,24 +1266,13 @@ app_support_item_size_bytes() { return 1 fi - local du_tmp - du_tmp=$(mktemp) - local du_status=0 + local du_output # Use stricter timeout for directories - if run_with_timeout "$timeout_seconds" du -skP "$item" > "$du_tmp" 2> /dev/null; then - du_status=0 - else - du_status=$? - fi - - if [[ $du_status -ne 0 ]]; then - rm -f "$du_tmp" + if ! du_output=$(run_with_timeout "$timeout_seconds" du -skP "$item" 2> /dev/null); then return 1 fi - local size_kb - size_kb=$(awk 'NR==1 {print $1; exit}' "$du_tmp") - rm -f "$du_tmp" + local size_kb="${du_output%%[^0-9]*}" [[ "$size_kb" =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "$((size_kb * 1024))" return 0 @@ -853,17 +1317,22 @@ clean_application_support_logs() { last_progress_update=$(get_epoch_seconds) for app_dir in ~/Library/Application\ Support/*; do [[ -d "$app_dir" ]] || continue - local app_name - app_name=$(basename "$app_dir") + local app_name="${app_dir##*/}" app_count=$((app_count + 1)) update_progress_if_needed "$app_count" "$total_apps" last_progress_update 1 || true - local app_name_lower - app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') local is_protected=false - if should_protect_data "$app_name"; then + if is_path_whitelisted "$app_dir" 2> /dev/null; then + is_protected=true + elif should_protect_path "$app_dir" 2> /dev/null; then is_protected=true - elif should_protect_data "$app_name_lower"; then + elif should_protect_data "$app_name"; then is_protected=true + else + local app_name_lower + app_name_lower=$(echo "$app_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + if should_protect_data "$app_name_lower"; then + is_protected=true + fi fi if [[ "$is_protected" == "true" ]]; then continue @@ -874,6 +1343,9 @@ clean_application_support_logs() { 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 if [[ -d "$candidate" ]]; then + if should_protect_path "$candidate" 2> /dev/null || is_path_whitelisted "$candidate" 2> /dev/null; then + continue + fi # Quick count check - skip if too many items to avoid hanging local quick_count quick_count=$(app_support_entry_count_capped "$candidate" 1 101) @@ -901,6 +1373,9 @@ clean_application_support_logs() { local candidate_item_count=0 while IFS= read -r -d '' item; do [[ -e "$item" ]] || continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi item_found=true candidate_item_count=$((candidate_item_count + 1)) if [[ ! -L "$item" && (-f "$item" || -d "$item") ]]; then @@ -1032,10 +1507,12 @@ clean_application_support_logs() { echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" fi else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") if [[ "$total_size_partial" == "true" ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}at least $size_human${NC}" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${line_color}at least $size_human${NC}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${GREEN}$size_human${NC}" + echo -e " ${line_color}${ICON_SUCCESS}${NC} Application Support logs/caches${NC}, ${line_color}$size_human${NC}" fi fi files_cleaned=$((files_cleaned + cleaned_count)) @@ -1044,6 +1521,87 @@ clean_application_support_logs() { note_activity fi } +# Remove cached device firmware (.ipsw) from iTunes, Finder, and Apple Configurator 2. +# These are installers for firmware already applied (or superseded) — macOS will +# re-download them on demand. Typical size: 5-8GB per file. Never touches backups. +clean_cached_device_firmware() { + local -a shallow_dirs=( + "$HOME/Library/iTunes/iPhone Software Updates" + "$HOME/Library/iTunes/iPad Software Updates" + "$HOME/Library/iTunes/iPod Software Updates" + ) + + # Apple Configurator 2 nests firmware under per-team-id group containers. + local -a configurator_dirs=() + local gc + for gc in "$HOME/Library/Group Containers"/*.group.com.apple.configurator; do + [[ -d "$gc" ]] || continue + configurator_dirs+=("$gc") + done + + local cleaned_count=0 + local total_size_kb=0 + local cleaned_any=false + + _process_ipsw_file() { + local ipsw="$1" + [[ -f "$ipsw" ]] || return 0 + if is_path_whitelisted "$ipsw"; then + return 0 + fi + local size_kb + size_kb=$(get_path_size_kb "$ipsw" || echo 0) + size_kb="${size_kb:-0}" + if [[ "$DRY_RUN" == "true" ]]; then + total_size_kb=$((total_size_kb + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + return 0 + fi + + if safe_remove "$ipsw" true > /dev/null 2>&1; then + total_size_kb=$((total_size_kb + size_kb)) + cleaned_count=$((cleaned_count + 1)) + cleaned_any=true + fi + } + + local dir ipsw + for dir in "${shallow_dirs[@]}"; do + [[ -d "$dir" ]] || continue + while IFS= read -r -d '' ipsw; do + _process_ipsw_file "$ipsw" + done < <(command find "$dir" -maxdepth 1 -type f -name "*.ipsw" -print0 2> /dev/null) + done + + if [[ ${#configurator_dirs[@]} -gt 0 ]]; then + for dir in "${configurator_dirs[@]}"; do + [[ -d "$dir" ]] || continue + while IFS= read -r -d '' ipsw; do + _process_ipsw_file "$ipsw" + done < <(command find "$dir" -type f -name "*.ipsw" -print0 2> /dev/null) + done + fi + + unset -f _process_ipsw_file + + if [[ "$cleaned_any" == "true" ]]; then + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Cached device firmware${NC}, ${YELLOW}${cleaned_count} files, $size_human dry${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} Cached device firmware${NC}, ${line_color}${cleaned_count} files, $size_human${NC}" + fi + files_cleaned=$((files_cleaned + cleaned_count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) + total_items=$((total_items + 1)) + note_activity + fi +} + # iOS device backup info. check_ios_device_backups() { local backup_dir="$HOME/Library/Application Support/MobileSync/Backup" diff --git a/Resources/mole/lib/core/app_protection.sh b/Resources/mole/lib/core/app_protection.sh index 144aac4..c64276f 100755 --- a/Resources/mole/lib/core/app_protection.sh +++ b/Resources/mole/lib/core/app_protection.sh @@ -56,6 +56,11 @@ readonly SYSTEM_CRITICAL_BUNDLES_FAST=( "GlobalPreferences" ".GlobalPreferences" "org.pqrs.Karabiner*" + # CUPS printing subsystem ships with macOS; there is no parent .app to + # anchor it, so org.cups.* prefs always look "orphaned" to bundle-ID + # matching. Deleting them wipes the default printer and recent-printer + # list, which users see as lost saved printers. See #731. + "org.cups.*" ) # Detailed list for uninstall protection @@ -290,6 +295,8 @@ readonly DATA_PROTECTED_BUNDLES=( "clash.*" "Clash.*" "clash_*" + "*clash-verge*" + "*Clash-Verge*" "clashverge*" "ClashVerge*" "com.nssurge.surge-mac" @@ -663,6 +670,12 @@ should_protect_data() { com.apple.* | loginwindow | dock | systempreferences | finder | safari) return 0 ;; + # CUPS is an OS-provided subsystem with no user-facing app; without this + # guard `~/Library/Preferences/org.cups.PrintingPrefs.plist` (which holds + # the default printer and recent printers) looks orphaned. See #731. + org.cups.*) + return 0 + ;; backgroundtaskmanagement* | keychain* | security* | bluetooth* | wifi* | network* | tcc) return 0 ;; @@ -694,7 +707,7 @@ should_protect_data() { com.nssurge.* | com.v2ray.* | com.clash.* | ClashX* | Surge* | Shadowrocket* | Quantumult*) return 0 ;; - clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | clashverge* | ClashVerge*) + clash-* | Clash-* | *-clash | *-Clash | clash.* | Clash.* | clash_* | *clash-verge* | *Clash-Verge* | clashverge* | ClashVerge*) return 0 ;; com.docker.* | com.getpostman.* | com.insomnia.*) @@ -800,6 +813,10 @@ should_protect_path() { */Library/Preferences/com.apple.dock.plist | */Library/Preferences/com.apple.finder.plist) return 0 ;; + # Protect Mole's own runtime logs so cleanup cannot delete its active log targets. + */Library/Logs/mole | */Library/Logs/mole/ | */Library/Logs/mole/*) + return 0 + ;; # Bluetooth and WiFi configurations */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) return 0 @@ -808,6 +825,11 @@ should_protect_path() { */Library/Mobile\ Documents* | */Mobile\ Documents*) return 0 ;; + # CoreAudio and audio subsystem caches (issue #553) + # Cleaning these can cause audio output loss on Intel Macs + *com.apple.coreaudio* | *com.apple.audio.* | *coreaudiod*) + return 0 + ;; esac # 6. Match full path against protected patterns @@ -855,8 +877,20 @@ is_path_whitelisted() { local target_path="$1" [[ -z "$target_path" ]] && return 1 - # Normalize path (remove trailing slash) + # Normalize path (remove trailing slash, collapse consecutive slashes). + # Callers sometimes concat a glob expansion that already ends in `/` + # with a sub-path that begins with `/`, producing `.../Default//Service + # Worker/...`. Without collapsing, those never match a whitelist entry + # written with single separators. See #724. + # + # Note: on bash 3.2 (macOS default), `${var//\/\//\/}` leaves a literal + # backslash in the replacement. Indirect variables sidestep that. + local _slash_single="/" + local _slash_double="//" local normalized_target="${target_path%/}" + while [[ "$normalized_target" == *"$_slash_double"* ]]; do + normalized_target="${normalized_target//$_slash_double/$_slash_single}" + done # Empty whitelist means nothing is protected [[ ${#WHITELIST_PATTERNS[@]} -eq 0 ]] && return 1 @@ -864,6 +898,9 @@ is_path_whitelisted() { for pattern in "${WHITELIST_PATTERNS[@]}"; do # Pattern is already expanded/normalized in bin/clean.sh local check_pattern="${pattern%/}" + while [[ "$check_pattern" == *"$_slash_double"* ]]; do + check_pattern="${check_pattern//$_slash_double/$_slash_single}" + done local has_glob="false" case "$check_pattern" in *\** | *\?* | *\[*) @@ -943,6 +980,7 @@ find_app_files() { "$HOME/Library/WebKit/$bundle_id" "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" "$HOME/Library/HTTPStorages/$bundle_id" + "$HOME/Library/HTTPStorages/$bundle_id.binarycookies" "$HOME/Library/Cookies/$bundle_id.binarycookies" "$HOME/Library/LaunchAgents/$bundle_id.plist" "$HOME/Library/Application Scripts/$bundle_id" @@ -967,6 +1005,10 @@ find_app_files() { "$HOME/.local/share/$app_name" "$HOME/.$app_name" "$HOME/.$app_name"rc + "$HOME/Library/SyncedPreferences/$bundle_id.plist" + "$HOME/Library/Address Book Plug-Ins/$app_name.bundle" + "$HOME/Library/Accessibility/$app_name.bundle" + "$HOME/Library/Mail/Bundles/$app_name.mailbundle" ) # Add all naming variants to cover inconsistent app directory naming @@ -1037,16 +1079,53 @@ find_app_files() { # Handle Preferences and ByHost variants (only if bundle_id is valid) 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/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id") [[ -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) + # NSURLSession download caches + local nsurlsession_dl="$HOME/Library/Caches/com.apple.nsurlsessiond/Downloads/$bundle_id" + [[ -d "$nsurlsession_dl" ]] && files_to_clean+=("$nsurlsession_dl") + # Group Containers (special handling) 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 + + # App extensions often use bundle-id-derived directories rather than the + # main bundle id exactly, for example share extensions or file providers. + local -a derived_bundle_roots=( + "$HOME/Library/Application Scripts" + "$HOME/Library/Containers" + "$HOME/Library/Application Support/FileProvider" + ) + local derived_root="" + local derived_path="" + local existing_path="" + local already_added=false + for derived_root in "${derived_bundle_roots[@]}"; do + [[ -d "$derived_root" ]] || continue + while IFS= read -r -d '' derived_path; do + already_added=false + for existing_path in "${files_to_clean[@]}"; do + if [[ "$existing_path" == "$derived_path" ]]; then + already_added=true + break + fi + done + [[ "$already_added" == "true" ]] || files_to_clean+=("$derived_path") + done < <(command find "$derived_root" -maxdepth 1 -type d -name "*$bundle_id*" -print0 2> /dev/null) + done + fi + + # Shared file lists (.sfl4 - recent documents etc.) + if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] && [[ -d "$HOME/Library/Application Support/com.apple.sharedfilelist" ]]; then + while IFS= read -r -d '' sfl4_file; do + files_to_clean+=("$sfl4_file") + done < <(command find "$HOME/Library/Application Support/com.apple.sharedfilelist" -maxdepth 2 -name "${bundle_id}.sfl4" -print0 2> /dev/null) fi # Launch Agents by name (special handling) @@ -1187,7 +1266,7 @@ get_diagnostic_report_paths_for_app() { *) continue ;; esac case "$base" in - *.ips | *.crash | *.spin) ;; + *.ips | *.crash | *.spin | *.diag) ;; *) continue ;; esac printf '%s\n' "$f" @@ -1233,6 +1312,10 @@ find_app_system_files() { "/Library/Screen Savers/$app_name.saver" "/Library/Caches/$bundle_id" "/Library/Caches/$app_name" + "/Library/Extensions/$app_name.kext" + "/Library/StartupItems/$app_name" + "/Library/Logs/$app_name" + "/Library/Logs/$bundle_id" ) # Add all naming variants for apps with spaces in name @@ -1264,6 +1347,21 @@ find_app_system_files() { system_files+=("$p") done + # System LaunchAgents/LaunchDaemons often use bundle-id-derived helper + # labels (for example ".ProxyConfigHelper.plist"), so scan for + # validated reverse-DNS bundle-id prefixes before falling back to app name. + # The two -name patterns are anchored at the dot boundary so that, e.g., + # bundle "com.foo" matches "com.foo.plist" and "com.foo.helper.plist" but + # NOT "com.foobar.plist" from an unrelated vendor. + if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && + "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then + for base in /Library/LaunchAgents /Library/LaunchDaemons; do + [[ -d "$base" ]] && while IFS= read -r -d '' plist; do + system_files+=("$plist") + done < <(command find "$base" -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) + done + fi + # System LaunchAgents/LaunchDaemons by name if [[ ${#app_name} -gt 3 ]]; then for base in /Library/LaunchAgents /Library/LaunchDaemons; do diff --git a/Resources/mole/lib/core/base.sh b/Resources/mole/lib/core/base.sh index 5479fa3..53519e4 100644 --- a/Resources/mole/lib/core/base.sh +++ b/Resources/mole/lib/core/base.sh @@ -78,6 +78,8 @@ readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increa 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 +readonly MOLE_ONE_GIB_KB=$((1024 * 1024)) +readonly MOLE_ONE_GB_BYTES=1000000000 # ============================================================================ # Whitelist Configuration @@ -96,6 +98,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( "$HOME/Library/Caches/pypoetry/virtualenvs*" "$HOME/Library/Caches/JetBrains*" "$HOME/Library/Caches/com.jetbrains.toolbox*" + "$HOME/Library/Caches/tealdeer/tldr-pages" "$HOME/Library/Application Support/JetBrains*" "$HOME/Library/Caches/com.apple.finder" "$HOME/Library/Mobile Documents*" @@ -547,6 +550,11 @@ bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))" } +# Pick a cleanup result color using the displayed decimal 1 GB threshold. +cleanup_result_color_kb() { + printf '%s' "$GREEN" +} + # ============================================================================ # Temporary File Management # ============================================================================ @@ -555,10 +563,93 @@ bytes_to_human_kb() { declare -a MOLE_TEMP_FILES=() declare -a MOLE_TEMP_DIRS=() +normalize_temp_root() { + local path="${1:-}" + [[ -z "$path" ]] && return 1 + + if [[ "$path" == "~"* ]]; then + path="${path/#\~/$HOME}" + fi + + while [[ "$path" != "/" && "$path" == */ ]]; do + path="${path%/}" + done + + [[ -n "$path" ]] || return 1 + printf '%s\n' "$path" +} + +probe_temp_root() { + local raw_path="$1" + local allow_create="${2:-false}" + local path + local probe="" + + path=$(normalize_temp_root "$raw_path") || return 1 + + if [[ "$allow_create" == "true" ]]; then + ensure_user_dir "$path" + fi + + [[ -d "$path" ]] || return 1 + + probe=$(mktemp "$path/mole.probe.XXXXXX" 2> /dev/null) || return 1 + rm -f "$probe" 2> /dev/null || true + + printf '%s\n' "$path" +} + +ensure_mole_temp_root() { + if [[ -n "${MOLE_RESOLVED_TMPDIR:-}" ]]; then + return 0 + fi + + local resolved="" + local candidate="${TMPDIR:-}" + local invoking_home="" + + if [[ -n "$candidate" ]]; then + resolved=$(probe_temp_root "$candidate" false || true) + fi + + if [[ -z "$resolved" ]]; then + invoking_home=$(get_invoking_home) + if [[ -n "$invoking_home" ]]; then + resolved=$(probe_temp_root "$invoking_home/.cache/mole/tmp" true || true) + fi + fi + + if [[ -z "$resolved" ]]; then + resolved=$(probe_temp_root "/tmp" false || true) + fi + + [[ -n "$resolved" ]] || resolved="/tmp" + MOLE_RESOLVED_TMPDIR="$resolved" + export MOLE_RESOLVED_TMPDIR +} + +get_mole_temp_root() { + ensure_mole_temp_root + printf '%s\n' "$MOLE_RESOLVED_TMPDIR" +} + +prepare_mole_tmpdir() { + ensure_mole_temp_root + export TMPDIR="$MOLE_RESOLVED_TMPDIR" + printf '%s\n' "$MOLE_RESOLVED_TMPDIR" +} + +mole_temp_path_template() { + local prefix="${1:-mole}" + ensure_mole_temp_root + printf '%s/%s.XXXXXX\n' "$MOLE_RESOLVED_TMPDIR" "$prefix" +} + # Create tracked temporary file create_temp_file() { local temp - temp=$(mktemp) || return 1 + ensure_mole_temp_root + temp=$(mktemp "$MOLE_RESOLVED_TMPDIR/mole.XXXXXX") || return 1 register_temp_file "$temp" echo "$temp" } @@ -566,7 +657,8 @@ create_temp_file() { # Create tracked temporary directory create_temp_dir() { local temp - temp=$(mktemp -d) || return 1 + ensure_mole_temp_root + temp=$(mktemp -d "$MOLE_RESOLVED_TMPDIR/mole.XXXXXX") || return 1 register_temp_dir "$temp" echo "$temp" } @@ -587,9 +679,8 @@ mktemp_file() { local prefix="${1:-mole}" local temp local error_msg - # Use TMPDIR if set, otherwise /tmp # Add .XXXXXX suffix to work with both BSD and GNU mktemp - if ! error_msg=$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" 2>&1); then + if ! error_msg=$(mktemp "$(mole_temp_path_template "$prefix")" 2>&1); then echo "Error: Failed to create temporary file: $error_msg" >&2 return 1 fi @@ -600,7 +691,9 @@ mktemp_file() { # Cleanup all tracked temp files and directories cleanup_temp_files() { - stop_inline_spinner || true + if declare -F stop_inline_spinner > /dev/null 2>&1; then + stop_inline_spinner || true + fi local file if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then for file in "${MOLE_TEMP_FILES[@]}"; do diff --git a/Resources/mole/lib/core/bundle_resolver.sh b/Resources/mole/lib/core/bundle_resolver.sh new file mode 100644 index 0000000..b4bad7e --- /dev/null +++ b/Resources/mole/lib/core/bundle_resolver.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Mole - Bundle ID resolution. +# Resolves whether a bundle ID belongs to an installed application on this system. +# Spotlight (mdfind) is unreliable: indexing can be off for /Applications, Homebrew +# installs sometimes skip metadata importers, and Spotlight rarely indexes helpers +# embedded inside .app bundles. This resolver falls back to a direct filesystem +# scan that reads each app's Info.plist and checks SMJobBless-registered helpers. + +if [[ -n "${_MOLE_BUNDLE_RESOLVER_LOADED:-}" ]]; then + return 0 +fi +readonly _MOLE_BUNDLE_RESOLVER_LOADED=1 + +# Standard locations for installed apps on macOS. Overridable from tests. +_MOLE_BUNDLE_RESOLVER_APP_ROOTS=( + "/Applications" + "/Applications/Setapp" + "/Applications/Utilities" + "$HOME/Applications" +) + +# Return 0 if some installed app either has the given CFBundleIdentifier, or +# registers a privileged helper with that ID via SMJobBless +# (Contents/Library/LaunchServices/). Return 1 otherwise. +# +# Intended for orphan/stale detection: answering "is this launchagent or +# privileged helper associated with an app that still exists on disk?" +bundle_has_installed_app() { + local bundle_id="$1" + [[ -z "$bundle_id" ]] && return 1 + + # Reject obviously malformed IDs to avoid feeding junk into mdfind/find. + [[ "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1 + + # Fast path: Spotlight. Gated with a timeout because mdfind has been known + # to wedge on misconfigured indexes. + if command -v mdfind > /dev/null 2>&1; then + local hit + if declare -f run_with_timeout > /dev/null 2>&1; then + hit=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1) + else + hit=$(mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1) + fi + [[ -n "$hit" ]] && return 0 + fi + + # Slow path: walk known app roots. Reads each Info.plist CFBundleIdentifier + # and checks for an SMJobBless helper registered under this bundle ID. This + # covers the two classes of false positive we saw: + # - App-owned launch agents whose bundle ID Spotlight failed to index + # (e.g. org.keepassxc.KeePassXC from Homebrew) -- issue #732 + # - Privileged helpers embedded in a parent .app under + # Contents/Library/LaunchServices/ (e.g. the Adobe + # ARMDC helpers shipped inside Adobe Acrobat DC.app) -- issue #733 + local parent_id="" + local suffix + for suffix in ".helper" ".daemon" ".agent" ".xpc"; do + if [[ "$bundle_id" == *"$suffix" ]]; then + parent_id="${bundle_id%"$suffix"}" + break + fi + done + + local app_root app info app_bundle + for app_root in "${_MOLE_BUNDLE_RESOLVER_APP_ROOTS[@]}"; do + [[ -d "$app_root" ]] || continue + while IFS= read -r -d '' app; do + if [[ -e "$app/Contents/Library/LaunchServices/$bundle_id" ]]; then + return 0 + fi + info="$app/Contents/Info.plist" + [[ -f "$info" ]] || continue + app_bundle=$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || echo "") + [[ "$app_bundle" == "$bundle_id" ]] && return 0 + [[ -n "$parent_id" && "$app_bundle" == "$parent_id" ]] && return 0 + done < <(find "$app_root" -maxdepth 1 -name "*.app" -print0 2> /dev/null) + done + + return 1 +} diff --git a/Resources/mole/lib/core/common.sh b/Resources/mole/lib/core/common.sh index 38f7640..4afac04 100755 --- a/Resources/mole/lib/core/common.sh +++ b/Resources/mole/lib/core/common.sh @@ -14,6 +14,7 @@ _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load core modules source "$_MOLE_CORE_DIR/base.sh" +prepare_mole_tmpdir > /dev/null source "$_MOLE_CORE_DIR/log.sh" source "$_MOLE_CORE_DIR/timeout.sh" @@ -21,12 +22,52 @@ source "$_MOLE_CORE_DIR/file_ops.sh" source "$_MOLE_CORE_DIR/help.sh" source "$_MOLE_CORE_DIR/ui.sh" source "$_MOLE_CORE_DIR/app_protection.sh" +source "$_MOLE_CORE_DIR/bundle_resolver.sh" # Load sudo management if available if [[ -f "$_MOLE_CORE_DIR/sudo.sh" ]]; then source "$_MOLE_CORE_DIR/sudo.sh" fi +# Normalize a path for comparisons while preserving root. +mole_normalize_path() { + local path="$1" + local normalized="${path%/}" + [[ -n "$normalized" ]] && printf '%s\n' "$normalized" || printf '%s\n' "$path" +} + +# Return a stable identity for an existing path. Prefer dev+inode so aliased +# paths on case-insensitive filesystems or symlinks collapse to one identity. +mole_path_identity() { + local path="$1" + local normalized + normalized=$(mole_normalize_path "$path") + + if [[ -e "$normalized" || -L "$normalized" ]]; then + if command -v stat > /dev/null 2>&1; then + local fs_id="" + fs_id=$(stat -L -f '%d:%i' "$normalized" 2> /dev/null || stat -f '%d:%i' "$normalized" 2> /dev/null || true) + if [[ "$fs_id" =~ ^[0-9]+:[0-9]+$ ]]; then + printf 'inode:%s\n' "$fs_id" + return 0 + fi + fi + fi + + printf 'path:%s\n' "$normalized" +} + +mole_identity_in_list() { + local needle="$1" + shift + + local existing + for existing in "$@"; do + [[ "$existing" == "$needle" ]] && return 0 + done + return 1 +} + # Update via Homebrew update_via_homebrew() { local current_version="$1" diff --git a/Resources/mole/lib/core/file_ops.sh b/Resources/mole/lib/core/file_ops.sh index 5c41618..4a5863f 100644 --- a/Resources/mole/lib/core/file_ops.sh +++ b/Resources/mole/lib/core/file_ops.sh @@ -92,7 +92,10 @@ validate_path_for_deletion() { # Validate resolved target against protected paths if [[ -n "$resolved_target" ]]; then case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) + / | /System | /System/* | /bin | /bin/* | /sbin | /sbin/* | \ + /usr | /usr/bin | /usr/bin/* | /usr/lib | /usr/lib/* | \ + /etc | /etc/* | /private/etc | /private/etc/* | \ + /Library/Extensions | /Library/Extensions/*) log_error "Symlink points to protected system path: $path -> $resolved_target" return 1 ;; @@ -399,6 +402,193 @@ safe_sudo_remove() { esac } +# ============================================================================ +# Unified deletion helper (Trash + permanent routing with forensic log) +# ============================================================================ + +# Route a deletion through either macOS Trash or permanent rm, while logging +# every call for forensic review. Designed for destructive paths where undo +# matters (e.g. uninstall). Not used by cache-clean paths. +# +# Usage: mole_delete [needs_sudo=false] +# +# Environment: +# MOLE_DELETE_MODE "permanent" (default) or "trash" +# MOLE_DRY_RUN=1 Log intent, do not delete +# MOLE_TEST_TRASH_DIR Test-only override; Trash moves go here via `mv` +# instead of Finder/trash CLI. Required for bats. +# MOLE_DELETE_LOG Override the log file path (default: +# ~/Library/Logs/mole/deletions.log) +# +# Returns 0 on success, 1 on failure. Always appends a tab-separated line to +# the deletions log: \t\t\t\t. +# size_kb is "unknown" when du could not measure the path (permission denied, +# disappeared mid-call); never silently coerced to 0KB so post-hoc forensics +# can tell measured-zero from measurement-failure. +mole_delete() { + local path="$1" + local needs_sudo="${2:-false}" + local mode="${MOLE_DELETE_MODE:-permanent}" + + [[ -z "$path" ]] && return 1 + + # Nothing to do if path does not exist (but a broken symlink still counts). + if [[ ! -e "$path" && ! -L "$path" ]]; then + return 0 + fi + + # Validation is delegated to the underlying safe_* helpers (which call + # validate_path_for_deletion). Trash routing only applies to paths the + # user could legitimately restore from, so we short-circuit invalid paths + # up front to avoid a no-op Trash move followed by a validation failure. + # The rejection itself is recorded in the forensic log so audit trails + # can distinguish refused-by-policy from never-attempted. + if [[ ! -L "$path" ]] && ! validate_path_for_deletion "$path"; then + _mole_delete_log "$mode" "0" "rejected" "$path" + return 1 + fi + + # Capture size before the delete so the log line is still useful when the + # path is gone afterwards. Use "unknown" (not 0) on failure so the log + # never lies about a multi-GB delete by recording it as 0KB. + local size_kb="unknown" + if [[ -e "$path" ]]; then + local raw_size="" + local du_rc=0 + if [[ "$needs_sudo" == "true" ]]; then + raw_size=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1; exit}') + du_rc=${PIPESTATUS[0]} + else + raw_size=$(get_path_size_kb "$path" 2> /dev/null) || du_rc=$? + fi + if [[ "$du_rc" -eq 0 && "$raw_size" =~ ^[0-9]+$ ]]; then + size_kb="$raw_size" + fi + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would delete ($mode): $path" + _mole_delete_log "$mode" "$size_kb" "dry-run" "$path" + return 0 + fi + + # Trash mode: attempt Trash move first, fall through to permanent removal + # on failure so destructive operations never get silently skipped. + if [[ "$mode" == "trash" ]]; then + if _mole_move_to_trash "$path" "$needs_sudo"; then + _mole_delete_log "trash" "$size_kb" "ok" "$path" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "TRASHED" "$path" "${size_kb}KB" + return 0 + fi + # User explicitly chose Trash for recoverability. Surface the fallback + # to permanent rm once per session so they know an "undo" isn't there. + if [[ -z "${_MOLE_TRASH_FALLBACK_WARNED:-}" ]]; then + _MOLE_TRASH_FALLBACK_WARNED=1 + export _MOLE_TRASH_FALLBACK_WARNED + printf 'Warning: Trash unavailable, removing permanently. Subsequent files this session also bypass Trash.\n' >&2 + fi + debug_log "Trash move failed, falling back to permanent delete: $path" + fi + + # Permanent path. Delegate to the existing safe_* helpers so path + # validation, sudo handling, and existing log_operation calls remain + # unchanged for callers that have always gone through rm -rf. + local rc=0 + if [[ -L "$path" ]]; then + safe_remove_symlink "$path" "$needs_sudo" || rc=$? + elif [[ "$needs_sudo" == "true" ]]; then + safe_sudo_remove "$path" || rc=$? + else + safe_remove "$path" "true" || rc=$? + fi + + local status_label="ok" + [[ $rc -ne 0 ]] && status_label="error" + # Mark the trash-mode fallback so forensics can tell why rm was used. + if [[ "$mode" == "trash" && "$status_label" == "ok" ]]; then + status_label="trash-fallback-rm" + fi + _mole_delete_log "$mode" "$size_kb" "$status_label" "$path" + return "$rc" +} + +# Move a path to the macOS Trash. Test harnesses set MOLE_TEST_TRASH_DIR to +# redirect the move to a tmpdir, avoiding any Finder/osascript interaction. +_mole_move_to_trash() { + local path="$1" + local needs_sudo="${2:-false}" + + if [[ -n "${MOLE_TEST_TRASH_DIR:-}" ]]; then + mkdir -p "$MOLE_TEST_TRASH_DIR" 2> /dev/null || return 1 + local dest="$MOLE_TEST_TRASH_DIR/$(basename "$path").$$.$(date +%s 2> /dev/null || echo 0)" + mv "$path" "$dest" 2> /dev/null + return $? + fi + + # Blocked in test mode so uninstall tests never hit Finder/AppleScript. + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + # Prefer the `trash` CLI (Homebrew formula) when available — it's faster + # and does not need Finder running. Fall back to AppleScript, which + # ships with macOS but prompts for auth on root-owned targets. + if command -v trash > /dev/null 2>&1; then + if [[ "$needs_sudo" == "true" ]]; then + sudo trash "$path" > /dev/null 2>&1 && return 0 + else + trash "$path" > /dev/null 2>&1 && return 0 + fi + fi + + # AppleScript fallback. Pass the path via argv so special chars (quotes, + # backslashes) cannot break out of the quoted string. + osascript - "$path" > /dev/null 2>&1 << 'APPLESCRIPT' +on run argv + set p to POSIX file (item 1 of argv) + tell application "Finder" + delete p + end tell +end run +APPLESCRIPT +} + +_mole_delete_log() { + local mode="$1" + local size_kb="$2" + local status="$3" + local target="$4" + + local log_file="${MOLE_DELETE_LOG:-$HOME/Library/Logs/mole/deletions.log}" + local log_dir + log_dir=$(dirname "$log_file") + + # Surface log-write failures once per session. The deletions log is the + # only audit trail for Trash-routed removals; silently no-oping when the + # log dir is unwritable (root-owned from prior sudo, ENOSPC, read-only + # volume) defeats the design. + if ! mkdir -p "$log_dir" 2> /dev/null; then + _mole_warn_log_broken "create directory: $log_dir" + return 0 + fi + + local ts + ts=$(date '+%Y-%m-%dT%H:%M:%S%z' 2> /dev/null || echo "unknown") + + if ! printf '%s\t%s\t%s\t%s\t%s\n' \ + "$ts" "$mode" "$size_kb" "$status" "$target" \ + >> "$log_file" 2> /dev/null; then + _mole_warn_log_broken "write to: $log_file" + fi +} + +_mole_warn_log_broken() { + [[ -n "${_MOLE_DELETE_LOG_WARNED:-}" ]] && return 0 + _MOLE_DELETE_LOG_WARNED=1 + export _MOLE_DELETE_LOG_WARNED + printf 'Warning: deletions audit log unavailable (%s). Forensic trail incomplete this session.\n' "$1" >&2 +} + # ============================================================================ # Safe Find and Delete Operations # ============================================================================ @@ -434,11 +624,17 @@ safe_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path + # Iterate results to respect both system protection and user whitelist. + # Per-caller whitelist gates were missed in past releases (see #710, #724, + # #738, #744, #757); enforcing here makes the protection structural so + # new clean_* functions get whitelist enforcement for free. while IFS= read -r -d '' match; do if should_protect_path "$match"; then continue fi + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$match"; then + continue + fi safe_remove "$match" true || true done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) @@ -481,11 +677,15 @@ safe_sudo_find_delete() { find_args+=("-mtime" "+$age_days") fi - # Iterate results to respect should_protect_path + # Iterate results to respect both system protection and user whitelist. + # See safe_find_delete for rationale (#757). while IFS= read -r -d '' match; do if should_protect_path "$match"; then continue fi + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$match"; then + continue + fi safe_sudo_remove "$match" || true done < <(sudo find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) diff --git a/Resources/mole/lib/core/help.sh b/Resources/mole/lib/core/help.sh index 6deb945..3c29277 100644 --- a/Resources/mole/lib/core/help.sh +++ b/Resources/mole/lib/core/help.sh @@ -3,10 +3,11 @@ show_clean_help() { echo "Usage: mo clean [OPTIONS]" echo "" - echo "Clean up disk space by removing caches, logs, and temporary files." + echo "Clean up disk space by removing caches, logs, temporary files, and app leftovers from already-uninstalled apps." echo "" echo "Options:" echo " --dry-run, -n Preview cleanup without making changes" + echo " --external PATH Clean OS metadata from a mounted external volume" echo " --whitelist Manage protected paths" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" @@ -53,13 +54,27 @@ show_touchid_help() { } show_uninstall_help() { - echo "Usage: mo uninstall [OPTIONS]" + echo "Usage: mo uninstall [OPTIONS] [APP_NAME ...]" echo "" echo "Interactively remove applications and their leftover files." + echo "Optionally specify one or more app names to uninstall directly." + echo "For leftovers from apps that are already gone, use mo clean." + echo "" + echo "Examples:" + echo " mo uninstall Open interactive app selector" + echo " mo uninstall slack Uninstall Slack" + echo " mo uninstall slack zoom Uninstall Slack and Zoom" + echo " mo uninstall --dry-run slack Preview Slack uninstallation" + echo " mo uninstall --list Show installed apps and the names mo uninstall accepts" echo "" echo "Options:" + echo " --list List installed apps with the exact name mo uninstall accepts" echo " --dry-run Preview app uninstallation without making changes" + echo " --permanent Bypass macOS Trash and rm -rf immediately" echo " --whitelist Not supported for uninstall (use clean/optimize)" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" + echo "" + echo "By default, uninstalled files go to the macOS Trash so they can be" + echo "recovered. Use --permanent to skip the Trash step." } diff --git a/Resources/mole/lib/core/log.sh b/Resources/mole/lib/core/log.sh index 95d92ce..3092257 100644 --- a/Resources/mole/lib/core/log.sh +++ b/Resources/mole/lib/core/log.sh @@ -21,9 +21,9 @@ fi # Logging Configuration # ============================================================================ -readonly LOG_FILE="${HOME}/.config/mole/mole.log" -readonly DEBUG_LOG_FILE="${HOME}/.config/mole/mole_debug_session.log" -readonly OPERATIONS_LOG_FILE="${HOME}/.config/mole/operations.log" +readonly LOG_FILE="${HOME}/Library/Logs/mole/mole.log" +readonly DEBUG_LOG_FILE="${HOME}/Library/Logs/mole/mole_debug_session.log" +readonly OPERATIONS_LOG_FILE="${HOME}/Library/Logs/mole/operations.log" readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB readonly OPLOG_MAX_SIZE_DEFAULT=5242880 # 5MB @@ -37,6 +37,22 @@ fi # Log Rotation # ============================================================================ +append_log_line() { + local file_path="$1" + local line="${2:-}" + + ensure_user_file "$file_path" + printf '%s\n' "$line" >> "$file_path" 2> /dev/null || true +} + +append_log_lines() { + local file_path="$1" + shift + + ensure_user_file "$file_path" + printf '%s\n' "$@" >> "$file_path" 2> /dev/null || true +} + # Rotate log file if it exceeds maximum size rotate_log_once() { # Skip if already checked this session @@ -81,9 +97,9 @@ log_info() { echo -e "${BLUE}$1${NC}" local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] INFO: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] INFO: $1" fi } @@ -92,9 +108,9 @@ log_success() { echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] SUCCESS: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] SUCCESS: $1" fi } @@ -103,9 +119,9 @@ log_warning() { echo -e "${YELLOW}$1${NC}" local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] WARNING: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] WARNING: $1" fi } @@ -114,9 +130,9 @@ log_error() { echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2 local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true + append_log_line "$LOG_FILE" "[$timestamp] ERROR: $1" if [[ "${MO_DEBUG:-}" == "1" ]]; then - echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] ERROR: $1" fi } @@ -126,7 +142,7 @@ debug_log() { echo -e "${GRAY}[DEBUG]${NC} $*" >&2 local timestamp timestamp=$(get_timestamp) - echo "[$timestamp] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "[$timestamp] DEBUG: $*" fi } @@ -163,7 +179,7 @@ log_operation() { local log_line="[$timestamp] [$command] $action $path" [[ -n "$detail" ]] && log_line+=" ($detail)" - echo "$log_line" >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + append_log_line "$OPERATIONS_LOG_FILE" "$log_line" } # Log session start marker @@ -175,10 +191,10 @@ log_operation_session_start() { local timestamp timestamp=$(get_timestamp) - { - echo "" - echo "# ========== $command session started at $timestamp ==========" - } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + append_log_lines \ + "$OPERATIONS_LOG_FILE" \ + "" \ + "# ========== $command session started at $timestamp ==========" } # shellcheck disable=SC2329 @@ -198,9 +214,9 @@ log_operation_session_end() { size_human="0B" fi - { - echo "# ========== $command session ended at $timestamp, $items items, $size_human ==========" - } >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true + append_log_line \ + "$OPERATIONS_LOG_FILE" \ + "# ========== $command session ended at $timestamp, $items items, $size_human ==========" } # Enhanced debug logging for operations @@ -214,11 +230,18 @@ debug_operation_start() { [[ -n "$operation_desc" ]] && echo -e "${GRAY}[DEBUG] $operation_desc${NC}" >&2 # Also log to file - { - echo "" - echo "=== $operation_name ===" - [[ -n "$operation_desc" ]] && echo "Description: $operation_desc" - } >> "$DEBUG_LOG_FILE" 2> /dev/null || true + if [[ -n "$operation_desc" ]]; then + append_log_lines \ + "$DEBUG_LOG_FILE" \ + "" \ + "=== $operation_name ===" \ + "Description: $operation_desc" + else + append_log_lines \ + "$DEBUG_LOG_FILE" \ + "" \ + "=== $operation_name ===" + fi fi } @@ -232,7 +255,7 @@ debug_operation_detail() { echo -e "${GRAY}[DEBUG] $detail_type: $detail_value${NC}" >&2 # Also log to file - echo "$detail_type: $detail_value" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "$detail_type: $detail_value" fi } @@ -252,7 +275,7 @@ debug_file_action() { echo -e "${GRAY}[DEBUG] $action: $msg${NC}" >&2 # Also log to file - echo "$action: $msg" >> "$DEBUG_LOG_FILE" 2> /dev/null || true + append_log_line "$DEBUG_LOG_FILE" "$action: $msg" fi } @@ -303,8 +326,10 @@ log_system_info() { fi echo "Shell: ${SHELL:-unknown}, ${TERM:-unknown}" - # Check sudo status non-interactively - if sudo -n true 2> /dev/null; then + # Check sudo status non-interactively (skip in test mode) + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + echo "Sudo Access: Skipped (test mode)" + elif sudo -n true 2> /dev/null; then echo "Sudo Access: Active" else echo "Sudo Access: Required" diff --git a/Resources/mole/lib/core/sudo.sh b/Resources/mole/lib/core/sudo.sh index 483497d..cb44042 100644 --- a/Resources/mole/lib/core/sudo.sh +++ b/Resources/mole/lib/core/sudo.sh @@ -44,58 +44,24 @@ is_clamshell_mode() { _request_password() { local tty_path="$1" - local attempts=0 - local show_hint=true - # Extra safety: ensure sudo cache is cleared before password input sudo -k 2> /dev/null - # Save original terminal settings and ensure they're restored on exit local stty_orig stty_orig=$(stty -g < "$tty_path" 2> /dev/null || echo "") trap '[[ -n "${stty_orig:-}" ]] && stty "${stty_orig:-}" < "$tty_path" 2> /dev/null || true' RETURN - while ((attempts < 3)); do - local password="" - - # Show hint on first attempt about Touch ID appearing again - if [[ $show_hint == true ]] && check_touchid_support; then - echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" - show_hint=false - fi - - printf "${PURPLE}${ICON_ARROW}${NC} Password: " > "$tty_path" - - # Disable terminal echo to hide password input (keep canonical mode for reliable input) - stty -echo < "$tty_path" 2> /dev/null || true - IFS= read -r password < "$tty_path" || password="" - # Restore terminal echo immediately - stty echo < "$tty_path" 2> /dev/null || true - - printf "\n" > "$tty_path" - - if [[ -z "$password" ]]; then - unset password - attempts=$((attempts + 1)) - if [[ $attempts -lt 3 ]]; then - echo -e "${GRAY}${ICON_WARNING}${NC} Password cannot be empty" > "$tty_path" - fi - continue - fi + if check_touchid_support; then + echo -e "${GRAY}Note: Touch ID dialog may appear once more, just cancel it${NC}" > "$tty_path" + fi - # Verify password with sudo - # NOTE: macOS PAM will trigger Touch ID before password auth - this is system behavior - if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then - unset password - return 0 - fi + echo -e "${PURPLE}${ICON_ARROW}${NC} Enter your credentials:" > "$tty_path" - unset password - attempts=$((attempts + 1)) - if [[ $attempts -lt 3 ]]; then - echo -e "${GRAY}${ICON_WARNING}${NC} Incorrect password, try again" > "$tty_path" - fi - done + # shellcheck disable=SC2024,SC2094 + # Intentionally route sudo's native prompt to the same TTY device it reads from. + if sudo -v < "$tty_path" > /dev/null 2> "$tty_path"; then + return 0 + fi return 1 } @@ -108,6 +74,11 @@ request_sudo_access() { return 0 fi + # Tests must never trigger real password or Touch ID prompts. + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + # Detect if running in TTY environment local tty_path="/dev/tty" local is_gui_mode=false @@ -149,10 +120,14 @@ request_sudo_access() { # Check if in clamshell mode - if yes, skip Touch ID entirely if is_clamshell_mode; then + local clear_lines=3 + if check_touchid_support; then + clear_lines=4 + fi echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}" if _request_password "$tty_path"; then # Clear all prompt lines (use safe clearing method) - safe_clear_lines 3 "$tty_path" + safe_clear_lines "$clear_lines" "$tty_path" return 0 fi return 1 @@ -299,6 +274,11 @@ ensure_sudo_session() { return 0 fi + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + MOLE_SUDO_ESTABLISHED="false" + return 1 + fi + # Stop old keepalive if exists if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" diff --git a/Resources/mole/lib/core/timeout.sh b/Resources/mole/lib/core/timeout.sh index edd7051..06bde93 100644 --- a/Resources/mole/lib/core/timeout.sh +++ b/Resources/mole/lib/core/timeout.sh @@ -55,6 +55,11 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then echo "[TIMEOUT] Install coreutils for better reliability: brew install coreutils" >&2 fi + # Export so child processes inherit detected values and skip re-detection. + # Without this, children that inherit MO_TIMEOUT_INITIALIZED=1 skip the init + # block but have empty bin vars, forcing the slow shell fallback. + export MO_TIMEOUT_BIN + export MO_TIMEOUT_PERL_BIN export MO_TIMEOUT_INITIALIZED=1 fi @@ -181,7 +186,10 @@ run_with_timeout() { "$@" & local cmd_pid=$! - # Start timeout killer in background + # Start timeout killer in background. + # Redirect all FDs to /dev/null so orphaned child processes (e.g. sleep $duration) + # do not inherit open file descriptors from the caller and block output pipes + # (notably bats output capture pipes that wait for all writers to close). ( # Wait for timeout duration sleep "$duration" @@ -200,7 +208,7 @@ run_with_timeout() { kill -KILL -"$cmd_pid" 2> /dev/null || kill -KILL "$cmd_pid" 2> /dev/null || true fi fi - ) & + ) < /dev/null > /dev/null 2>&1 & local killer_pid=$! # Wait for command to complete diff --git a/Resources/mole/lib/core/ui.sh b/Resources/mole/lib/core/ui.sh index 421d29a..3f2a2f9 100755 --- a/Resources/mole/lib/core/ui.sh +++ b/Resources/mole/lib/core/ui.sh @@ -223,6 +223,7 @@ read_key() { 'q' | 'Q') echo "QUIT" ;; 'R') echo "RETRY" ;; 'm' | 'M') echo "MORE" ;; + 'v' | 'V') echo "VERSION" ;; 'u' | 'U') echo "UPDATE" ;; 't' | 'T') echo "TOUCHID" ;; 'j' | 'J') echo "DOWN" ;; @@ -324,7 +325,8 @@ start_inline_spinner() { if [[ -t 1 ]]; then # Create unique stop flag file for this spinner instance - INLINE_SPINNER_STOP_FILE="${TMPDIR:-/tmp}/mole_spinner_$$_$RANDOM.stop" + ensure_mole_temp_root + INLINE_SPINNER_STOP_FILE="$MOLE_RESOLVED_TMPDIR/mole_spinner_$$_$RANDOM.stop" ( local stop_file="$INLINE_SPINNER_STOP_FILE" @@ -342,7 +344,7 @@ start_inline_spinner() { # Output to stderr to avoid interfering with stdout printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$display_message" >&2 || break i=$((i + 1)) - sleep 0.05 + /bin/sleep 0.05 done # Clean up stop file before exiting @@ -366,7 +368,7 @@ stop_inline_spinner() { # 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 + /bin/sleep 0.05 2> /dev/null || true wait_count=$((wait_count + 1)) done diff --git a/Resources/mole/lib/manage/purge_paths.sh b/Resources/mole/lib/manage/purge_paths.sh index aa34819..8ffb34b 100644 --- a/Resources/mole/lib/manage/purge_paths.sh +++ b/Resources/mole/lib/manage/purge_paths.sh @@ -12,15 +12,13 @@ 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}" +# Config file path (prefer the shared project constant when available) +PURGE_PATHS_CONFIG="${PURGE_PATHS_CONFIG:-${PURGE_CONFIG_FILE:-$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 + if ! write_purge_config "# 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 # @@ -28,7 +26,9 @@ ensure_config_template() { # ~/Documents/MyProjects # ~/Work/ClientA # ~/Work/ClientB -EOF +"; then + echo -e "${YELLOW}${ICON_WARNING}${NC} Could not initialize ${PURGE_PATHS_CONFIG/#$HOME/~}" >&2 + fi fi } diff --git a/Resources/mole/lib/manage/whitelist.sh b/Resources/mole/lib/manage/whitelist.sh index 41259ac..5ab7984 100755 --- a/Resources/mole/lib/manage/whitelist.sh +++ b/Resources/mole/lib/manage/whitelist.sh @@ -120,10 +120,12 @@ npm package cache|$HOME/.npm/_cacache/*|package_manager pip Python package cache|$HOME/.cache/pip/*|package_manager uv Python package cache|$HOME/.cache/uv/*|package_manager R renv global cache (virtual environments)|$HOME/Library/Caches/org.R-project.R/R/renv/*|package_manager +tealdeer tldr pages cache|$HOME/Library/Caches/tealdeer/tldr-pages|package_manager Homebrew downloaded packages|$HOME/Library/Caches/Homebrew/*|package_manager Yarn package manager cache|$HOME/.cache/yarn/*|package_manager pnpm package store|$HOME/Library/pnpm/store/*|package_manager -Composer PHP dependencies cache|$HOME/.composer/cache/*|package_manager +Composer PHP dependencies cache (legacy)|$HOME/.composer/cache/*|package_manager +Composer PHP dependencies cache|$HOME/Library/Caches/composer/*|package_manager RubyGems cache|$HOME/.gem/cache/*|package_manager Conda packages cache|$HOME/.conda/pkgs/*|package_manager Anaconda packages cache|$HOME/anaconda3/pkgs/*|package_manager @@ -140,12 +142,14 @@ Firefox browser cache|$HOME/Library/Caches/Firefox/*|browser_cache Brave browser cache|$HOME/Library/Caches/BraveSoftware/Brave-Browser/*|browser_cache Surge proxy cache|$HOME/Library/Caches/com.nssurge.surge-mac/*|network_tools Surge configuration and data|$HOME/Library/Application Support/com.nssurge.surge-mac/*|network_tools -Docker Desktop image cache|$HOME/Library/Containers/com.docker.docker/Data/*|container_cache +Docker BuildX cache|$HOME/.docker/buildx/cache/*|container_cache Podman container cache|$HOME/.local/share/containers/cache/*|container_cache Font cache|$HOME/Library/Caches/com.apple.FontRegistry/*|system_cache Spotlight metadata cache|$HOME/Library/Caches/com.apple.spotlight/*|system_cache CloudKit cache|$HOME/Library/Caches/CloudKit/*|system_cache Trash|$HOME/.Trash|system_cache +iOS/iPadOS device firmware (.ipsw) from iTunes/Finder|$HOME/Library/iTunes/*Software Updates/*.ipsw|system_cache +Apple Configurator 2 device firmware (.ipsw)|$HOME/Library/Group Containers/*.group.com.apple.configurator/**/*.ipsw|system_cache EOF # Add FINDER_METADATA with constant reference echo "Finder metadata, .DS_Store|$FINDER_METADATA_SENTINEL|system_cache" diff --git a/Resources/mole/lib/optimize/tasks.sh b/Resources/mole/lib/optimize/tasks.sh index 0f69863..37fb789 100644 --- a/Resources/mole/lib/optimize/tasks.sh +++ b/Resources/mole/lib/optimize/tasks.sh @@ -252,6 +252,59 @@ opt_network_optimization() { fi } +# Quarantine database cleanup (Gatekeeper download history). +opt_quarantine_cleanup() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_start "Quarantine Database Cleanup" "Clear Gatekeeper download tracking history" + debug_operation_detail "Method" "DELETE + VACUUM on QuarantineEventsV2 SQLite database" + debug_operation_detail "Safety" "Only clears download tracking metadata, does not affect file quarantine flags" + debug_operation_detail "Expected outcome" "Reduced database size, cleared download tracking history" + debug_risk_level "LOW" "Database is automatically recreated by macOS" + fi + + if ! command -v sqlite3 > /dev/null 2>&1; then + echo -e " ${GRAY}-${NC} Quarantine cleanup skipped, sqlite3 unavailable" + return 0 + fi + + local quarantine_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2" + + if [[ ! -f "$quarantine_db" ]]; then + opt_msg "Quarantine database already clean" + return 0 + fi + + if should_protect_path "$quarantine_db"; then + opt_msg "Quarantine database already clean" + return 0 + fi + + # Check if database has any entries worth cleaning. + local row_count + row_count=$(run_with_timeout 5 sqlite3 "$quarantine_db" "SELECT COUNT(*) FROM LSQuarantineEvent;" 2> /dev/null || echo "0") + + if [[ ! "$row_count" =~ ^[0-9]+$ ]] || [[ "$row_count" -eq 0 ]]; then + opt_msg "Quarantine database already clean" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + local exit_code=0 + set +e + run_with_timeout 10 sqlite3 "$quarantine_db" "DELETE FROM LSQuarantineEvent; VACUUM;" 2> /dev/null + exit_code=$? + set -e + + if [[ $exit_code -eq 0 ]]; then + opt_msg "Quarantine history cleared ($row_count entries)" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clean quarantine database" + fi + else + opt_msg "Quarantine history cleared ($row_count entries)" + fi +} + # SQLite vacuum for Mail/Messages/Safari (safety checks applied). opt_sqlite_vacuum() { if [[ "${MO_DEBUG:-}" == "1" ]]; then @@ -445,11 +498,27 @@ opt_launch_services_rebuild() { } # Font cache rebuild. +browser_family_is_running() { + local browser_name="$1" + + case "$browser_name" in + "Firefox") + pgrep -if "Firefox|org\\.mozilla\\.firefox|firefox .*contentproc|firefox .*plugin-container|firefox .*crashreporter" > /dev/null 2>&1 + ;; + "Zen Browser") + pgrep -if "Zen Browser|org\\.mozilla\\.zen|Zen Browser Helper|zen .*contentproc" > /dev/null 2>&1 + ;; + *) + pgrep -ix "$browser_name" > /dev/null 2>&1 + ;; + esac +} + opt_font_cache_rebuild() { if [[ "${MO_DEBUG:-}" == "1" ]]; then debug_operation_start "Font Cache Rebuild" "Clear and rebuild font cache" debug_operation_detail "Method" "Run atsutil databases -remove" - debug_operation_detail "Safety checks" "Skip when browsers are running to avoid cache rebuild conflicts" + debug_operation_detail "Safety checks" "Skip when browsers or browser helpers are running to avoid cache rebuild conflicts" debug_operation_detail "Expected outcome" "Fixed font display issues, removed corrupted font cache" debug_risk_level "LOW" "System automatically rebuilds font database" fi @@ -457,15 +526,13 @@ opt_font_cache_rebuild() { local success=false if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - # Some browsers (notably Firefox) can keep stale GPU/text caches in /var/folders if - # system font databases are reset while browser/helper processes are still running. + # Some browsers can keep stale GPU/text caches in /var/folders if system font + # databases are reset while browser/helper processes are still running. local -a running_browsers=() - if pgrep -if "Firefox|org\\.mozilla\\.firefox|firefox-gpu-helper" > /dev/null 2>&1; then - running_browsers+=("Firefox") - fi local browser_name local -a browser_checks=( + "Firefox" "Safari" "Google Chrome" "Chromium" @@ -478,7 +545,7 @@ opt_font_cache_rebuild() { "Helium" ) for browser_name in "${browser_checks[@]}"; do - if pgrep -ix "$browser_name" > /dev/null 2>&1; then + if browser_family_is_running "$browser_name"; then running_browsers+=("$browser_name") fi done @@ -487,8 +554,7 @@ opt_font_cache_rebuild() { local running_list running_list=$(printf "%s, " "${running_browsers[@]}") running_list="${running_list%, }" - echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped font cache rebuild because browsers are running: ${running_list}" - echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Quit browsers completely, then rerun optimize if font issues persist${NC}" + echo -e " ${YELLOW}${ICON_WARNING}${NC} Font cache rebuild skipped · ${running_list} still running" return 0 fi @@ -641,6 +707,7 @@ opt_bluetooth_reset() { fi local spinner_started="false" + local disconnect_notice="Bluetooth devices may disconnect briefly during refresh" if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Bluetooth..." spinner_started="true" @@ -688,13 +755,14 @@ opt_bluetooth_reset() { fi if sudo pkill -TERM bluetoothd > /dev/null 2>&1; then + if [[ "$spinner_started" == "true" ]]; then + stop_inline_spinner + fi + echo -e " ${GRAY}${ICON_WARNING}${NC} ${GRAY}${disconnect_notice}${NC}" sleep 1 if pgrep -x bluetoothd > /dev/null 2>&1; then sudo pkill -KILL bluetoothd > /dev/null 2>&1 || true fi - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi opt_msg "Bluetooth module restarted" opt_msg "Connectivity issues resolved" else @@ -707,6 +775,7 @@ opt_bluetooth_reset() { if [[ "$spinner_started" == "true" ]]; then stop_inline_spinner fi + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${disconnect_notice}" opt_msg "Bluetooth module restarted" opt_msg "Connectivity issues resolved" fi @@ -789,6 +858,346 @@ opt_dock_refresh() { opt_msg "Dock refreshed" } +# Prevent .DS_Store on network and USB volumes. +# Idempotent: writes two user defaults that stop Finder from creating +# .DS_Store files on SMB/AFP/NFS shares and removable USB volumes. +# Reversible with: defaults delete com.apple.desktopservices DSDontWrite{Network,USB}Stores +opt_prevent_network_dsstore() { + local domain="com.apple.desktopservices" + local -a keys=("DSDontWriteNetworkStores" "DSDontWriteUSBStores") + local changed=0 + local already=0 + + for key in "${keys[@]}"; do + local current + current=$(defaults read "$domain" "$key" 2> /dev/null || echo "") + if [[ "$current" == "1" ]]; then + already=$((already + 1)) + continue + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + changed=$((changed + 1)) + continue + fi + + if defaults write "$domain" "$key" -bool true 2> /dev/null; then + changed=$((changed + 1)) + fi + done + + if [[ $changed -eq 0 && $already -gt 0 ]]; then + opt_msg ".DS_Store prevention already enabled on network & USB volumes" + return 0 + fi + + if [[ $changed -gt 0 ]]; then + opt_msg ".DS_Store prevention enabled on network & USB volumes" + fi +} + +# Broken LaunchAgent cleanup. +opt_launch_agents_cleanup() { + local agents_dir="$HOME/Library/LaunchAgents" + + if [[ ! -d "$agents_dir" ]]; then + opt_msg "Launch Agents all healthy" + return 0 + fi + + local broken_count=0 + local -a broken_plists=() + + for plist in "$agents_dir"/*.plist; do + [[ -f "$plist" ]] || continue + + local binary="" + binary=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" "$plist" 2> /dev/null || true) + if [[ -z "$binary" ]]; then + binary=$(/usr/libexec/PlistBuddy -c "Print :Program" "$plist" 2> /dev/null || true) + fi + + if [[ -n "$binary" && ! -e "$binary" ]]; then + broken_count=$((broken_count + 1)) + broken_plists+=("$plist") + fi + done + + if [[ $broken_count -eq 0 ]]; then + opt_msg "Launch Agents all healthy" + return 0 + fi + + for plist in "${broken_plists[@]}"; do + run_launchctl_unload "$plist" + safe_remove "$plist" true > /dev/null 2>&1 || true + done + + opt_msg "Cleaned $broken_count broken Launch Agent(s)" +} + +# macOS periodic maintenance scripts (daily/weekly/monthly). +# Log path is configurable via MOLE_PERIODIC_LOG for testing; defaults to /var/log/daily.out. +# A missing log file is treated as stale and triggers maintenance. +opt_periodic_maintenance() { + local daily_log="${MOLE_PERIODIC_LOG:-/var/log/daily.out}" + local stale_days=7 + + if [[ -f "$daily_log" ]]; then + local last_mod now age_days + last_mod=$(stat -f %m "$daily_log" 2> /dev/null || echo "0") + now=$(get_epoch_seconds) + age_days=$(((now - last_mod) / 86400)) + + if [[ $age_days -lt $stale_days ]]; then + opt_msg "Periodic maintenance already current (${age_days}d ago)" + return 0 + fi + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + if ! sudo -n true 2> /dev/null; then + opt_msg "Periodic maintenance skipped (requires sudo)" + return 0 + fi + # Capture stderr so --debug can surface the real failure reason + # (missing /etc/periodic scripts, SIP, broken launchd, etc.). + local periodic_output rc + if periodic_output=$(sudo periodic daily weekly monthly 2>&1); then + opt_msg "Periodic maintenance triggered" + else + rc=$? + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to run periodic maintenance (exit=$rc)" + if [[ -n "$periodic_output" ]]; then + debug_log "periodic stderr: $periodic_output" + fi + fi + else + opt_msg "Periodic maintenance triggered" + fi +} + +# Repair corrupted shared file list databases (Finder favorites, recent docs). +opt_shared_file_list_repair() { + local sfl_dir="$HOME/Library/Application Support/com.apple.sharedfilelist" + if [[ ! -d "$sfl_dir" ]]; then + opt_msg "Shared file lists directory not found" + return 0 + fi + + local repaired=0 + while IFS= read -r sfl_file; do + [[ -f "$sfl_file" ]] || continue + # Skip recent-documents list (user data, not a cache) + [[ "$sfl_file" == *"ApplicationRecentDocuments"* ]] && continue + if ! plutil -lint "$sfl_file" > /dev/null 2>&1; then + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + safe_remove "$sfl_file" true > /dev/null 2>&1 || true + fi + repaired=$((repaired + 1)) + fi + done < <(command find "$sfl_dir" \( -name "*.sfl2" -o -name "*.sfl3" \) -type f 2> /dev/null || true) + + if [[ $repaired -gt 0 ]]; then + opt_msg "Repaired $repaired corrupted shared file list(s)" + else + opt_msg "Shared file lists all healthy" + fi +} + +# Clean old delivered notifications from NotificationCenter database. +opt_notification_cleanup() { + local nc_db_dir + nc_db_dir="$(getconf DARWIN_USER_DIR 2> /dev/null || true)/com.apple.notificationcenter/db2" + local nc_db="$nc_db_dir/db" + + if [[ ! -f "$nc_db" ]]; then + opt_msg "Notification Center database not found" + return 0 + fi + + local db_size + db_size=$(command du -sk "$nc_db" 2> /dev/null | awk '{print $1}') + db_size=${db_size:-0} + + # Only clean if database exceeds 50MB (51200 KB) + if [[ $db_size -lt 51200 ]]; then + opt_msg "Notification Center database is healthy ($(bytes_to_human $((db_size * 1024))))" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + if command -v sqlite3 > /dev/null 2>&1; then + local sql_ok=0 + sqlite3 "$nc_db" \ + "DELETE FROM record WHERE delivered_date < strftime('%s','now','-30 days'); VACUUM;" \ + 2> /dev/null || sql_ok=$? + if [[ $sql_ok -eq 0 ]]; then + killall NotificationCenter 2> /dev/null || true + opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Notification Center cleanup skipped (database busy or locked)" + fi + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available" + fi + else + opt_msg "Notification Center database cleaned (was $(bytes_to_human $((db_size * 1024))))" + fi +} + +# Verify filesystem integrity via diskutil. +# Disabled by default: diskutil verifyVolume triggers kernel-level I/O that +# cannot be interrupted by SIGKILL when the volume has APFS inconsistencies, +# causing the system to freeze. Set MOLE_ENABLE_DISK_VERIFY=1 to opt in. +opt_disk_verify() { + if [[ "${MOLE_ENABLE_DISK_VERIFY:-0}" != "1" ]]; then + opt_msg "Disk verify skipped (set MOLE_ENABLE_DISK_VERIFY=1 to enable)" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + opt_msg "Disk verify · skipped in dry-run" + return 0 + fi + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Verifying disk filesystem..." + fi + local output + output=$(run_with_timeout 30 diskutil verifyVolume / 2>&1 || true) + if [[ -t 1 ]]; then + stop_inline_spinner + fi + + if echo "$output" | grep -qi "appears to be OK\|volume appears to be ok"; then + opt_msg "Disk filesystem verified OK" + elif echo "$output" | grep -qi "error\|corrupt\|invalid"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk issues detected · run: sudo diskutil repairVolume /" + else + opt_msg "Disk verify complete" + fi +} + +# Clean Knowledge/CoreDuet usage tracking databases. +opt_coreduet_cleanup() { + local knowledge_dir="$HOME/Library/Application Support/Knowledge" + local knowledge_db="$knowledge_dir/knowledgeC.db" + + if [[ ! -f "$knowledge_db" ]]; then + opt_msg "Knowledge database not found" + return 0 + fi + + # Check combined size of WAL/SHM files + database + local wal_file="$knowledge_db-wal" + local shm_file="$knowledge_db-shm" + local total_size=0 + + for f in "$knowledge_db" "$wal_file" "$shm_file"; do + if [[ -f "$f" ]]; then + local fsize + fsize=$(command du -sk "$f" 2> /dev/null | awk '{print $1}') + total_size=$((total_size + ${fsize:-0})) + fi + done + + # Skip if combined size < 100MB (102400 KB) + if [[ $total_size -lt 102400 ]]; then + opt_msg "Knowledge database is healthy ($(bytes_to_human $((total_size * 1024))))" + return 0 + fi + + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + # Remove WAL and SHM files safely (auto-regenerated by SQLite) + for f in "$wal_file" "$shm_file"; do + [[ -f "$f" ]] && safe_remove "$f" true > /dev/null 2>&1 || true + done + # Remove ZOBJECT entries older than 90 days (CoreTime is Mac epoch: seconds since 2001-01-01) + if command -v sqlite3 > /dev/null 2>&1; then + local sql_ok=0 + sqlite3 "$knowledge_db" \ + "DELETE FROM ZOBJECT WHERE ZCREATIONDATE < (strftime('%s','now','-90 days') - strftime('%s','2001-01-01')); VACUUM;" \ + 2> /dev/null || sql_ok=$? + if [[ $sql_ok -eq 0 ]]; then + opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Knowledge database cleanup skipped (database busy or locked)" + fi + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} sqlite3 not available" + fi + else + opt_msg "Knowledge database cleaned (was $(bytes_to_human $((total_size * 1024))))" + fi +} + +# Audit login items for broken entries referencing missing apps. +# Check if a login item name corresponds to an installed app. +# Login item names often differ from .app bundle names (e.g. "AliLangClient" -> "AliLang.app", +# "Top Calendar" -> "TopCalendar.app"), so we try multiple matching strategies. +_login_item_app_exists() { + local name="$1" + # 1. Exact match + if mdfind "kMDItemFSName == '${name}.app'" 2> /dev/null | grep -q .; then + return 0 + fi + # 2. Try without spaces (e.g. "Top Calendar" -> "TopCalendar") + local nospace="${name// /}" + if [[ "$nospace" != "$name" ]] && mdfind "kMDItemFSName == '${nospace}.app'" 2> /dev/null | grep -q .; then + return 0 + fi + # 3. Strip common helper suffixes (e.g. "AliLangClient" -> "AliLang") + local stripped + stripped=$(echo "$nospace" | sed -E 's/(Client|Helper|Agent|Launcher|Service)$//') + if [[ "$stripped" != "$nospace" ]] && mdfind "kMDItemFSName == '${stripped}.app'" 2> /dev/null | grep -q .; then + return 0 + fi + return 1 +} + +opt_login_items_audit() { + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + opt_msg "Login items audit · skipped in test mode" + return 0 + fi + + local items_output + items_output=$(osascript -e 'tell application "System Events" to get the name of every login item' 2> /dev/null || true) + + if [[ -z "$items_output" ]]; then + opt_msg "No login items found" + return 0 + fi + + local broken=0 + local checked=0 + # Split on ", " (comma-space) to preserve multi-word names like "Top Calendar" and "mihomo-party" + local old_ifs="$IFS" + IFS=',' read -ra items_list <<< "$items_output" + IFS="$old_ifs" + for item in "${items_list[@]}"; do + # Strip leading/trailing spaces from each token + item="${item# }" + item="${item% }" + [[ -z "$item" ]] && continue + # Skip items with single quotes to avoid breaking the mdfind query string + [[ "$item" == *"'"* ]] && continue + checked=$((checked + 1)) + if _login_item_app_exists "$item"; then + continue + fi + echo -e " ${YELLOW}${ICON_WARNING}${NC} Broken login item: $item (app not found)" + broken=$((broken + 1)) + done + + if [[ $broken -eq 0 ]]; then + opt_msg "Login items all healthy ($checked checked)" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} $broken broken login item(s) · remove via System Settings > General > Login Items" + fi +} + # Dispatch optimization by action name. execute_optimization() { local action="$1" @@ -800,15 +1209,24 @@ execute_optimization() { saved_state_cleanup) opt_saved_state_cleanup ;; fix_broken_configs) opt_fix_broken_configs ;; network_optimization) opt_network_optimization ;; + quarantine_cleanup) opt_quarantine_cleanup ;; sqlite_vacuum) opt_sqlite_vacuum ;; launch_services_rebuild) opt_launch_services_rebuild ;; font_cache_rebuild) opt_font_cache_rebuild ;; dock_refresh) opt_dock_refresh ;; + prevent_network_dsstore) opt_prevent_network_dsstore ;; memory_pressure_relief) opt_memory_pressure_relief ;; network_stack_optimize) opt_network_stack_optimize ;; disk_permissions_repair) opt_disk_permissions_repair ;; bluetooth_reset) opt_bluetooth_reset ;; spotlight_index_optimize) opt_spotlight_index_optimize ;; + launch_agents_cleanup) opt_launch_agents_cleanup ;; + periodic_maintenance) opt_periodic_maintenance ;; + shared_file_list_repair) opt_shared_file_list_repair ;; + notification_cleanup) opt_notification_cleanup ;; + disk_verify) opt_disk_verify ;; + coreduet_cleanup) opt_coreduet_cleanup ;; + login_items_audit) opt_login_items_audit ;; *) echo -e "${YELLOW}${ICON_ERROR}${NC} Unknown action: $action" return 1 diff --git a/Resources/mole/lib/ui/app_selector.sh b/Resources/mole/lib/ui/app_selector.sh index add9015..3c9d070 100755 --- a/Resources/mole/lib/ui/app_selector.sh +++ b/Resources/mole/lib/ui/app_selector.sh @@ -17,8 +17,8 @@ format_app_display() { fi # Format size - local size_str="N/A" - [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" + local size_str="--" + [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" && "$size" != "N/A" && "$size" != "--" ]] && size_str="$size" # Calculate available width for app name based on terminal width # Accept pre-calculated max_name_width (5th param) to avoid recalculation in loops @@ -58,10 +58,19 @@ format_app_display() { current_display_width=$(get_display_width "$truncated_name") # Calculate padding needed - # Formula: char_count + (available_width - display_width) = padding to add - local char_count=${#truncated_name} + # printf counts bytes (in LC_ALL=C), not display width or char count. + # Get byte count for printf width calculation. + local old_lc="${LC_ALL:-}" + export LC_ALL=C + local byte_count=${#truncated_name} + if [[ -n "$old_lc" ]]; then + export LC_ALL="$old_lc" + else + unset LC_ALL + fi + local padding_needed=$((available_width - current_display_width)) - local printf_width=$((char_count + padding_needed)) + local printf_width=$((byte_count + padding_needed)) # Use dynamic column width with corrected padding printf "%-*s %9s | %s" "$printf_width" "$truncated_name" "$size_str" "$compact_last_used" diff --git a/Resources/mole/lib/ui/menu_paginated.sh b/Resources/mole/lib/ui/menu_paginated.sh index c241fc1..9212386 100755 --- a/Resources/mole/lib/ui/menu_paginated.sh +++ b/Resources/mole/lib/ui/menu_paginated.sh @@ -400,9 +400,9 @@ paginated_multi_select() { draw_header() { printf "\033[1;1H" >&2 if [[ -n "$filter_text" ]]; then - printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2 + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Search: ${filter_text}_${NC} ${GRAY}(%d/%d)${NC}\n" "${title}" "${#view_indices[@]}" "$total_items" >&2 elif [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Filter: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2 + printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${YELLOW}/ Search: _ ${NC}${GRAY}(type to search)${NC}\n" "${title}" >&2 else printf "\r\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 fi @@ -511,7 +511,7 @@ paginated_multi_select() { local sort_ctrl="${GRAY}S ${sort_status}${NC}" local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" - local filter_ctrl="${GRAY}/ Filter${NC}" + local filter_ctrl="${GRAY}/ Search${NC}" if [[ -n "$filter_text" ]]; then local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}Ctrl+U Clear${NC}" "${GRAY}ESC Clear${NC}") diff --git a/Resources/mole/lib/uninstall/batch.sh b/Resources/mole/lib/uninstall/batch.sh index 8a22f9a..d93a4b2 100755 --- a/Resources/mole/lib/uninstall/batch.sh +++ b/Resources/mole/lib/uninstall/batch.sh @@ -11,14 +11,27 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # Batch uninstall with a single confirmation. -get_lsregister_path() { - echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" -} - is_uninstall_dry_run() { [[ "${MOLE_DRY_RUN:-0}" == "1" ]] } +app_declares_local_network_usage() { + local app_path="$1" + local info_plist="$app_path/Contents/Info.plist" + + [[ -f "$info_plist" ]] || return 1 + + if plutil -extract NSLocalNetworkUsageDescription raw "$info_plist" > /dev/null 2>&1; then + return 0 + fi + + if plutil -extract NSBonjourServices xml1 -o - "$info_plist" > /dev/null 2>&1; then + return 0 + fi + + return 1 +} + # High-performance sensitive data detection (pure Bash, no subprocess) # Faster than grep for batch operations, especially when processing many apps has_sensitive_data() { @@ -186,26 +199,29 @@ remove_login_item() { # Remove from Login Items using index-based deletion (handles broken items) if [[ -n "$clean_name" ]]; then - # Escape double quotes and backslashes for AppleScript - local escaped_name="${clean_name//\\/\\\\}" - escaped_name="${escaped_name//\"/\\\"}" - - osascript <<- EOF > /dev/null 2>&1 || true - tell application "System Events" - try - set itemCount to count of login items - -- Delete in reverse order to avoid index shifting - repeat with i from itemCount to 1 by -1 - try - set itemName to name of login item i - if itemName is "$escaped_name" then - delete login item i - end if - end try - end repeat - end try - end tell - EOF + # Skip AppleScript during tests to avoid permission dialogs + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + # Escape double quotes and backslashes for AppleScript + local escaped_name="${clean_name//\\/\\\\}" + escaped_name="${escaped_name//\"/\\\"}" + + osascript <<- EOF > /dev/null 2>&1 || true + tell application "System Events" + try + set itemCount to count of login items + -- Delete in reverse order to avoid index shifting + repeat with i from itemCount to 1 by -1 + try + set itemName to name of login item i + if itemName is "$escaped_name" then + delete login item i + end if + end try + end repeat + end try + end tell + EOF + fi fi } @@ -223,19 +239,14 @@ remove_file_list() { continue fi - if [[ -L "$file" ]]; then - safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true + if [[ "$use_sudo" == "true" ]] && is_uninstall_dry_run; then + debug_log "[DRY RUN] Would sudo remove: $file" + ((++count)) else - if [[ "$use_sudo" == "true" ]]; then - if is_uninstall_dry_run; then - debug_log "[DRY RUN] Would sudo remove: $file" - ((++count)) - else - safe_sudo_remove "$file" && ((++count)) || true - fi - else - safe_remove "$file" true && ((++count)) || true - fi + # mole_delete routes through Trash when MOLE_DELETE_MODE=trash + # (uninstall default), falls back to the underlying safe_* helpers + # in permanent mode or when Trash is unavailable. See #723. + mole_delete "$file" "$use_sudo" && ((++count)) || true fi done <<< "$file_list" @@ -282,6 +293,7 @@ batch_uninstall_applications() { # Pre-scan: running apps, sudo needs, size. local -a running_apps=() local -a sudo_apps=() + local -a brew_cask_apps=() local total_estimated_size=0 local -a app_details=() @@ -319,6 +331,10 @@ batch_uninstall_applications() { fi fi + if [[ "$is_brew_cask" == "true" ]]; then + brew_cask_apps+=("$app_name") + fi + # Check if sudo is needed local needs_sudo=false local app_owner=$(get_file_owner "$app_path") @@ -363,6 +379,11 @@ batch_uninstall_applications() { has_sensitive_data="true" fi + local has_local_network_usage="false" + if app_declares_local_network_usage "$app_path"; then + has_local_network_usage="true" + fi + # Store details for later use (base64 keeps lists on one line). local encoded_files encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n' || echo "") @@ -370,7 +391,7 @@ batch_uninstall_applications() { encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n' || echo "") local encoded_diag_system encoded_diag_system=$(printf '%s' "$diag_system" | base64 | tr -d '\n' || echo "") - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name|$encoded_diag_system") + app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name|$encoded_diag_system|$has_local_network_usage") done if [[ -t 1 ]]; then stop_inline_spinner; fi @@ -380,10 +401,7 @@ batch_uninstall_applications() { # Warn if brew cask apps are present. local has_brew_cask=false - for detail in "${app_details[@]}"; do - IFS='|' read -r _ _ _ _ _ _ _ _ is_brew_cask_flag _ <<< "$detail" - [[ "$is_brew_cask_flag" == "true" ]] && has_brew_cask=true - done + [[ ${#brew_cask_apps[@]} -gt 0 ]] && has_brew_cask=true if [[ "$has_brew_cask" == "true" ]]; then echo -e "${GRAY}${ICON_WARNING} Homebrew apps will be fully cleaned, --zap removes configs and data${NC}" @@ -392,7 +410,7 @@ batch_uninstall_applications() { echo "" 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 is_brew_cask cask_name encoded_diag_system <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system has_local_network_usage <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -464,11 +482,19 @@ batch_uninstall_applications() { # that user explicitly chose to uninstall. System-critical components remain protected. export MOLE_UNINSTALL_MODE=1 - # Request sudo if needed for non-Homebrew removal operations. - # Note: Homebrew resets sudo timestamp at process startup, so pre-auth would - # cause duplicate password prompts in cask-only flows. - if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if ! ensure_sudo_session "Admin required for system apps: ${sudo_apps[*]}"; then + # Establish sudo once before uninstalling apps that need admin access. + # Homebrew cask removal can prompt via sudo during uninstall hooks, which + # does not work reliably under Mole's timed non-interactive execution path. + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]] && + { [[ ${#sudo_apps[@]} -gt 0 ]] || [[ ${#brew_cask_apps[@]} -gt 0 ]]; }; then + local admin_prompt="Admin required to uninstall selected apps" + if [[ ${#sudo_apps[@]} -gt 0 && ${#brew_cask_apps[@]} -eq 0 ]]; then + admin_prompt="Admin required for system apps: ${sudo_apps[*]}" + elif [[ ${#brew_cask_apps[@]} -gt 0 && ${#sudo_apps[@]} -eq 0 ]]; then + admin_prompt="Admin required for Homebrew casks: ${brew_cask_apps[*]}" + fi + + if ! ensure_sudo_session "$admin_prompt"; then echo "" log_error "Admin access denied" _restore_uninstall_traps @@ -481,10 +507,12 @@ batch_uninstall_applications() { local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove local -a failed_items=() local -a success_items=() + local -a local_network_warning_apps=() + local -a system_extension_warning_apps=() local current_index=0 for detail in "${app_details[@]}"; do current_index=$((current_index + 1)) - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system has_local_network_usage <<< "$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local diag_system=$(decode_file_list "$encoded_diag_system" "$app_name") @@ -520,6 +548,13 @@ batch_uninstall_applications() { # Stop spinner before any removal attempt (avoids mixed output on errors) [[ -t 1 ]] && stop_inline_spinner + # For large apps, print a waiting hint so the terminal does not appear frozen + if [[ -t 1 && $total_kb -gt 1048576 && -z "$reason" ]]; then + local _wait_size + _wait_size=$(bytes_to_human "$((total_kb * 1024))") + echo -e " ${GRAY}Removing ${app_name} (${_wait_size}), please wait...${NC}" + fi + local used_brew_successfully=false if [[ -z "$reason" ]]; then if [[ "$is_brew_cask" == "true" && -n "$cask_name" ]]; then @@ -527,15 +562,29 @@ batch_uninstall_applications() { if brew_uninstall_cask "$cask_name" "$app_path"; then used_brew_successfully=true else - # Fallback to manual removal if brew fails - if [[ "$needs_sudo" == true ]]; then - if ! safe_sudo_remove "$app_path"; then - reason="brew failed, manual removal failed" + # Only fall back to manual app removal when Homebrew no longer + # tracks the cask. Otherwise we would recreate the mismatch + # where brew still reports the app as installed after Mole + # removes the bundle manually. + local cask_state=2 + if command -v is_brew_cask_installed > /dev/null 2>&1; then + if is_brew_cask_installed "$cask_name"; then + cask_state=0 + else + cask_state=$? fi - else - if ! safe_remove "$app_path" true; then - reason="brew failed, manual removal failed" + fi + + if [[ $cask_state -eq 1 ]]; then + if ! mole_delete "$app_path" "$needs_sudo"; then + reason="brew cleanup incomplete, manual removal failed" fi + elif [[ $cask_state -eq 0 ]]; then + reason="brew uninstall failed, package still installed" + suggestion="Run brew uninstall --cask --zap $cask_name" + else + reason="brew uninstall failed, package state unknown" + suggestion="Run brew uninstall --cask --zap $cask_name" fi fi elif [[ "$needs_sudo" == true ]]; then @@ -554,24 +603,24 @@ batch_uninstall_applications() { reason="protected system symlink, cannot remove" ;; *) - if ! safe_remove_symlink "$app_path" "true"; then + if ! mole_delete "$app_path" "true"; then reason="failed to remove symlink" fi ;; esac else - if ! safe_remove_symlink "$app_path" "true"; then + if ! mole_delete "$app_path" "true"; then reason="failed to remove symlink" fi fi else if is_uninstall_dry_run; then - if ! safe_remove "$app_path" true; then + if ! mole_delete "$app_path" "false"; then reason="dry-run path validation failed" fi else local ret=0 - safe_sudo_remove "$app_path" || ret=$? + mole_delete "$app_path" "true" || ret=$? if [[ $ret -ne 0 ]]; then local diagnosis diagnosis=$(diagnose_removal_failure "$ret" "$app_name") @@ -580,7 +629,7 @@ batch_uninstall_applications() { fi fi else - if ! safe_remove "$app_path" true; then + if ! mole_delete "$app_path" "false"; then if [[ ! -w "$(dirname "$app_path")" ]]; then reason="parent directory not writable" else @@ -594,6 +643,24 @@ batch_uninstall_applications() { if [[ -z "$reason" ]]; then remove_file_list "$related_files" "false" > /dev/null + # Check for related files that still exist after removal (silent failures, + # e.g. container directories managed by macOS that resist rm -rf). + local leftover_kb=0 + local -a leftover_paths=() + while IFS= read -r _lf; do + [[ -n "$_lf" && -e "$_lf" ]] || continue + # Skip macOS-managed container stubs: containermanagerd protects + # these directories via com.apple.provenance xattr; rm -rf always + # fails on them by design. User data is already gone at this point. + if [[ "$_lf" == */Library/Containers/* && -f "$_lf/.com.apple.containermanagerd.metadata.plist" ]]; then + continue + fi + leftover_paths+=("$_lf") + local _lfkb + _lfkb=$(get_path_size_kb "$_lf" || echo "0") + leftover_kb=$((leftover_kb + _lfkb)) + done <<< "$related_files" + if [[ "$used_brew_successfully" == "true" ]]; then remove_file_list "$diag_system" "true" > /dev/null else @@ -621,7 +688,7 @@ batch_uninstall_applications() { if [[ -d "$HOME/Library/Preferences/ByHost" ]]; then if [[ "$bundle_id" =~ ^[A-Za-z0-9._-]+$ ]]; then while IFS= read -r -d '' plist_file; do - safe_remove "$plist_file" true > /dev/null || true + mole_delete "$plist_file" "true" || true done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true) else debug_log "Skipping ByHost cleanup, invalid bundle id: $bundle_id" @@ -638,12 +705,31 @@ batch_uninstall_applications() { fi fi + # Warn about files that could not be removed and exclude them from freed total. + if [[ ${#leftover_paths[@]} -gt 0 ]]; then + for _lpath in "${leftover_paths[@]}"; do + echo -e " ${YELLOW}${ICON_WARNING}${NC} Could not remove: ${_lpath/$HOME/~}" + done + total_kb=$((total_kb - leftover_kb)) + ((total_kb < 0)) && total_kb=0 + fi + total_size_freed=$((total_size_freed + total_kb)) success_count=$((success_count + 1)) [[ "$used_brew_successfully" == "true" ]] && brew_apps_removed=$((brew_apps_removed + 1)) files_cleaned=$((files_cleaned + 1)) total_items=$((total_items + 1)) success_items+=("$app_path") + if [[ "$has_local_network_usage" == "true" ]]; then + local_network_warning_apps+=("$app_name") + fi + + # Check for orphaned system extensions (camera, network, endpoint security, etc.) + if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && "$bundle_id" =~ ^[A-Za-z0-9._-]+$ && -d /Library/SystemExtensions ]]; then + if command find /Library/SystemExtensions -maxdepth 3 -name "*.systemextension" -path "*${bundle_id}*" -print -quit 2> /dev/null | grep -q .; then + system_extension_warning_apps+=("$app_name") + fi + fi else if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then @@ -761,6 +847,31 @@ batch_uninstall_applications() { summary_details+=("No applications were uninstalled.") fi + if [[ ${#local_network_warning_apps[@]} -gt 0 ]]; then + local local_network_list="" + local idx + for ((idx = 0; idx < ${#local_network_warning_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && local_network_list+=", " + local_network_list+="${local_network_warning_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} Local Network permissions on macOS 15+ can outlive app removal: ${YELLOW}${local_network_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Mole does not reset ${GRAY}/Volumes/Data/Library/Preferences/com.apple.networkextension*.plist${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} If stale or duplicate entries remain, clear them manually in Recovery mode because the reset is global${NC}") + fi + + if [[ ${#system_extension_warning_apps[@]} -gt 0 ]]; then + local ext_list="" + local idx + for ((idx = 0; idx < ${#system_extension_warning_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && ext_list+=", " + ext_list+="${system_extension_warning_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} System extensions may remain after removal: ${YELLOW}${ext_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Check ${GRAY}System Settings > General > Login Items & Extensions${NC} to remove leftover extensions") + fi + local title="Uninstall complete" if [[ "$summary_status" == "warn" ]]; then title="Uninstall incomplete" diff --git a/Resources/mole/lib/uninstall/brew.sh b/Resources/mole/lib/uninstall/brew.sh index 012ca53..5856a52 100644 --- a/Resources/mole/lib/uninstall/brew.sh +++ b/Resources/mole/lib/uninstall/brew.sh @@ -35,6 +35,21 @@ is_homebrew_available() { command -v brew > /dev/null 2>&1 } +# Check whether a cask is still recorded as installed in Homebrew. +# Exit codes: +# 0 - cask is installed +# 1 - cask is not installed +# 2 - install state could not be determined +is_brew_cask_installed() { + local cask_name="$1" + [[ -n "$cask_name" ]] || return 2 + is_homebrew_available || return 2 + + local cask_list + cask_list=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null) || return 2 + grep -qxF "$cask_name" <<< "$cask_list" +} + # Extract cask token from a Caskroom path # Args: $1 - path (must be inside Caskroom) # Prints: cask token to stdout @@ -211,7 +226,12 @@ brew_uninstall_cask() { # Verify removal (only if not timed out) local cask_gone=true app_gone=true - HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "$cask_name" && cask_gone=false + if is_brew_cask_installed "$cask_name"; then + cask_gone=false + else + local cask_state=$? + [[ $cask_state -eq 1 ]] || cask_gone=false + fi [[ -n "$app_path" && -e "$app_path" ]] && app_gone=false # Success: uninstall worked and both are gone, or already uninstalled diff --git a/Resources/mole/mole b/Resources/mole/mole index 6f99026..9ddbd04 100755 --- a/Resources/mole/mole +++ b/Resources/mole/mole @@ -13,12 +13,11 @@ source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.30.0" +VERSION="1.35.0" MOLE_TAGLINE="Deep clean and optimize your Mac." is_touchid_configured() { - local pam_sudo_file="/etc/pam.d/sudo" - [[ -f "$pam_sudo_file" ]] && grep -q "pam_tid.so" "$pam_sudo_file" 2> /dev/null + grep -q "pam_tid.so" /etc/pam.d/sudo /etc/pam.d/sudo_local 2> /dev/null } get_latest_version() { @@ -133,6 +132,14 @@ get_install_commit() { fi } +get_latest_commit_from_github() { + local sha + sha=$(curl -fsSL --connect-timeout 2 --max-time 3 \ + "https://api.github.com/repos/tw93/mole/commits/main" 2> /dev/null | + grep '"sha"[[:space:]]*:[[:space:]]*"[0-9a-f]\{40\}"' | head -1 | sed -E 's/.*"sha"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + echo "$sha" +} + # Background update notice check_for_updates() { local msg_cache="$HOME/.cache/mole/update_message" @@ -141,28 +148,44 @@ check_for_updates() { ( ( - local latest + local channel + channel=$(get_install_channel) - latest=$(get_latest_version_from_github) - if [[ -z "$latest" ]]; then - latest=$(get_latest_version) - fi + if [[ "$channel" == "nightly" ]]; then + # Nightly: compare commit hashes instead of version numbers + local installed_commit latest_commit + installed_commit=$(get_install_commit) + latest_commit=$(get_latest_commit_from_github) + + if [[ -n "$installed_commit" && -n "$latest_commit" && "${installed_commit:0:7}" != "${latest_commit:0:7}" ]]; then + printf "\nNew nightly commit %s available, run %smo update --nightly%s\n\n" "${latest_commit:0:7}" "$GREEN" "$NC" > "$msg_cache" + else + echo -n > "$msg_cache" + fi + else + local latest - if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then - if is_homebrew_install; then - # For Homebrew, only notify if the brew tap has the new version available locally - local brew_latest - brew_latest=$(get_homebrew_latest_version || true) - if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then - printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache" + latest=$(get_latest_version_from_github) + if [[ -z "$latest" ]]; then + latest=$(get_latest_version) + fi + + if [[ -n "$latest" && "$VERSION" != "$latest" && "$(printf '%s\n' "$VERSION" "$latest" | sort -V | head -1)" == "$VERSION" ]]; then + if is_homebrew_install; then + # For Homebrew, only notify if the brew tap has the new version available locally + local brew_latest + brew_latest=$(get_homebrew_latest_version || true) + if [[ -n "$brew_latest" && "$brew_latest" != "$VERSION" && "$(printf '%s\n' "$VERSION" "$brew_latest" | sort -V | head -1)" == "$VERSION" ]]; then + printf "\nUpdate %s available, run %smo update%s\n\n" "$brew_latest" "$GREEN" "$NC" > "$msg_cache" + else + echo -n > "$msg_cache" + fi else - echo -n > "$msg_cache" + printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache" fi else - printf "\nUpdate %s available, run %smo update%s\n\n" "$latest" "$GREEN" "$NC" > "$msg_cache" + echo -n > "$msg_cache" fi - else - echo -n > "$msg_cache" fi ) > /dev/null 2>&1 < /dev/null & ) @@ -494,6 +517,10 @@ update_mole() { # Remove flow (Homebrew + manual + config/cache). remove_mole() { local dry_run_mode="${1:-false}" + local test_mode=false + if [[ "${MOLE_TEST_MODE:-0}" == "1" ]]; then + test_mode=true + fi if [[ -t 1 ]]; then start_inline_spinner "Detecting Mole installations..." @@ -507,37 +534,47 @@ remove_mole() { local -a manual_installs=() local -a alias_installs=() - if command -v brew > /dev/null 2>&1; 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 [[ "$test_mode" != "true" ]]; then + if command -v brew > /dev/null 2>&1; 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 mole > /dev/null 2>&1; then - brew_has_mole="true" + if [[ -n "$brew_cmd" ]]; then + if "$brew_cmd" list mole > /dev/null 2>&1; then + brew_has_mole="true" + fi fi - fi - if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then - is_homebrew=true + if [[ "$brew_has_mole" == "true" ]] || is_homebrew_install; then + is_homebrew=true + fi fi local found_mole - found_mole=$(command -v mole 2> /dev/null || true) - if [[ -n "$found_mole" && -f "$found_mole" ]]; then - if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then - manual_installs+=("$found_mole") + found_mole="" + if [[ "$test_mode" != "true" ]]; then + found_mole=$(command -v mole 2> /dev/null || true) + if [[ -n "$found_mole" && -f "$found_mole" ]]; then + if [[ ! -L "$found_mole" ]] || ! readlink "$found_mole" | grep -q "Cellar/mole"; then + manual_installs+=("$found_mole") + fi fi fi - local -a fallback_paths=( - "/usr/local/bin/mole" - "$HOME/.local/bin/mole" - "/opt/local/bin/mole" - ) + local -a fallback_paths=() + if [[ "$test_mode" == "true" ]]; then + fallback_paths=("$HOME/.local/bin/mole") + else + fallback_paths=( + "/usr/local/bin/mole" + "$HOME/.local/bin/mole" + "/opt/local/bin/mole" + ) + fi for path in "${fallback_paths[@]}"; do if [[ -f "$path" && "$path" != "$found_mole" ]]; then @@ -548,18 +585,26 @@ remove_mole() { done local found_mo - found_mo=$(command -v mo 2> /dev/null || true) - if [[ -n "$found_mo" && -f "$found_mo" ]]; then - if [[ ! -L "$found_mo" ]] || ! readlink "$found_mo" | grep -q "Cellar/mole"; then - alias_installs+=("$found_mo") + found_mo="" + if [[ "$test_mode" != "true" ]]; then + found_mo=$(command -v mo 2> /dev/null || true) + if [[ -n "$found_mo" && -f "$found_mo" ]]; then + if [[ ! -L "$found_mo" ]] || ! readlink "$found_mo" | grep -q "Cellar/mole"; then + alias_installs+=("$found_mo") + fi fi fi - local -a alias_fallback=( - "/usr/local/bin/mo" - "$HOME/.local/bin/mo" - "/opt/local/bin/mo" - ) + local -a alias_fallback=() + if [[ "$test_mode" == "true" ]]; then + alias_fallback=("$HOME/.local/bin/mo") + else + alias_fallback=( + "/usr/local/bin/mo" + "$HOME/.local/bin/mo" + "/opt/local/bin/mo" + ) + fi for alias in "${alias_fallback[@]}"; do if [[ -f "$alias" && "$alias" != "$found_mo" ]]; then @@ -602,6 +647,7 @@ remove_mole() { fi [[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}" [[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}" + [[ -d "$HOME/Library/Logs/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/Library/Logs/mole${NC}" printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made" exit 0 @@ -616,6 +662,7 @@ remove_mole() { done echo " ${ICON_LIST} ~/.config/mole" echo " ${ICON_LIST} ~/.cache/mole" + echo " ${ICON_LIST} ~/Library/Logs/mole" echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to confirm, ${GRAY}ESC${NC} to cancel: " IFS= read -r -s -n1 key || key="" @@ -688,6 +735,9 @@ remove_mole() { if [[ -d "$HOME/.config/mole" ]]; then rm -rf "$HOME/.config/mole" 2> /dev/null || true fi + if [[ -d "$HOME/Library/Logs/mole" ]]; then + rm -rf "$HOME/Library/Logs/mole" 2> /dev/null || true + fi local final_message if [[ "$has_error" == "true" ]]; then @@ -737,7 +787,7 @@ show_main_menu() { if [[ -t 0 ]]; then printf '\r\033[2K\n' - local controls="${GRAY}↑↓ | Enter | M More" + local controls="${GRAY}↑↓ | Enter | M More | V Version" if ! is_touchid_configured; then controls="${controls} | T TouchID" elif [[ "${MAIN_MENU_SHOW_UPDATE:-false}" == "true" ]]; then @@ -873,7 +923,7 @@ main() { "uninstall") exec "$SCRIPT_DIR/bin/uninstall.sh" "${args[@]:1}" ;; - "analyze") + "analyze" | "analyse") exec "$SCRIPT_DIR/bin/analyze.sh" "${args[@]:1}" ;; "status") diff --git a/Resources/mole/scripts/test.sh b/Resources/mole/scripts/test.sh index 2cf6fab..fd64292 100755 --- a/Resources/mole/scripts/test.sh +++ b/Resources/mole/scripts/test.sh @@ -10,6 +10,9 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" +# Never allow the scripted test run to trigger real sudo or Touch ID prompts. +export MOLE_TEST_NO_AUTH=1 + # shellcheck source=lib/core/file_ops.sh source "$PROJECT_ROOT/lib/core/file_ops.sh" @@ -20,6 +23,20 @@ echo "" FAILED=0 +enforce_timeout_dependency_in_ci() { + if [[ "${CI:-}" != "true" && "${GITHUB_ACTIONS:-}" != "true" ]]; then + return 0 + fi + + if command -v gtimeout > /dev/null 2>&1 || command -v timeout > /dev/null 2>&1; then + return 0 + fi + + printf "${RED}${ICON_ERROR} Missing timeout binary (gtimeout/timeout) in CI${NC}\n" + printf "${YELLOW}${ICON_WARNING} Install coreutils to provide gtimeout${NC}\n" + exit 1 +} + report_unit_result() { if [[ $1 -eq 0 ]]; then printf "${GREEN}${ICON_SUCCESS} Unit tests passed${NC}\n" @@ -29,6 +46,8 @@ report_unit_result() { fi } +enforce_timeout_dependency_in_ci + echo "1. Linting test scripts..." if command -v shellcheck > /dev/null 2>&1; then TEST_FILES=() @@ -98,51 +117,90 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then use_color=true fi - if bats --help 2>&1 | grep -q -- "--formatter"; then - formatter="${BATS_FORMATTER:-pretty}" - if [[ "$formatter" == "tap" ]]; then - if $use_color; then - esc=$'\033' - if bats --formatter tap "$@" | - sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ - -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then - report_unit_result 0 - else - report_unit_result 1 - fi - else - if bats --formatter tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi - fi + + # Enable parallel execution across test files when GNU parallel is available. + # Cap at 6 jobs to balance speed vs. system load during CI. + bats_opts=() + if command -v parallel > /dev/null 2>&1 && bats --help 2>&1 | grep -q -- "--jobs"; then + _ncpu="$(sysctl -n hw.logicalcpu 2> /dev/null || nproc 2> /dev/null || echo 4)" + _jobs="$((_ncpu > 6 ? 6 : (_ncpu < 2 ? 2 : _ncpu)))" + # --no-parallelize-within-files ensures each test file's tests run + # sequentially (they share a $HOME set by setup_file and are not safe + # to run concurrently). Parallelism is only across files. + bats_opts+=("--jobs" "$_jobs" "--no-parallelize-within-files") + unset _ncpu _jobs + fi + + # core_performance.bats has wall-clock timing assertions that are skewed by + # CPU contention from parallel test workers. When parallel mode is active, + # split it out to run sequentially after the parallel batch completes. + _perf_files=() + if [[ ${#bats_opts[@]} -gt 0 ]]; then + _all=("$@") + _rest=() + if [[ ${#_all[@]} -eq 1 && -d "${_all[0]}" ]]; then + while IFS= read -r _f; do + case "$_f" in + *core_performance.bats) _perf_files+=("$_f") ;; + *) _rest+=("$_f") ;; + esac + done < <(find "${_all[0]}" -type f -name '*.bats' | sort) else - # Pretty format for local development - if bats --formatter "$formatter" "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi + for _f in "${_all[@]}"; do + case "$_f" in + *core_performance.bats) _perf_files+=("$_f") ;; + *) _rest+=("$_f") ;; + esac + done fi - else - if $use_color; then - esc=$'\033' - if bats --tap "$@" | - sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ - -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /"; then - report_unit_result 0 + if [[ ${#_rest[@]} -gt 0 ]]; then + set -- "${_rest[@]}" + else + set -- + fi + unset _all _rest _f + fi + + # Accumulate pass/fail across all bats invocations. + _unit_rc=0 + + # Main run (parallel when bats_opts has --jobs, skipped if no files remain). + if [[ $# -gt 0 ]]; then + if bats --help 2>&1 | grep -q -- "--formatter"; then + formatter="${BATS_FORMATTER:-pretty}" + if [[ "$formatter" == "tap" ]]; then + if $use_color; then + esc=$'\033' + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" | + sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ + -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1 + else + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" || _unit_rc=1 + fi else - report_unit_result 1 + # Pretty format for local development + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter "$formatter" "$@" || _unit_rc=1 fi else - if bats --tap "$@"; then - report_unit_result 0 + if $use_color; then + esc=$'\033' + bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" | + sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ + -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1 else - report_unit_result 1 + bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" || _unit_rc=1 fi fi fi + + # Post-run: timing-sensitive perf tests run after parallel workers have + # finished so CPU contention does not skew wall-clock assertions. + for _pf in ${_perf_files[@]+"${_perf_files[@]}"}; do + bats "$_pf" || _unit_rc=1 + done + unset _perf_files _pf + + report_unit_result "$_unit_rc" else printf "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n" fi @@ -185,37 +243,42 @@ fi echo "" echo "6. Testing installation..." -# Skip if Homebrew mole is installed (install.sh will refuse to overwrite) -install_test_home="" -if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then - printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" +# Installation script is macOS-specific; skip this test on non-macOS platforms +if [[ "$(uname -s)" != "Darwin" ]]; then + printf "${YELLOW}${ICON_WARNING} Installation test skipped (non-macOS)${NC}\n" else - install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)" - if [[ -z "$install_test_home" ]]; then - install_test_home="/tmp/mole-test-home" - mkdir -p "$install_test_home" + # Skip if Homebrew mole is installed (install.sh will refuse to overwrite) + install_test_home="" + if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then + printf "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" + else + install_test_home="$(mktemp -d /tmp/mole-test-home.XXXXXX 2> /dev/null || true)" + if [[ -z "$install_test_home" ]]; then + install_test_home="/tmp/mole-test-home" + mkdir -p "$install_test_home" + fi fi -fi -if [[ -z "$install_test_home" ]]; then - : -elif HOME="$install_test_home" \ - XDG_CONFIG_HOME="$install_test_home/.config" \ - XDG_CACHE_HOME="$install_test_home/.cache" \ - MO_NO_OPLOG=1 \ - ./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" + if [[ -z "$install_test_home" ]]; then + : + elif HOME="$install_test_home" \ + XDG_CONFIG_HOME="$install_test_home/.config" \ + XDG_CACHE_HOME="$install_test_home/.cache" \ + MO_NO_OPLOG=1 \ + ./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 -else - printf "${RED}${ICON_ERROR} Installation test failed${NC}\n" - ((FAILED++)) -fi -MO_NO_OPLOG=1 safe_remove "/tmp/mole-test" true || true -if [[ -n "$install_test_home" ]]; then - MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true + MO_NO_OPLOG=1 safe_remove "/tmp/mole-test" true || true + if [[ -n "$install_test_home" ]]; then + MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true + fi fi echo "" diff --git a/Resources/mole/scripts/update_homebrew_tap_formula.sh b/Resources/mole/scripts/update_homebrew_tap_formula.sh new file mode 100755 index 0000000..7357b70 --- /dev/null +++ b/Resources/mole/scripts/update_homebrew_tap_formula.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat << 'EOF' +Usage: + update_homebrew_tap_formula.sh \ + --formula /path/to/Formula/mole.rb \ + --tag V1.32.0 \ + --source-sha \ + --arm-sha \ + --amd-sha +EOF +} + +formula_path="" +tag="" +source_sha="" +arm_sha="" +amd_sha="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --formula) + formula_path="${2:-}" + shift 2 + ;; + --tag) + tag="${2:-}" + shift 2 + ;; + --source-sha) + source_sha="${2:-}" + shift 2 + ;; + --arm-sha) + arm_sha="${2:-}" + shift 2 + ;; + --amd-sha) + amd_sha="${2:-}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$formula_path" || -z "$tag" || -z "$source_sha" || -z "$arm_sha" || -z "$amd_sha" ]]; then + usage >&2 + exit 1 +fi + +if [[ ! -f "$formula_path" ]]; then + echo "Formula not found: $formula_path" >&2 + exit 1 +fi + +replacement_counts="$( + TAG="$tag" \ + SOURCE_SHA="$source_sha" \ + ARM_SHA="$arm_sha" \ + AMD_SHA="$amd_sha" \ + perl -0ne ' + my $text = $_; + + my $source_replacements = ( + $text =~ s{url "https://github.com/tw93/(?:Mole|mole)/archive/refs/tags/[^"]+\.tar\.gz"\n sha256 "[^"]+"}{ + qq{url "https://github.com/tw93/Mole/archive/refs/tags/$ENV{TAG}.tar.gz"\n sha256 "$ENV{SOURCE_SHA}"} + }se + ); + + my $arm_replacements = ( + $text =~ s{(on_arm do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-arm64\.tar\.gz("\s+sha256 ")[^"]+(")}{ + qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-arm64.tar.gz$2$ENV{ARM_SHA}$3} + }se + ); + + my $amd_replacements = ( + $text =~ s{(on_intel do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-amd64\.tar\.gz("\s+sha256 ")[^"]+(")}{ + qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-amd64.tar.gz$2$ENV{AMD_SHA}$3} + }se + ); + + print "$source_replacements $arm_replacements $amd_replacements\n"; + ' "$formula_path" +)" + +read -r source_replacements arm_replacements amd_replacements <<< "$replacement_counts" + +if [[ "$source_replacements" != "1" || "$arm_replacements" != "1" || "$amd_replacements" != "1" ]]; then + echo "Failed to update formula: expected 1 replacement for source/arm/amd, got ${source_replacements}/${arm_replacements}/${amd_replacements}" >&2 + exit 1 +fi + +TAG="$tag" \ + SOURCE_SHA="$source_sha" \ + ARM_SHA="$arm_sha" \ + AMD_SHA="$amd_sha" \ + perl -0pi -e ' + s{url "https://github.com/tw93/(?:Mole|mole)/archive/refs/tags/[^"]+\.tar\.gz"\n sha256 "[^"]+"}{ + qq{url "https://github.com/tw93/Mole/archive/refs/tags/$ENV{TAG}.tar.gz"\n sha256 "$ENV{SOURCE_SHA}"} + }se; + + s{(on_arm do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-arm64\.tar\.gz("\s+sha256 ")[^"]+(")}{ + qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-arm64.tar.gz$2$ENV{ARM_SHA}$3} + }se; + + s{(on_intel do\s+url ")https://github.com/tw93/(?:Mole|mole)/releases/download/[^/]+/binaries-darwin-amd64\.tar\.gz("\s+sha256 ")[^"]+(")}{ + qq{$1https://github.com/tw93/Mole/releases/download/$ENV{TAG}/binaries-darwin-amd64.tar.gz$2$ENV{AMD_SHA}$3} + }se; +' "$formula_path" diff --git a/Resources/mole/tests/brew_uninstall.bats b/Resources/mole/tests/brew_uninstall.bats index d3aab74..5cc3b7c 100644 --- a/Resources/mole/tests/brew_uninstall.bats +++ b/Resources/mole/tests/brew_uninstall.bats @@ -9,6 +9,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-brew-uninstall-home.XXXXXX")" export HOME + + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE } teardown_file() { @@ -97,6 +101,10 @@ remove_apps_from_dock() { :; } force_kill_app() { return 0; } run_with_timeout() { shift; "$@"; } export -f run_with_timeout +ensure_sudo_session() { + echo "ENSURE_SUDO:$*" >> "$HOME/brew_calls.log" + return 0 +} # Mock brew to track calls brew() { @@ -117,13 +125,14 @@ total_size_cleaned=0 # Simulate 'Enter' for confirmation printf '\n' | batch_uninstall_applications > /dev/null 2>&1 +grep -q "ENSURE_SUDO:Admin required for Homebrew casks: BrewApp" "$HOME/brew_calls.log" grep -q "uninstall --cask --zap brew-app-cask" "$HOME/brew_calls.log" EOF [ "$status" -eq 0 ] } -@test "batch_uninstall_applications does not pre-auth sudo for brew-only casks" { +@test "batch_uninstall_applications pre-auths sudo for brew-only casks" { local app_bundle="$HOME/Applications/BrewPreAuth.app" mkdir -p "$app_bundle" @@ -145,8 +154,8 @@ run_with_timeout() { shift; "$@"; } export -f run_with_timeout ensure_sudo_session() { - echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/order.log" - return 1 + echo "ENSURE_SUDO:$*" >> "$HOME/order.log" + return 0 } brew() { @@ -165,8 +174,9 @@ total_size_cleaned=0 printf '\n' | batch_uninstall_applications > /dev/null 2>&1 +grep -q "ENSURE_SUDO:Admin required for Homebrew casks: BrewPreAuth" "$HOME/order.log" grep -q "BREW_CALL:uninstall --cask --zap brew-preauth-cask" "$HOME/order.log" -! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/order.log" +[[ "$(sed -n '1p' "$HOME/order.log")" == "ENSURE_SUDO:Admin required for Homebrew casks: BrewPreAuth" ]] EOF [ "$status" -eq 0 ] @@ -192,6 +202,7 @@ print_summary_block() { :; } force_kill_app() { return 0; } remove_apps_from_dock() { :; } refresh_launch_services_after_uninstall() { echo "LS_REFRESH"; } +ensure_sudo_session() { return 0; } get_brew_cask_name() { echo "brew-timeout-cask"; return 0; } brew_uninstall_cask() { return 0; } @@ -224,37 +235,165 @@ EOF [[ "$output" != *"Checking brew dependencies"* ]] } -@test "brew_uninstall_cask does not trigger extra sudo pre-auth" { +@test "batch_uninstall_applications keeps brew-managed app intact when brew uninstall fails" { + local app_bundle="$HOME/Applications/BrewBroken.app" + mkdir -p "$app_bundle" + 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/uninstall/brew.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { :; } +run_with_timeout() { shift; "$@"; } +ensure_sudo_session() { return 0; } + +safe_remove() { + echo "SAFE_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +safe_sudo_remove() { + echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +get_brew_cask_name() { echo "brew-broken-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 0; } + +selected_apps=("0|$HOME/Applications/BrewBroken.app|BrewBroken|com.example.brewbroken|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 || true + +[[ -d "$HOME/Applications/BrewBroken.app" ]] +[[ ! -f "$HOME/remove.log" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "batch_uninstall_applications finishes cleanup after brew removes cask record" { + local app_bundle="$HOME/Applications/BrewCleanup.app" + mkdir -p "$app_bundle" + + 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/uninstall/batch.sh" + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +stop_launch_services() { :; } +unregister_app_bundle() { :; } +remove_login_item() { :; } +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +calculate_total_size() { echo "0"; } +has_sensitive_data() { return 1; } +decode_file_list() { return 0; } +remove_file_list() { :; } +run_with_timeout() { shift; "$@"; } +ensure_sudo_session() { return 0; } + +safe_remove() { + echo "SAFE_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +safe_sudo_remove() { + echo "SAFE_SUDO_REMOVE:$1" >> "$HOME/remove.log" + rm -rf "$1" +} + +get_brew_cask_name() { echo "brew-cleanup-cask"; return 0; } +brew_uninstall_cask() { return 1; } +is_brew_cask_installed() { return 1; } + +selected_apps=("0|$HOME/Applications/BrewCleanup.app|BrewCleanup|com.example.brewcleanup|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 -debug_log() { :; } -get_path_size_kb() { echo "0"; } -run_with_timeout() { local _timeout="$1"; shift; "$@"; } +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 + +[[ ! -d "$HOME/Applications/BrewCleanup.app" ]] +grep -q "SAFE_REMOVE:$HOME/Applications/BrewCleanup.app" "$HOME/remove.log" +EOF -sudo() { - echo "UNEXPECTED_SUDO_CALL:$*" - return 1 + [ "$status" -eq 0 ] } +@test "batch_uninstall_applications skips brew sudo pre-auth in dry-run mode" { + 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/uninstall/batch.sh" + brew() { - if [[ "${1:-}" == "uninstall" ]]; then + echo "BREW_CALL:$*" >> "$HOME/dry_run.log" return 0 - fi - if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then - return 0 - fi - return 0 } -export -f sudo brew +export -f brew + +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +remove_apps_from_dock() { :; } +force_kill_app() { return 0; } +ensure_sudo_session() { + echo "UNEXPECTED_ENSURE_SUDO:$*" >> "$HOME/dry_run.log" + return 1 +} +run_with_timeout() { shift; "$@"; } +export -f run_with_timeout + +get_brew_cask_name() { echo "brew-dry-run-cask"; return 0; } + +export MOLE_DRY_RUN=1 +selected_apps=("0|$HOME/Applications/BrewDryRun.app|BrewDryRun|com.example.brewdryrun|0|Never") +mkdir -p "$HOME/Applications/BrewDryRun.app" +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications > /dev/null 2>&1 -brew_uninstall_cask "mock-cask" -echo "DONE" +! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/dry_run.log" 2> /dev/null EOF [ "$status" -eq 0 ] - [[ "$output" == *"DONE"* ]] - [[ "$output" != *"UNEXPECTED_SUDO_CALL:"* ]] } diff --git a/Resources/mole/tests/bundle_resolver.bats b/Resources/mole/tests/bundle_resolver.bats new file mode 100644 index 0000000..8eeb694 --- /dev/null +++ b/Resources/mole/tests/bundle_resolver.bats @@ -0,0 +1,142 @@ +#!/usr/bin/env bats + +# Tests for lib/core/bundle_resolver.sh. Validates the filesystem-fallback path: +# we cannot rely on Spotlight indexing a fake /Applications under a tmpdir, +# so each test forces the Spotlight path to miss (no binary or empty result) +# and asserts the filesystem scan finds the app. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + FAKE_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-bundle-home.XXXXXX")" + export FAKE_HOME + mkdir -p "$FAKE_HOME/Applications" + + # Stage a fake /Applications tree inside the tmp area. bundle_has_installed_app + # hardcodes the real /Applications roots, so we patch _MOLE_BUNDLE_RESOLVER_APP_ROOTS + # from the test harness itself. + FAKE_APPS="$FAKE_HOME/FakeApplications" + export FAKE_APPS + mkdir -p "$FAKE_APPS" +} + +teardown() { + rm -rf "$FAKE_HOME" +} + +# Shared prelude: source base + resolver, disable mdfind, point resolver at FAKE_APPS. +prelude() { + cat <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/timeout.sh" +source "$PROJECT_ROOT/lib/core/bundle_resolver.sh" + +# Force Spotlight miss so we test only the filesystem fallback. +mdfind() { return 0; } +export -f mdfind + +# Override the hardcoded app roots for the test. +_MOLE_BUNDLE_RESOLVER_APP_ROOTS=("$FAKE_APPS") +EOF +} + +make_app() { + local app_dir="$1" + local bundle_id="$2" + mkdir -p "$app_dir/Contents" + cat > "$app_dir/Contents/Info.plist" < + + + + CFBundleIdentifier + $bundle_id + + +EOF +} + +@test "bundle_has_installed_app finds an app by CFBundleIdentifier (Spotlight miss)" { + make_app "$FAKE_APPS/KeePassXC.app" "org.keepassxc.KeePassXC" + + run env FAKE_APPS="$FAKE_APPS" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$app/Contents/Library/LaunchServices/com.adobe.ARMDC.SMJobBlessHelper" + + run env FAKE_APPS="$FAKE_APPS" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <Program%s' "$2" > "$1"; } +mk "$LA/com.ghost.helper.plist" "/Applications/Ghost.app/Contents/MacOS/helper" +mk "$LA/com.real.tool.plist" "/bin/sh" +mk "$LA/com.apple.fake.plist" "/nonexistent/x" +check_orphan_launch_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"1 orphan"* ]] + [[ "$output" == *"com.ghost.helper"* ]] + [[ "$output" != *"com.real.tool"* ]] + [[ "$output" != *"com.apple.fake"* ]] +} + +@test "check_orphan_launch_agents reports None when 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/check/all.sh" +LA="$HOME/Library/LaunchAgents" +rm -rf "$LA" && mkdir -p "$LA" +export MOLE_LAUNCH_AGENT_DIRS="$LA" +check_orphan_launch_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"None orphaned"* ]] +} + +@test "check_orphan_launch_agents respects whitelist" { + 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/check/all.sh" +is_whitelisted() { [[ "$1" == "check_orphan_launch_agents" ]]; } +export -f is_whitelisted +LA="$HOME/Library/LaunchAgents" +rm -rf "$LA" && mkdir -p "$LA" +export MOLE_LAUNCH_AGENT_DIRS="$LA" +printf 'Program/nonexistent/x' > "$LA/com.ghost.plist" +check_orphan_launch_agents +EOF + + [ "$status" -eq 0 ] + [[ -z "$output" ]] +} diff --git a/Resources/mole/tests/clean_app_caches.bats b/Resources/mole/tests/clean_app_caches.bats index 067664f..d211fd8 100644 --- a/Resources/mole/tests/clean_app_caches.bats +++ b/Resources/mole/tests/clean_app_caches.bats @@ -10,6 +10,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-app-caches.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } @@ -54,20 +58,36 @@ EOF [[ "$output" == *"Xcode documentation index"* ]] } -@test "clean_media_players protects spotify offline cache" { +@test "clean_media_players protects spotify offline cache when bnk has content" { 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" +dd if=/dev/zero of="$HOME/Library/Application Support/Spotify/PersistentCache/Storage/offline.bnk" bs=1024 count=2 2>/dev/null safe_clean() { echo "CLEAN:$2"; } clean_media_players EOF [ "$status" -eq 0 ] + [[ "$output" != *"CLEAN:Spotify cache"* ]] [[ "$output" == *"Spotify cache protected"* ]] - [[ "$output" != *"CLEAN: Spotify cache"* ]] +} + +@test "clean_media_players cleans spotify cache when bnk is empty" { + 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" +> "$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" { @@ -80,13 +100,17 @@ safe_clean() { :; } clean_xcode_tools() { echo "xcode"; } clean_code_editors() { echo "editors"; } clean_communication_apps() { echo "comm"; } +clean_dingtalk() { echo "dingtalk"; } +clean_ai_apps() { echo "ai"; } clean_user_gui_applications EOF [ "$status" -eq 0 ] - [[ "$output" == *"xcode"* ]] - [[ "$output" == *"editors"* ]] + [[ "$output" != *"xcode"* ]] + [[ "$output" != *"editors"* ]] [[ "$output" == *"comm"* ]] + [[ "$output" == *"dingtalk"* ]] + [[ "$output" == *"ai"* ]] } @test "clean_ai_apps calls expected caches" { @@ -208,3 +232,131 @@ EOF [[ "$output" == *"Minecraft logs"* ]] [[ "$output" == *"Lunar Client logs"* ]] } + +@test "clean_code_editors includes Zed caches" { + 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" +safe_clean() { echo "$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Zed cache"* ]] + [[ "$output" == *"Zed logs"* ]] +} + +@test "clean_shell_utils includes Warp and Ghostty caches" { + 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" +safe_clean() { echo "$2"; } +clean_shell_utils +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Warp cache"* ]] + [[ "$output" == *"Warp log"* ]] + [[ "$output" == *"Warp Sentry crash reports"* ]] + [[ "$output" == *"Ghostty cache"* ]] +} + +@test "clean_xcode_tools handles zero unavailable simulators without syntax error" { + 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 1; } +safe_clean() { echo "$2"; } +xcrun() { + if [[ "$*" == "simctl list devices unavailable" ]]; then + echo "== Devices ==" + echo "-- iOS 17.0 --" + return 0 + fi + return 0 +} +export -f xcrun +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"syntax error"* ]] + [[ "$output" != *"Unavailable simulators"* ]] +} + +@test "clean_xcode_tools reports unavailable simulators when present" { + 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/app_caches.sh" +pgrep() { return 1; } +safe_clean() { echo "$2"; } +xcrun() { + if [[ "$*" == "simctl list devices unavailable" ]]; then + echo "== Devices ==" + echo "-- Unavailable --" + echo " iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)" + echo " iPhone 13 (12345678-90AB-CDEF-1234-567890ABCDEF) (Shutdown) (unavailable)" + return 0 + fi + return 0 +} +export -f xcrun +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"syntax error"* ]] + [[ "$output" == *"would delete 2 devices"* ]] +} + +# Previously the cleanup path used '|| true' followed by an unconditional +# green SUCCESS echo, so a simctl timeout (124) or any failure was reported +# as "deleted N devices". Capture exit code and branch on it. +@test "clean_xcode_tools reports failure when simctl delete returns non-zero" { + 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 1; } +safe_clean() { echo "$2"; } +xcrun() { + case "$*" in + "simctl list devices unavailable") + echo "== Devices ==" + echo "-- Unavailable --" + echo " iPhone 12 (ABCDEF01-2345-6789-ABCD-EF0123456789) (Shutdown) (unavailable)" + return 0 + ;; + "simctl delete unavailable") + return 124 # simulate run_with_timeout firing + ;; + esac + return 0 +} +export -f xcrun +clean_xcode_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"deleted 1 devices"* ]] + [[ "$output" == *"simctl delete failed"* ]] + [[ "$output" == *"exit=124"* ]] +} + +@test "clean_video_players includes Stremio caches" { + 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" +safe_clean() { echo "$2"; } +clean_video_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Stremio cache"* ]] + [[ "$output" == *"Stremio server cache"* ]] +} diff --git a/Resources/mole/tests/clean_apps.bats b/Resources/mole/tests/clean_apps.bats index 0fafe10..8fa9689 100644 --- a/Resources/mole/tests/clean_apps.bats +++ b/Resources/mole/tests/clean_apps.bats @@ -10,6 +10,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-apps-module.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } @@ -28,8 +32,31 @@ 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"; } +get_file_size() { echo $((2 * 1024 * 1024 * 1024)); } +bytes_to_human() { echo "2.15GB"; } +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"* ]] + [[ "$output" == *$'\033[0;33m→\033[0m'* ]] +} + +@test "clean_ds_store_tree uses green for successful cleanups" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /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 512; } +bytes_to_human() { echo "512B"; } files_cleaned=0 total_size_cleaned=0 total_items=0 @@ -40,6 +67,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"DS test"* ]] + [[ "$output" == *$'\033[0;32m✓\033[0m'* ]] } @test "scan_installed_apps uses cache when fresh" { @@ -59,6 +87,48 @@ EOF [[ "$output" == *"com.example.App"* ]] } +@test "scan_installed_apps filters missing value from osascript output" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Create a fake .app with a plist that has no CFBundleIdentifier +mkdir -p "$HOME/Applications/FakeApp.app/Contents" +cat > "$HOME/Applications/FakeApp.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleName + FakeApp + + +PLIST + +# Create a valid .app alongside it +mkdir -p "$HOME/Applications/GoodApp.app/Contents" +cat > "$HOME/Applications/GoodApp.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.GoodApp + + +PLIST + +debug_log() { :; } +scan_installed_apps "$HOME/installed.txt" +cat "$HOME/installed.txt" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"com.example.GoodApp"* ]] + [[ "$output" != *"missing value"* ]] +} + @test "is_bundle_orphaned returns true for old uninstalled bundle" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORPHAN_AGE_THRESHOLD=30 bash --noprofile --norc <<'EOF' set -euo pipefail @@ -340,6 +410,75 @@ EOF } +@test "clean_orphaned_app_data honors WHITELIST_PATTERNS for Claude VM bundle" { + 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" + +scan_installed_apps() { : > "$1"; } +mdfind() { return 0; } +pgrep() { return 1; } +run_with_timeout() { shift; "$@"; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 4; } +safe_clean() { echo "UNEXPECTED_CLEAN:$2"; rm -rf "$1"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches" +mkdir -p "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" +echo "vm data" > "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img" + +WHITELIST_PATTERNS=("$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle") + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Application Support/Claude/vm_bundles/claudevm.bundle" ]]; then + echo "PASS: Claude VM preserved by whitelist" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] + [[ "$output" == *"PASS: Claude VM preserved by whitelist"* ]] +} + +@test "clean_orphaned_app_data honors WHITELIST_PATTERNS for orphaned 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/apps.sh" + +scan_installed_apps() { : > "$1"; } +is_bundle_orphaned() { return 0; } +is_claude_vm_bundle_orphaned() { return 1; } +mdfind() { return 0; } +pgrep() { return 1; } +run_with_timeout() { shift; "$@"; } +get_file_mtime() { echo 0; } +get_path_size_kb() { echo 4; } +safe_clean() { echo "UNEXPECTED_CLEAN:$2"; rm -rf "$1"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } + +mkdir -p "$HOME/Library/Caches/com.devtool.localbuild" +echo "c" > "$HOME/Library/Caches/com.devtool.localbuild/data" + +WHITELIST_PATTERNS=("$HOME/Library/Caches/com.devtool.localbuild") + +clean_orphaned_app_data + +if [[ -d "$HOME/Library/Caches/com.devtool.localbuild" ]]; then + echo "PASS: whitelisted orphan cache preserved" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED_CLEAN"* ]] + [[ "$output" == *"PASS: whitelisted orphan cache preserved"* ]] +} + @test "is_critical_system_component matches known system services" { run bash --noprofile --norc <<'EOF' set -euo pipefail @@ -367,7 +506,7 @@ EOF } @test "clean_orphaned_system_services respects dry-run" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" @@ -412,142 +551,31 @@ EOF [[ "$output" != *"launchctl-called"* ]] } -@test "is_launch_item_orphaned detects orphan when program missing" { +@test "clean_orphaned_launch_agents preserves user launch agents" { 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" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.orphan.plist" - -cat > "$tmp_plist" << 'PLIST' - - - - - Label - com.test.orphan - ProgramArguments - - /nonexistent/app/program - - - -PLIST - -run_with_timeout() { shift; "$@"; } - -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -fi - -rm -rf "$tmp_dir" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"orphan"* ]] -} - -@test "is_launch_item_orphaned protects when program exists" { - 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" - -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.active.plist" -tmp_program="$tmp_dir/program" -touch "$tmp_program" - -cat > "$tmp_plist" << PLIST - - - - - Label - com.test.active - ProgramArguments - - $tmp_program - - - -PLIST - -run_with_timeout() { shift; "$@"; } - -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -else - echo "not-orphan" -fi - -rm -rf "$tmp_dir" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] -} - -@test "is_launch_item_orphaned protects when app support active" { - 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" - -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.appsupport.plist" - -mkdir -p "$HOME/Library/Application Support/TestApp" -touch "$HOME/Library/Application Support/TestApp/recent.txt" - -cat > "$tmp_plist" << 'PLIST' +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.example.custom-task.plist" <<'PLIST' Label - com.test.appsupport - ProgramArguments - - $HOME/Library/Application Support/TestApp/Current/app - + com.example.custom-task PLIST -run_with_timeout() { shift; "$@"; } - -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -else - echo "not-orphan" -fi - -rm -rf "$tmp_dir" -rm -rf "$HOME/Library/Application Support/TestApp" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] -} - -@test "clean_orphaned_launch_agents skips when no orphans" { - 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/Library/LaunchAgents" - start_section_spinner() { :; } stop_section_spinner() { :; } note_activity() { :; } -get_path_size_kb() { echo "1"; } -run_with_timeout() { shift; "$@"; } clean_orphaned_launch_agents + +[[ -f "$HOME/Library/LaunchAgents/com.example.custom-task.plist" ]] EOF [ "$status" -eq 0 ] diff --git a/Resources/mole/tests/clean_browser_versions.bats b/Resources/mole/tests/clean_browser_versions.bats index b90350a..0a2a3ed 100644 --- a/Resources/mole/tests/clean_browser_versions.bats +++ b/Resources/mole/tests/clean_browser_versions.bats @@ -10,6 +10,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } diff --git a/Resources/mole/tests/clean_cached_device_firmware.bats b/Resources/mole/tests/clean_cached_device_firmware.bats new file mode 100644 index 0000000..0167fc5 --- /dev/null +++ b/Resources/mole/tests/clean_cached_device_firmware.bats @@ -0,0 +1,254 @@ +#!/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-device-firmware.XXXXXX")" + export HOME + + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + + mkdir -p "$HOME" +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + rm -rf "$HOME/Library" +} + +@test "clean_cached_device_firmware is a no-op when no .ipsw files exist" { + 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" + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Items: $total_items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Items: 0"* ]] + [[ "$output" != *"Cached device firmware"* ]] +} + +@test "clean_cached_device_firmware reports .ipsw files in dry-run from iTunes dirs" { + 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" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +IPAD_DIR="$HOME/Library/iTunes/iPad Software Updates" +mkdir -p "$IPHONE_DIR" "$IPAD_DIR" +touch "$IPHONE_DIR/iPhone17,1_18.0_22A000_Restore.ipsw" +touch "$IPHONE_DIR/iPhone15,2_17.5_21F000_Restore.ipsw" +touch "$IPAD_DIR/iPad14,1_18.0_22A000_Restore.ipsw" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "5242880"; } # 5GB +bytes_to_human() { echo "5.0G"; } +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_cached_device_firmware +echo "Files: $files_cleaned Items: $total_items" + +# Verify files still exist (dry-run must not delete) +[[ -f "$IPHONE_DIR/iPhone17,1_18.0_22A000_Restore.ipsw" ]] || exit 11 +[[ -f "$IPAD_DIR/iPad14,1_18.0_22A000_Restore.ipsw" ]] || exit 12 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] + [[ "$output" == *"3 files"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"Files: 3 Items: 1"* ]] +} + +@test "clean_cached_device_firmware finds .ipsw in Apple Configurator 2 nested cache" { + 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" + +CONFIG_DIR="$HOME/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Firmware/iPhone" +mkdir -p "$CONFIG_DIR" +touch "$CONFIG_DIR/nested_firmware.ipsw" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "6291456"; } +bytes_to_human() { echo "6G"; } +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_cached_device_firmware +echo "Files: $files_cleaned" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] + [[ "$output" == *"Files: 1"* ]] +} + +@test "clean_cached_device_firmware removes .ipsw files when not dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +IPSW="$IPHONE_DIR/test_firmware.ipsw" +touch "$IPSW" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +safe_remove() { rm -f "$1"; return 0; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity safe_remove + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware + +if [[ -f "$IPSW" ]]; then + echo "FAIL: ipsw still present" + exit 10 +fi +echo "DELETED" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] + [[ "$output" == *"DELETED"* ]] +} + +@test "clean_cached_device_firmware dry-run leaves real filesystem untouched (no safe_remove mock)" { + 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" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +IPSW="$IPHONE_DIR/preserve.ipsw" +touch "$IPSW" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +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 + +# Do NOT mock safe_remove — real function must honor DRY_RUN +clean_cached_device_firmware + +if [[ ! -f "$IPSW" ]]; then + echo "FAIL: dry-run deleted the file" + exit 20 +fi +echo "PRESERVED" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cached device firmware"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"PRESERVED"* ]] +} + +@test "clean_cached_device_firmware 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" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +touch "$IPHONE_DIR/keep.ipsw" + +is_path_whitelisted() { return 0; } +get_path_size_kb() { echo "5242880"; } +bytes_to_human() { echo "5G"; } +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_cached_device_firmware +echo "Files: $files_cleaned" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Files: 0"* ]] + [[ "$output" != *"Cached device firmware"* ]] +} + +@test "clean_cached_device_firmware does not report success when deletion fails" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +IPHONE_DIR="$HOME/Library/iTunes/iPhone Software Updates" +mkdir -p "$IPHONE_DIR" +IPSW="$IPHONE_DIR/fail_firmware.ipsw" +touch "$IPSW" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +safe_remove() { return 1; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity safe_remove + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_cached_device_firmware +echo "Files: $files_cleaned Items: $total_items Size: $total_size_cleaned" + +if [[ ! -f "$IPSW" ]]; then + echo "FAIL: file deleted" + exit 30 +fi +echo "PRESENT" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Files: 0 Items: 0 Size: 0"* ]] + [[ "$output" == *"PRESENT"* ]] + [[ "$output" != *"Cached device firmware"* ]] +} diff --git a/Resources/mole/tests/clean_core.bats b/Resources/mole/tests/clean_core.bats index 836c15e..ab05999 100644 --- a/Resources/mole/tests/clean_core.bats +++ b/Resources/mole/tests/clean_core.bats @@ -10,6 +10,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-home.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } @@ -87,6 +91,26 @@ run_clean_dry_run() { [[ "$output" == *"full preview"* ]] } +@test "mo clean --dry-run survives an unwritable TMPDIR" { + local blocked_tmp="$HOME/blocked-tmp" + mkdir -p "$blocked_tmp" + chmod 500 "$blocked_tmp" + + set_mock_sudo_uncached + local test_path="$PATH" + if [[ -n "${TEST_MOCK_BIN:-}" ]]; then + test_path="$TEST_MOCK_BIN:$PATH" + fi + + run env HOME="$HOME" TMPDIR="$blocked_tmp" MOLE_TEST_MODE=1 PATH="$test_path" \ + "$PROJECT_ROOT/mole" clean --dry-run + + [ "$status" -eq 0 ] + [[ "$output" != *"mktemp:"* ]] + [[ "$output" != *"Failed to create temporary file"* ]] + [ -d "$HOME/.cache/mole/tmp" ] +} + @test "mo clean --dry-run reports user cache without deleting it" { mkdir -p "$HOME/Library/Caches/TestApp" echo "cache data" > "$HOME/Library/Caches/TestApp/cache.tmp" @@ -98,6 +122,41 @@ run_clean_dry_run() { [ -f "$HOME/Library/Caches/TestApp/cache.tmp" ] } +@test "mo clean --dry-run reports stale login item without deleting it" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.stale.plist" <<'PLIST' + + + + + Label + com.example.stale + ProgramArguments + + /Applications/Missing.app/Contents/MacOS/Missing + + + +PLIST + + run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Potential stale login item: com.example.stale.plist"* ]] + [ -f "$HOME/Library/LaunchAgents/com.example.stale.plist" ] +} + +@test "mo clean --dry-run does not export duplicate targets across sections" { + mkdir -p "$HOME/Library/Application Support/Code/CachedData" + echo "cache" > "$HOME/Library/Application Support/Code/CachedData/data.bin" + + run env HOME="$HOME" MOLE_TEST_MODE=0 "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + + run grep -c "Application Support/Code/CachedData" "$HOME/.config/mole/clean-list.txt" + [ "$status" -eq 0 ] + [ "$output" -eq 1 ] +} + @test "mo clean honors whitelist entries" { mkdir -p "$HOME/Library/Caches/WhitelistedApp" echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp" @@ -322,4 +381,3 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Time Machine backup in progress, skipping cleanup"* ]] } - diff --git a/Resources/mole/tests/clean_dev_caches.bats b/Resources/mole/tests/clean_dev_caches.bats index 9a2137e..64365ff 100644 --- a/Resources/mole/tests/clean_dev_caches.bats +++ b/Resources/mole/tests/clean_dev_caches.bats @@ -159,25 +159,266 @@ EOF [[ "$output" != *"(custom path)"* ]] } -@test "clean_dev_docker skips when daemon not running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 DRY_RUN=false bash --noprofile --norc <<'EOF' +@test "clean_dev_npm cleans default bun cache when bun is unavailable" { + 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/dev.sh" start_section_spinner() { :; } stop_section_spinner() { :; } -run_with_timeout() { return 1; } -clean_tool_cache() { echo "$1"; } +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { return 1; } +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Bun cache|$HOME/.bun/install/cache/*"* ]] + [[ "$output" != *"bun cache|bun cache bun pm cache rm"* ]] + [[ "$output" != *"Orphaned bun cache"* ]] +} + +@test "clean_dev_npm uses bun cache command for default bun cache path" { + 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/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "$HOME/.bun/install/cache" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"bun cache"* ]] + [[ "$output" != *"Bun cache|$HOME/.bun/install/cache/*"* ]] + [[ "$output" != *"Orphaned bun cache"* ]] +} + +@test "clean_dev_npm cleans orphaned default bun cache when custom path is configured" { + 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/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "/tmp/mole-bun-cache" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"bun cache"* ]] + [[ "$output" == *"Orphaned bun cache|$HOME/.bun/install/cache/*"* ]] +} + +@test "clean_dev_npm treats default bun cache path with trailing slash as same path" { + 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/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "$HOME/.bun/install/cache/" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"bun cache"* ]] + [[ "$output" != *"Orphaned bun cache"* ]] +} + +@test "clean_dev_npm falls back to filesystem cleanup when bun cache command fails" { + 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/dev.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +clean_tool_cache() { :; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +npm() { return 0; } +bun() { + if [[ "$1" == "--version" ]]; then + echo "1.2.0" + return 0 + fi + if [[ "$1" == "pm" && "$2" == "cache" && "${3:-}" == "rm" ]]; then + return 1 + fi + if [[ "$1" == "pm" && "$2" == "cache" ]]; then + echo "/tmp/mole-bun-cache" + return 0 + fi + return 0 +} +export -f npm bun +clean_dev_npm +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Bun cache|/tmp/mole-bun-cache/*"* ]] + [[ "$output" == *"Orphaned bun cache|$HOME/.bun/install/cache/*"* ]] +} + +@test "clean_dev_docker skips daemon-managed cleanup by default" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +clean_tool_cache() { echo "$1|$*"; } safe_clean() { echo "$2"; } -debug_log() { echo "$*"; } -docker() { return 1; } +note_activity() { :; } +debug_log() { :; } +docker() { echo "docker called"; return 0; } +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker unused data · skipped by default"* ]] + [[ "$output" == *"Review: docker system df"* ]] + [[ "$output" == *"Prune: docker system prune"* ]] + [[ "$output" == *"Docker BuildX cache"* ]] + [[ "$output" != *"docker called"* ]] +} + +@test "clean_dev_docker keeps BuildX cache cleanup" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { echo "$2|$1"; } +note_activity() { :; } +debug_log() { :; } +docker() { return 0; } export -f docker clean_dev_docker EOF [ "$status" -eq 0 ] - [[ "$output" == *"Docker daemon not running"* ]] - [[ "$output" != *"Docker build cache"* ]] + [[ "$output" == *"Docker BuildX cache|$HOME/.docker/buildx/cache/*"* ]] +} + +@test "clean_dev_docker no longer depends on whitelist to avoid prune" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +clean_tool_cache() { echo "$1|$*"; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { + [[ "$1" == "$HOME/.docker" ]] && return 0 + return 1 +} +export -f is_path_whitelisted +docker() { echo "docker called"; return 0; } +export -f docker +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Docker unused data · skipped by default"* ]] + [[ "$output" != *"whitelisted"* ]] + [[ "$output" != *"mo clean --whitelist"* ]] + [[ "$output" != *"docker called"* ]] + [[ "$output" == *"Prune: docker system prune"* ]] +} + +@test "clean_dev_mise respects MISE_CACHE_DIR and only targets cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MISE_CACHE_DIR="/tmp/mise-cache" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_tool_cache() { :; } +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +clean_dev_mise +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"mise cache|/tmp/mise-cache/*"* ]] + [[ "$output" != *".local/share/mise"* ]] +} + +@test "clean_dev_other_langs cleans configured composer cache paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" COMPOSER_HOME="$HOME/.config/composer-home" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_other_langs +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PHP Composer cache (legacy)|"* ]] + [[ "$output" == *"PHP Composer cache|"* ]] } @test "clean_developer_tools runs key stages" { @@ -191,6 +432,7 @@ clean_homebrew() { echo "brew"; } clean_project_caches() { :; } clean_dev_python() { :; } clean_dev_go() { :; } +clean_dev_mise() { echo "mise"; } clean_dev_rust() { :; } check_rust_toolchains() { :; } check_android_ndk() { :; } @@ -222,6 +464,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"npm"* ]] + [[ "$output" == *"mise"* ]] [[ "$output" == *"brew"* ]] } diff --git a/Resources/mole/tests/clean_hints.bats b/Resources/mole/tests/clean_hints.bats index 04ab24f..634ba39 100644 --- a/Resources/mole/tests/clean_hints.bats +++ b/Resources/mole/tests/clean_hints.bats @@ -23,6 +23,10 @@ setup() { mkdir -p "$HOME/.config/mole" } +teardown() { + rm -rf "$HOME/Library/LaunchAgents" +} + @test "probe_project_artifact_hints reuses purge targets and excludes noisy names" { local root="$HOME/hints-root" mkdir -p "$root/proj/node_modules" "$root/proj/vendor" "$root/proj/bin" @@ -97,3 +101,67 @@ EOT3 [[ "$output" == *"~/Library/Developer/Xcode/DerivedData"* ]] [[ "$output" == *"Review: mo analyze, Device backups, docker system df"* ]] } + +@test "show_user_launch_agent_hint_notice reports missing app-backed target" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.stale.plist" <<'PLIST' + + + + + Label + com.example.stale + ProgramArguments + + /Applications/Missing.app/Contents/MacOS/Missing + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOT4' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +show_user_launch_agent_hint_notice +EOT4 + + [ "$status" -eq 0 ] + [[ "$output" == *"Potential stale login item: com.example.stale.plist"* ]] + [[ "$output" == *"Missing app/helper target"* ]] + [[ "$output" == *"Review: open ~/Library/LaunchAgents"* ]] +} + +@test "show_user_launch_agent_hint_notice skips custom shell wrappers" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.example.custom.plist" <<'PLIST' + + + + + Label + com.example.custom + ProgramArguments + + /bin/bash + -c + $HOME/bin/custom-task + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOT5' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +show_user_launch_agent_hint_notice +EOT5 + + [ "$status" -eq 0 ] + [[ "$output" != *"Potential stale login item:"* ]] + [[ "$output" != *"Review: open ~/Library/LaunchAgents"* ]] +} diff --git a/Resources/mole/tests/clean_misc.bats b/Resources/mole/tests/clean_misc.bats index 31282bb..2d0617d 100644 --- a/Resources/mole/tests/clean_misc.bats +++ b/Resources/mole/tests/clean_misc.bats @@ -10,6 +10,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-clean-extras.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } diff --git a/Resources/mole/tests/clean_system_caches.bats b/Resources/mole/tests/clean_system_caches.bats index 0275c70..3d1f091 100644 --- a/Resources/mole/tests/clean_system_caches.bats +++ b/Resources/mole/tests/clean_system_caches.bats @@ -72,10 +72,10 @@ setup() { mkdir -p "$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' + run_with_timeout() { shift; \"\$@\"; } + export -f run_with_timeout clean_service_worker_cache 'TestBrowser' '$test_cache' " [ "$status" -eq 0 ] @@ -89,6 +89,10 @@ setup() { mkdir -p "$test_cache/def456_https_example.com_0" run bash -c " + export DRY_RUN=true + export PROTECTED_SW_DOMAINS=(capcut.com photopea.com) + source '$PROJECT_ROOT/lib/core/common.sh' + source '$PROJECT_ROOT/lib/clean/caches.sh' run_with_timeout() { local timeout=\"\$1\" shift @@ -105,15 +109,92 @@ setup() { \"\$@\" } export -f run_with_timeout - export DRY_RUN=true - export PROTECTED_SW_DOMAINS=(capcut.com photopea.com) + clean_service_worker_cache 'TestBrowser' '$test_cache' + " + [ "$status" -eq 0 ] + + [[ -d "$test_cache/abc123_https_capcut.com_0" ]] + + rm -rf "$test_cache" +} + +# Regression for #724: MV3 extension SW caches are keyed by origin hash, +# so the PROTECTED_SW_DOMAINS domain-match never fires for them. The +# whitelist is the only escape hatch users have — respect it here. +@test "clean_service_worker_cache honors is_path_whitelisted (#724)" { + local test_cache="$HOME/test_sw_cache_wl" + mkdir -p "$test_cache/abc123hash_extension" + mkdir -p "$test_cache/def456hash_other" + + run bash -c " + export DRY_RUN=false + export PROTECTED_SW_DOMAINS=(nomatch.invalid) source '$PROJECT_ROOT/lib/core/common.sh' source '$PROJECT_ROOT/lib/clean/caches.sh' + WHITELIST_PATTERNS=('$test_cache/abc123hash_extension') + safe_remove() { echo \"REMOVE:\$1\"; return 0; } + export -f safe_remove + note_activity() { :; } + export -f note_activity + run_with_timeout() { + local timeout=\"\$1\" + shift + if [[ \"\$1\" == \"sh\" ]]; then + printf '%s\n' '$test_cache/abc123hash_extension' '$test_cache/def456hash_other' + return 0 + fi + if [[ \"\$1\" == \"du\" ]]; then + printf '2048\t%s\n' \"\$3\" + return 0 + fi + \"\$@\" + } + export -f run_with_timeout clean_service_worker_cache 'TestBrowser' '$test_cache' " + [ "$status" -eq 0 ] + # Whitelisted dir must never be passed to safe_remove + [[ "$output" != *"REMOVE:$test_cache/abc123hash_extension"* ]] + # Non-whitelisted dir must be removed + [[ "$output" == *"REMOVE:$test_cache/def456hash_other"* ]] + # UI reports the protection count + [[ "$output" == *"1 protected"* ]] - [[ -d "$test_cache/abc123_https_capcut.com_0" ]] + rm -rf "$test_cache" +} + +@test "clean_service_worker_cache colors cleaned size with success color" { + local test_cache="$HOME/test_sw_cache_colored" + mkdir -p "$test_cache/abc123_https_example.com_0" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$HOME/.config/mole/purge_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/caches.sh" +roots=$(discover_project_cache_roots) +printf '%s\n' "$roots" +printf 'COUNT=%s\n' "$(printf '%s\n' "$roots" | sed '/^$/d' | wc -l | tr -d ' ')" +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=1"* ]] +} + @test "clean_project_caches skips stalled root scans" { mkdir -p "$HOME/.config/mole" mkdir -p "$HOME/SlowProjects/app" @@ -268,6 +445,34 @@ EOF rm -rf "$HOME/.config/mole" "$HOME/SlowProjects" "$fake_bin" } +@test "scan_project_cache_root prunes conda and site-packages" { + mkdir -p "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg1/__pycache__" + mkdir -p "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg2/__pycache__" + mkdir -p "$HOME/Projects/app/__pycache__" + touch "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg1/__pycache__/mod.pyc" + touch "$HOME/Projects/miniconda3/lib/python3.11/site-packages/pkg2/__pycache__/mod.pyc" + touch "$HOME/Projects/app/pyproject.toml" + touch "$HOME/Projects/app/__pycache__/mod.pyc" + + local output_file + output_file=$(mktemp) + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$HOME/.cache/mole/softwareupdate_list" <<'OUT' +Software Update Tool -defaults() { echo "0"; } +Software Update found the following new or updated software: +* Label: macOS 99 + Title: macOS 99, Version: 99.1, Size: 1024KiB, Recommended: YES, Action: restart, +OUT run_with_timeout() { echo "SHOULD_NOT_CALL_SOFTWAREUPDATE" - return 0 + return 124 } check_macos_update @@ -660,18 +679,63 @@ echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" EOF [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] + [[ "$output" == *"macOS 99, Version: 99.1"* ]] + [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] [[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]] } -@test "check_macos_update outputs debug info when MO_DEBUG set" { +@test "reset_softwareupdate_cache clears in-memory softwareupdate state" { run bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/check/all.sh" -defaults() { echo "1"; } +calls_file="$HOME/softwareupdate_calls" +printf '0\n' > "$calls_file" +first_file="$HOME/first_updates.txt" +second_file="$HOME/second_updates.txt" +rm -f "$HOME/.cache/mole/softwareupdate_list" +SOFTWARE_UPDATE_LIST="" +SOFTWARE_UPDATE_LIST_LOADED="false" +run_with_timeout() { + local timeout="${1:-}" + shift + if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then + local calls + calls=$(cat "$calls_file") + calls=$((calls + 1)) + printf '%s\n' "$calls" > "$calls_file" + cat < "$first_file" +reset_softwareupdate_cache +get_software_updates > "$second_file" +printf 'CALLS=%s\n' "$(cat "$calls_file")" +printf 'FIRST=%s\n' "$(cat "$first_file")" +printf 'SECOND=%s\n' "$(cat "$second_file")" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CALLS=2"* ]] + [[ "$output" == *"FIRST=Software Update Tool"* ]] + [[ "$output" == *"SECOND=Software Update Tool"* ]] + [[ "$output" == *"macOS 2"* ]] +} + +@test "check_macos_update outputs debug info when MO_DEBUG set" { + run bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/check/all.sh" export MO_DEBUG=1 @@ -692,7 +756,7 @@ check_macos_update 2>&1 EOF [ "$status" -eq 0 ] - [[ "$output" == *"[DEBUG] softwareupdate exit status:"* ]] + [[ "$output" == *"[DEBUG] softwareupdate cached output lines:"* ]] } @test "run_with_timeout succeeds without GNU timeout" { @@ -1456,6 +1520,7 @@ opt_bluetooth_reset EOF [ "$status" -eq 0 ] + [[ "$output" == *"Bluetooth devices may disconnect briefly during refresh"* ]] [[ "$output" == *"Bluetooth module restarted"* ]] } diff --git a/Resources/mole/tests/clean_user_core.bats b/Resources/mole/tests/clean_user_core.bats index acf3b54..c0273d7 100644 --- a/Resources/mole/tests/clean_user_core.bats +++ b/Resources/mole/tests/clean_user_core.bats @@ -10,6 +10,10 @@ setup_file() { HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-user-core.XXXXXX")" export HOME + # Prevent AppleScript permission dialogs during tests + MOLE_TEST_MODE=1 + export MOLE_TEST_MODE + mkdir -p "$HOME" } @@ -76,6 +80,41 @@ EOF [[ "$output" == *"Trash · emptied, 2 items"* ]] } +@test "clean_user_essentials keeps Mole runtime logs while cleaning other user logs" { + mkdir -p "$HOME/Library/Logs/mole" + mkdir -p "$HOME/Library/Logs/OtherApp" + touch "$HOME/Library/Logs/mole/operations.log" + touch "$HOME/Library/Logs/OtherApp/old.log" + + 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" +DRY_RUN=false +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +safe_clean() { + local path="" + for path in "${@:1:$#-1}"; do + if should_protect_path "$path"; then + continue + fi + /bin/rm -rf "$path" + done +} + +clean_user_essentials + +[[ -d "$HOME/Library/Logs/mole" ]] +[[ -f "$HOME/Library/Logs/mole/operations.log" ]] +[[ ! -e "$HOME/Library/Logs/OtherApp/old.log" ]] +EOF + + [ "$status" -eq 0 ] +} + @test "clean_app_caches includes macOS system caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -114,7 +153,7 @@ EOF [[ "$output" == *"SPIN_START:Scanning app caches..."* ]] } -@test "clean_support_app_data targets crash, wallpaper, and messages preview caches only" { +@test "clean_support_app_data targets crash, idle assets, and messages preview caches only" { local support_home="$HOME/support-cache-home-1" run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -137,13 +176,14 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"FIND:$support_home/Library/Application Support/CrashReporter:30:f"* ]] [[ "$output" == *"FIND:$support_home/Library/Application Support/com.apple.idleassetsd:30:f"* ]] + [[ "$output" != *"Aerial wallpaper videos"* ]] [[ "$output" == *"Messages sticker cache"* ]] [[ "$output" == *"Messages preview attachment cache"* ]] [[ "$output" == *"Messages preview sticker cache"* ]] [[ "$output" != *"Messages attachments"* ]] } -@test "clean_support_app_data skips messages preview caches while Messages is running" { +@test "clean_support_app_data always cleans messages preview caches" { local support_home="$HOME/support-cache-home-2" run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -158,10 +198,9 @@ clean_support_app_data EOF [ "$status" -eq 0 ] - [[ "$output" == *"Messages is running"* ]] - [[ "$output" != *"Messages sticker cache"* ]] - [[ "$output" != *"Messages preview attachment cache"* ]] - [[ "$output" != *"Messages preview sticker cache"* ]] + [[ "$output" == *"Messages sticker cache"* ]] + [[ "$output" == *"Messages preview attachment cache"* ]] + [[ "$output" == *"Messages preview sticker cache"* ]] } @test "clean_app_caches skips protected containers" { @@ -188,6 +227,39 @@ EOF [[ "$output" != *"App caches"* ]] || [[ "$output" == *"already clean"* ]] } +@test "clean_app_caches skips expensive size scans for large sandboxed caches" { + 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/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +safe_clean() { :; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +get_path_size_kb() { + echo "SHOULD_NOT_SIZE_SCAN" + return 0 +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Containers/com.example.large/Data/Library/Caches" +for i in $(seq 1 101); do + touch "$HOME/Library/Containers/com.example.large/Data/Library/Caches/file-$i.tmp" +done + +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Sandboxed app caches"* ]] + [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]] +} + @test "clean_application_support_logs counts nested directory contents in dry-run size summary" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' set -euo pipefail @@ -255,6 +327,37 @@ EOF [[ "$output" != *"REMOVE:"* ]] } +@test "clean_application_support_logs skips whitelisted application support directories" { + local support_home="$HOME/support-appsupport-whitelist" + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +mkdir -p "$HOME" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +safe_remove() { echo "REMOVE:$1"; } +update_progress_if_needed() { return 1; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +WHITELIST_PATTERNS=("$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev") +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs" +touch "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/runtime.log" + +clean_application_support_logs +test -f "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/runtime.log" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"REMOVE:"* ]] +} + @test "app_support_entry_count_capped stops at cap without failing under pipefail" { local support_home="$HOME/support-appsupport-cap" run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' @@ -415,6 +518,36 @@ EOF [[ "$output" != *"Group Containers logs/caches"* ]] } +@test "clean_group_container_caches skips per-item size scans for large candidates" { + 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/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +get_path_size_kb() { + echo "SHOULD_NOT_SIZE_SCAN" + return 0 +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Group Containers/group.com.example.large/Library/Caches" +for i in $(seq 1 101); do + touch "$HOME/Library/Group Containers/group.com.example.large/Library/Caches/file-$i.tmp" +done + +clean_group_container_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Group Containers logs/caches"* ]] + [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]] +} + @test "clean_finder_metadata respects protection flag" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PROTECT_FINDER_METADATA=true /bin/bash --noprofile --norc <<'EOF' set -euo pipefail @@ -447,6 +580,7 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" safe_clean() { echo "$2"; } +clean_service_worker_cache() { :; } note_activity() { :; } files_cleaned=0 total_size_cleaned=0 @@ -460,6 +594,29 @@ EOF [[ "$output" == *"Puppeteer browser cache"* ]] } +@test "clean_browsers cleans Brave Service Worker caches" { + mkdir -p "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Service Worker/ScriptCache" + + 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" +safe_clean() { echo "$2"; } +clean_service_worker_cache() { echo "Brave SW $1"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Brave SW Brave"* ]] + [[ "$output" == *"Brave Service Worker ScriptCache"* ]] + + rm -rf "$HOME/Library" +} + @test "clean_application_support_logs skips when no access" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -511,3 +668,74 @@ EOF [[ "$output" == *"FOUND: .hidden_dir"* ]] [[ "$output" == *"FOUND: regular_file.txt"* ]] } + +@test "validate_external_volume_target canonicalizes root before comparing target" { + 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_bin="$HOME/bin" +mkdir -p "$mock_bin" +cat > "$mock_bin/diskutil" <<'MOCK' +#!/bin/bash +exit 0 +MOCK +chmod +x "$mock_bin/diskutil" +export PATH="$mock_bin:$PATH" + +real_root="$(mktemp -d "$HOME/ext-real.XXXXXX")" +link_root="$HOME/ext-link" +ln -s "$real_root" "$link_root" +mkdir -p "$link_root/USB" +export MOLE_EXTERNAL_VOLUMES_ROOT="$link_root" + +resolved=$(validate_external_volume_target "$link_root/USB") +echo "RESOLVED=$resolved" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"RESOLVED="*"/USB"* ]] + [[ "$output" != *"must be under"* ]] +} + +@test "clean_app_caches caps precise sandbox size scans when many containers exist" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true MOLE_CONTAINER_CACHE_PRECISE_SIZE_LIMIT=2 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +safe_clean() { :; } +clean_support_app_data() { :; } +clean_group_container_caches() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +count_file="$HOME/size-count" +get_path_size_kb() { + local count + count=$(cat "$count_file" 2> /dev/null || echo "0") + count=$((count + 1)) + echo "$count" > "$count_file" + echo "1" +} + +for i in $(seq 1 5); do + mkdir -p "$HOME/Library/Containers/com.example.$i/Data/Library/Caches" + touch "$HOME/Library/Containers/com.example.$i/Data/Library/Caches/file-$i.tmp" +done + +clean_app_caches +echo "SIZE_CALLS=$(cat "$count_file")" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Sandboxed app caches"* ]] + [[ "$output" == *"SIZE_CALLS=2"* ]] +} diff --git a/Resources/mole/tests/clean_xcode_derived_data.bats b/Resources/mole/tests/clean_xcode_derived_data.bats new file mode 100644 index 0000000..3f790ef --- /dev/null +++ b/Resources/mole/tests/clean_xcode_derived_data.bats @@ -0,0 +1,151 @@ +#!/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-xcode-dd.XXXXXX")" + export HOME + + mkdir -p "$HOME" +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +@test "clean_xcode_derived_data reports project count and size" { + 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/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +cleanup_result_color_kb() { echo "\033[0;32m"; } +bytes_to_human() { echo "36 KB"; } +DRY_RUN=false +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +pgrep() { return 1; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/ProjectAlpha-abcdef123" +mkdir -p "$dd_dir/ProjectBeta-ghijkl456" +mkdir -p "$dd_dir/ProjectGamma-mnopqr789" +echo "build output" > "$dd_dir/ProjectAlpha-abcdef123/build.o" +echo "build output" > "$dd_dir/ProjectBeta-ghijkl456/build.o" +echo "build output" > "$dd_dir/ProjectGamma-mnopqr789/build.o" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"3 projects"* ]] + [[ "$output" == *"Xcode DerivedData"* ]] +} + +@test "clean_xcode_derived_data skips when Xcode 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/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +DRY_RUN=false + +pgrep() { return 0; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/SomeProject-abc123" +echo "data" > "$dd_dir/SomeProject-abc123/build.o" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode is running"* ]] +} + +@test "clean_xcode_derived_data handles empty DerivedData" { + 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/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +DRY_RUN=false +pgrep() { return 1; } +export -f pgrep + +mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"projects"* ]] +} + +@test "clean_xcode_derived_data handles missing DerivedData dir" { + 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/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +DRY_RUN=false +pgrep() { return 1; } +export -f pgrep + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_xcode_derived_data dry run shows would-clean message" { + 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/app_caches.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +DRY_RUN=true +pgrep() { return 1; } +export -f pgrep + +dd_dir="$HOME/Library/Developer/Xcode/DerivedData" +mkdir -p "$dd_dir/MyApp-abc123" +echo "data" > "$dd_dir/MyApp-abc123/build.o" + +clean_xcode_derived_data +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"1 project"* ]] +} diff --git a/Resources/mole/tests/cli.bats b/Resources/mole/tests/cli.bats index 44882fd..f783600 100644 --- a/Resources/mole/tests/cli.bats +++ b/Resources/mole/tests/cli.bats @@ -7,10 +7,31 @@ setup_file() { ORIGINAL_HOME="${HOME:-}" export ORIGINAL_HOME + # Capture real GOCACHE before HOME is replaced with a temp dir. + # Without this, go build would use $HOME/Library/Caches/go-build inside the + # temp dir (empty), causing a full cold rebuild on every test run (~6s). + ORIGINAL_GOCACHE="$(go env GOCACHE 2>/dev/null || true)" + export ORIGINAL_GOCACHE + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" export HOME mkdir -p "$HOME" + + # Build Go binaries from current source for JSON tests. + # Point GOPATH/GOMODCACHE/GOCACHE at the real home so go build can reuse + # the module and build caches rather than doing a cold rebuild every run. + if command -v go > /dev/null 2>&1; then + ANALYZE_BIN="$(mktemp "${TMPDIR:-/tmp}/analyze-go.XXXXXX")" + STATUS_BIN="$(mktemp "${TMPDIR:-/tmp}/status-go.XXXXXX")" + GOPATH="${ORIGINAL_HOME}/go" GOMODCACHE="${ORIGINAL_HOME}/go/pkg/mod" \ + GOCACHE="${ORIGINAL_GOCACHE}" \ + go build -o "$ANALYZE_BIN" "$PROJECT_ROOT/cmd/analyze" 2>/dev/null + GOPATH="${ORIGINAL_HOME}/go" GOMODCACHE="${ORIGINAL_HOME}/go/pkg/mod" \ + GOCACHE="${ORIGINAL_GOCACHE}" \ + go build -o "$STATUS_BIN" "$PROJECT_ROOT/cmd/status" 2>/dev/null + export ANALYZE_BIN STATUS_BIN + fi } teardown_file() { @@ -19,6 +40,7 @@ teardown_file() { if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi + rm -f "${ANALYZE_BIN:-}" "${STATUS_BIN:-}" } create_fake_utils() { @@ -184,7 +206,7 @@ EOF [ "$status" -eq 0 ] MOLE_OUTPUT="$output" - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + DEBUG_LOG="$HOME/Library/Logs/mole/mole_debug_session.log" [ -f "$DEBUG_LOG" ] run grep "Mole Debug Session" "$DEBUG_LOG" @@ -206,7 +228,7 @@ EOF run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run [ "$status" -eq 0 ] - DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" + DEBUG_LOG="$HOME/Library/Logs/mole/mole_debug_session.log" run grep "User:" "$DEBUG_LOG" [ "$status" -eq 0 ] @@ -215,6 +237,41 @@ EOF [ "$status" -eq 0 ] } +@test "mo clean --help includes external volume option" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --help + [ "$status" -eq 0 ] + [[ "$output" == *"--external PATH"* ]] + [[ "$output" == *"already-uninstalled apps"* ]] +} + +@test "mo uninstall --help directs leftover-only cleanup to clean" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" uninstall --help + [ "$status" -eq 0 ] + [[ "$output" == *"already gone, use mo clean"* ]] +} + +@test "mo clean --external accepts canonicalized custom root" { + real_root="$(mktemp -d "$HOME/ext-real.XXXXXX")" + link_root="$HOME/ext-link" + ln -s "$real_root" "$link_root" + mkdir -p "$link_root/USB/.Trashes" + touch "$link_root/USB/.Trashes/cache.tmp" + + mock_bin="$HOME/mock-bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/diskutil" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$mock_bin/diskutil" + + run env HOME="$HOME" PATH="$mock_bin:$PATH" MOLE_EXTERNAL_VOLUMES_ROOT="$link_root" \ + MOLE_TEST_NO_AUTH=1 "$PROJECT_ROOT/mole" clean --external "$link_root/USB" --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"Clean External Volume"* ]] + [[ "$output" == *"External volume cleanup"* ]] +} + @test "touchid status reflects pam file contents" { pam_file="$HOME/pam_test" cat >"$pam_file" <<'EOF' @@ -278,3 +335,152 @@ EOF run grep "pam_tid.so" "$pam_file" [ "$status" -ne 0 ] } + +# --- JSON output mode tests --- + +@test "mo analyze --json outputs valid JSON with expected fields" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json /tmp + [ "$status" -eq 0 ] + + # Validate it is parseable JSON + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" + + # Check required top-level keys + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert 'path' in data, 'missing path' +assert 'overview' in data, 'missing overview' +assert 'entries' in data, 'missing entries' +assert 'total_size' in data, 'missing total_size' +assert 'total_files' in data, 'missing total_files' +assert isinstance(data['entries'], list), 'entries is not a list' +" +} + +@test "mo analyze --json entries contain required fields" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json /tmp + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert data['overview'] is False, 'explicit path should not be overview mode' +for entry in data['entries']: + assert 'name' in entry, 'entry missing name' + assert 'path' in entry, 'entry missing path' + assert 'size' in entry, 'entry missing size' + assert 'is_dir' in entry, 'entry missing is_dir' +" +} + +@test "mo analyze --json path reflects target directory" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json /tmp + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert data['path'] == '/tmp' or data['path'] == '/private/tmp', \ + f\"unexpected path: {data['path']}\" +" +} + +@test "mo analyze --json overview mode returns expected schema" { + if [[ ! -x "${ANALYZE_BIN:-}" ]]; then + skip "analyze binary not available (go not installed?)" + fi + + run "$ANALYZE_BIN" --json + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +assert 'path' in data, 'missing path' +assert 'overview' in data, 'missing overview' +assert data['overview'] is True, 'overview scan should have overview: true' +assert 'entries' in data, 'missing entries' +assert 'total_size' in data, 'missing total_size' +assert isinstance(data['entries'], list), 'entries is not a list' +" +} + +@test "mo status --json outputs valid JSON with expected fields" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run "$STATUS_BIN" --json + [ "$status" -eq 0 ] + + # Validate it is parseable JSON + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" + + # Check required top-level keys + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for key in ['cpu', 'memory', 'disks', 'health_score', 'host', 'uptime']: + assert key in data, f'missing key: {key}' +" +} + +@test "mo status --json cpu section has expected structure" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run "$STATUS_BIN" --json + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +cpu = data['cpu'] +assert 'usage' in cpu, 'cpu missing usage' +assert 'logical_cpu' in cpu, 'cpu missing logical_cpu' +assert isinstance(cpu['usage'], (int, float)), 'cpu usage is not a number' +" +} + +@test "mo status --json memory section has expected structure" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + run "$STATUS_BIN" --json + [ "$status" -eq 0 ] + + echo "$output" | python3 -c " +import sys, json +data = json.load(sys.stdin) +mem = data['memory'] +assert 'total' in mem, 'memory missing total' +assert 'used' in mem, 'memory missing used' +assert 'used_percent' in mem, 'memory missing used_percent' +assert mem['total'] > 0, 'memory total should be positive' +" +} + +@test "mo status --json piped to stdout auto-detects JSON mode" { + if [[ ! -x "${STATUS_BIN:-}" ]]; then + skip "status binary not available (go not installed?)" + fi + + # When piped (not a tty), status should auto-detect and output JSON + output=$("$STATUS_BIN" 2>/dev/null) + echo "$output" | python3 -c "import sys, json; json.load(sys.stdin)" +} diff --git a/Resources/mole/tests/completion.bats b/Resources/mole/tests/completion.bats index 562a731..24645fe 100755 --- a/Resources/mole/tests/completion.bats +++ b/Resources/mole/tests/completion.bats @@ -101,14 +101,14 @@ setup() { @test "completion fish generates valid fish script" { run "$PROJECT_ROOT/bin/completion.sh" fish [ "$status" -eq 0 ] - [[ "$output" == *"complete -c mole"* ]] - [[ "$output" == *"complete -c mo"* ]] + [[ "$output" == *"complete -f -c mole"* ]] + [[ "$output" == *"complete -f -c mo"* ]] } @test "completion fish includes both mole and mo commands" { output="$("$PROJECT_ROOT/bin/completion.sh" fish)" - mole_count=$(echo "$output" | grep -c "complete -c mole") - mo_count=$(echo "$output" | grep -c "complete -c mo") + mole_count=$(echo "$output" | grep -c "complete -f -c mole") + mo_count=$(echo "$output" | grep -c "complete -f -c mo") [ "$mole_count" -gt 0 ] [ "$mo_count" -gt 0 ] diff --git a/Resources/mole/tests/core_common.bats b/Resources/mole/tests/core_common.bats index 69d0a6f..a7a279c 100644 --- a/Resources/mole/tests/core_common.bats +++ b/Resources/mole/tests/core_common.bats @@ -44,13 +44,31 @@ setup() { [[ -n "$result" ]] } +@test "cleanup_result_color_kb always returns green" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +small_kb=1 +large_kb=$(((MOLE_ONE_GB_BYTES * 2) / 1024)) + +if [[ "$(cleanup_result_color_kb "$small_kb")" == "$GREEN" ]] && + [[ "$(cleanup_result_color_kb "$large_kb")" == "$GREEN" ]]; then + echo "ok" +fi +EOF + + [ "$status" -eq 0 ] + [ "$output" = "ok" ] +} + @test "log_info prints message and appends to log file" { local message="Informational message from test" local stdout_output stdout_output="$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; log_info '$message'")" [[ "$stdout_output" == *"$message"* ]] - local log_file="$HOME/.config/mole/mole.log" + local log_file="$HOME/Library/Logs/mole/mole.log" [[ -f "$log_file" ]] grep -q "INFO: $message" "$log_file" } @@ -64,13 +82,35 @@ setup() { [[ -s "$stderr_file" ]] grep -q "$message" "$stderr_file" - local log_file="$HOME/.config/mole/mole.log" + local log_file="$HOME/Library/Logs/mole/mole.log" [[ -f "$log_file" ]] grep -q "ERROR: $message" "$log_file" } +@test "log_operation recreates operations log if the log directory disappears mid-session" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +rm -rf "$HOME/Library/Logs/mole" +log_operation "clean" "REMOVED" "/tmp/example" "1KB" +EOF + [ "$status" -eq 0 ] + + local oplog="$HOME/Library/Logs/mole/operations.log" + [[ -f "$oplog" ]] + grep -Fq "[clean] REMOVED /tmp/example (1KB)" "$oplog" +} + +@test "should_protect_path protects Mole runtime logs" { + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc -c \ + 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$HOME/Library/Logs/mole/operations.log" && echo protected || echo not-protected' + )" + [ "$result" = "protected" ] +} + @test "rotate_log_once only checks log size once per session" { - local log_file="$HOME/.config/mole/mole.log" + local log_file="$HOME/Library/Logs/mole/mole.log" mkdir -p "$(dirname "$log_file")" dd if=/dev/zero of="$log_file" bs=1024 count=1100 2> /dev/null @@ -142,10 +182,24 @@ EOF result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.clash.app' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'io.github.clash-verge-rev.clash-verge-rev' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.example.RegularApp' && echo 'protected' || echo 'not-protected'") [ "$result" = "not-protected" ] } +# Regression: CUPS prefs have a bundle-ID-style name but no parent .app, +# so the orphan sweep deleted them and users lost their default printer +# and recent-printer list. See #731. +@test "should_protect_data protects CUPS printing prefs (#731)" { + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.cups.PrintingPrefs' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'org.cups.printers' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] +} + @test "input methods are protected during cleanup but allowed for uninstall" { result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.tencent.inputmethod.QQInput' && echo 'protected' || echo 'not-protected'") [ "$result" = "protected" ] @@ -201,10 +255,30 @@ sleep 0.1 stop_inline_spinner echo "done" EOF -) + ) [[ "$result" == *"done"* ]] } +@test "start_inline_spinner ignores PATH-provided sleep in TTY mode" { + local fake_bin="$HOME/fake-bin" + local marker="$HOME/fake-sleep.marker" + + mkdir -p "$fake_bin" + cat > "$fake_bin/sleep" <> "$marker" +exec /bin/sleep "\$@" +EOF + chmod +x "$fake_bin/sleep" + + PATH="$fake_bin:$PATH" PROJECT_ROOT="$PROJECT_ROOT" HOME="$HOME" \ + /usr/bin/script -q /dev/null /bin/bash --noprofile --norc -c \ + "source \"\$PROJECT_ROOT/lib/core/common.sh\"; start_inline_spinner \"Testing...\"; /bin/sleep 0.15; stop_inline_spinner" \ + > /dev/null 2>&1 + + [ ! -f "$marker" ] +} + @test "read_key maps j/k/h/l to navigation" { run bash -c "export MOLE_BASE_LOADED=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" [ "$output" = "DOWN" ] @@ -231,3 +305,34 @@ EOF run bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; echo -n 'j' | read_key" [ "$output" = "CHAR:j" ] } + +@test "ensure_sudo_session returns 1 and sets MOLE_SUDO_ESTABLISHED=false in test mode" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 bash --noprofile --norc <<'SCRIPT' +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" +MOLE_SUDO_ESTABLISHED="" +ensure_sudo_session "Test prompt" && rc=0 || rc=$? +echo "EXIT=$rc" +echo "FLAG=$MOLE_SUDO_ESTABLISHED" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"EXIT=1"* ]] + [[ "$output" == *"FLAG=false"* ]] +} + +@test "ensure_sudo_session short-circuits to 0 when session already established" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" +has_sudo_session() { return 0; } +export -f has_sudo_session +MOLE_SUDO_ESTABLISHED="true" +ensure_sudo_session "Test prompt" +echo "EXIT=$?" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"EXIT=0"* ]] +} diff --git a/Resources/mole/tests/core_performance.bats b/Resources/mole/tests/core_performance.bats index 4bf4034..36361ab 100644 --- a/Resources/mole/tests/core_performance.bats +++ b/Resources/mole/tests/core_performance.bats @@ -108,7 +108,7 @@ setup() { @test "get_invoking_user executes quickly" { local start end elapsed - local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-500}" + local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-2000}" start=$(date +%s%N) for i in {1..100}; do @@ -132,6 +132,7 @@ setup() { @test "create_temp_file and cleanup_temp_files work efficiently" { local start end elapsed + local limit_ms="${MOLE_PERF_CREATE_TEMP_FILE_LIMIT_MS:-3000}" declare -a MOLE_TEMP_DIRS=() @@ -143,7 +144,7 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 1000 ] + [ "$elapsed" -lt "$limit_ms" ] [ "${#MOLE_TEMP_FILES[@]}" -eq 50 ] @@ -152,7 +153,7 @@ setup() { end=$(date +%s%N) elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 2000 ] + [ "$elapsed" -lt "$limit_ms" ] [ "${#MOLE_TEMP_FILES[@]}" -eq 0 ] } @@ -233,5 +234,6 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 2000 ] + local limit_ms="${MOLE_PERF_SECTION_LIMIT_MS:-2000}" + [ "$elapsed" -lt "$limit_ms" ] } diff --git a/Resources/mole/tests/core_safe_functions.bats b/Resources/mole/tests/core_safe_functions.bats index 5805f04..a2ea495 100644 --- a/Resources/mole/tests/core_safe_functions.bats +++ b/Resources/mole/tests/core_safe_functions.bats @@ -66,6 +66,9 @@ teardown() { } @test "validate_path_for_deletion rejects system directories" { + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/'" + [ "$status" -eq 1 ] + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/System'" [ "$status" -eq 1 ] @@ -86,6 +89,15 @@ teardown() { [ "$status" -eq 1 ] } +@test "validate_path_for_deletion rejects symlink to protected system path" { + local link_path="$TEST_DIR/system-link" + ln -s "/System" "$link_path" + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$link_path' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"protected system path"* ]] +} + @test "safe_remove successfully removes file" { local test_file="$TEST_DIR/test_file.txt" echo "test" > "$test_file" @@ -134,6 +146,22 @@ teardown() { [ "$status" -eq 1 ] } +@test "safe_sudo_remove refuses symlink paths" { + local target_dir="$TEST_DIR/real" + local link_dir="$TEST_DIR/link" + mkdir -p "$target_dir" + ln -s "$target_dir" "$link_dir" + + run bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + sudo() { return 0; } + export -f sudo + safe_sudo_remove '$link_dir' 2>&1 + " + [ "$status" -eq 1 ] + [[ "$output" == *"Refusing to sudo remove symlink"* ]] +} + @test "safe_find_delete rejects symlinked directory" { local real_dir="$TEST_DIR/real" local link_dir="$TEST_DIR/link" diff --git a/Resources/mole/tests/dev_environment.bats b/Resources/mole/tests/dev_environment.bats new file mode 100644 index 0000000..c86766c --- /dev/null +++ b/Resources/mole/tests/dev_environment.bats @@ -0,0 +1,229 @@ +#!/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-devenv.XXXXXX")" + export HOME + + mkdir -p "$HOME" +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +# ============================================================================ +# Launch Agents tests +# ============================================================================ + +@test "check_launch_agents reports healthy when no broken agents" { + 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/check/dev_environment.sh" +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.valid.plist" << 'INNER_PLIST' + + + + + Label + com.test.valid + ProgramArguments + + /bin/sh + + + +INNER_PLIST +check_launch_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"All healthy"* ]] +} + +@test "check_launch_agents detects broken agent" { + 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/check/dev_environment.sh" +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.broken.plist" << 'INNER_PLIST' + + + + + Label + com.test.broken + ProgramArguments + + /nonexistent/path/to/binary + + + +INNER_PLIST +check_launch_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"1 broken"* ]] + [[ "$output" == *"com.test.broken"* ]] +} + +@test "check_launch_agents healthy when directory missing" { + 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/check/dev_environment.sh" +rm -rf "$HOME/Library/LaunchAgents" +check_launch_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"All healthy"* ]] +} + +# ============================================================================ +# Dev Tools tests +# ============================================================================ + +@test "check_dev_tools reports found tools" { + 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/check/dev_environment.sh" +command() { + if [[ "$1" == "-v" ]]; then + case "$2" in + docker|go) return 1 ;; + *) builtin command "$@" ;; + esac + else + builtin command "$@" + fi +} +export -f command +check_dev_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Dev Tools"* ]] + [[ "$output" == *"found"* ]] + [[ "$output" != *"docker"* ]] + [[ "$output" != *"not found"* ]] +} + +# ============================================================================ +# Version Mismatches tests +# ============================================================================ + +@test "check_version_mismatches detects psql mismatch" { + 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/check/dev_environment.sh" +psql() { echo "psql (PostgreSQL) 16.2"; } +postgres() { echo "postgres (PostgreSQL) 14.1"; } +export -f psql postgres +command() { + if [[ "$1" == "-v" ]]; then + case "$2" in + psql|postgres) return 0 ;; + pyenv) return 1 ;; + *) builtin command "$@" ;; + esac + else + builtin command "$@" + fi +} +export -f command +check_version_mismatches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"psql 16.2 vs server 14.1"* ]] +} + +@test "check_version_mismatches reports no conflicts when versions match" { + 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/check/dev_environment.sh" +psql() { echo "psql (PostgreSQL) 16.2"; } +postgres() { echo "postgres (PostgreSQL) 16.2"; } +export -f psql postgres +command() { + if [[ "$1" == "-v" ]]; then + case "$2" in + psql|postgres) return 0 ;; + pyenv) return 1 ;; + *) builtin command "$@" ;; + esac + else + builtin command "$@" + fi +} +export -f command +check_version_mismatches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No conflicts"* ]] +} + +@test "_extract_major_minor handles version strings" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/check/dev_environment.sh" +result1=$(_extract_major_minor "v18.17.1") +result2=$(_extract_major_minor "PostgreSQL 16.2") +result3=$(_extract_major_minor "Python 3.12.1") +echo "r1:$result1" +echo "r2:$result2" +echo "r3:$result3" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"r1:18.17"* ]] + [[ "$output" == *"r2:16.2"* ]] + [[ "$output" == *"r3:3.12"* ]] +} + +# ============================================================================ +# Aggregator test +# ============================================================================ + +@test "check_all_dev_environment runs all three checks" { + 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/check/dev_environment.sh" +mkdir -p "$HOME/Library/LaunchAgents" +command() { + if [[ "$1" == "-v" ]]; then + case "$2" in + psql|postgres|pyenv) return 1 ;; + *) builtin command "$@" ;; + esac + else + builtin command "$@" + fi +} +export -f command +check_all_dev_environment +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Launch Agents"* || "$output" == *"All healthy"* ]] + [[ "$output" == *"Dev Tools"* ]] + [[ "$output" == *"Versions"* || "$output" == *"No conflicts"* ]] +} diff --git a/Resources/mole/tests/dev_extended.bats b/Resources/mole/tests/dev_extended.bats index ec7515e..939eae8 100644 --- a/Resources/mole/tests/dev_extended.bats +++ b/Resources/mole/tests/dev_extended.bats @@ -99,6 +99,7 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" safe_clean() { echo "$2"; } +clean_service_worker_cache() { :; } clean_dev_editors EOF @@ -272,6 +273,115 @@ EOF [[ "$output" != *"/241.2"* ]] } +@test "clean_dev_ai_agents keeps newest version and removes older ones by mtime" { + local claude_root="$HOME/.local/share/claude/versions" + local cursor_root="$HOME/.local/share/cursor-agent/versions" + mkdir -p "$claude_root" "$cursor_root" + touch -t 202604170829 "$claude_root/2.1.112" + touch -t 202604180902 "$claude_root/2.1.113" + touch -t 202604181002 "$claude_root/2.1.114" + mkdir -p "$cursor_root/2026.04.08-old" "$cursor_root/2026.04.15-new" + touch -t 202604080000 "$cursor_root/2026.04.08-old" + touch -t 202604150000 "$cursor_root/2026.04.15-new" + + 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/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1|$2"; } +clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/2.1.112|Claude Code old version"* ]] + [[ "$output" == *"/2.1.113|Claude Code old version"* ]] + [[ "$output" != *"/2.1.114|"* ]] + [[ "$output" == *"/2026.04.08-old|Cursor Agent old version"* ]] + [[ "$output" != *"/2026.04.15-new|"* ]] +} + +@test "clean_dev_ai_agents respects MOLE_AI_AGENTS_KEEP and skips missing roots" { + local claude_root="$HOME/.local/share/claude/versions" + mkdir -p "$claude_root" + touch -t 202604170000 "$claude_root/2.1.100" + touch -t 202604180000 "$claude_root/2.1.101" + touch -t 202604190000 "$claude_root/2.1.102" + + 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/dev.sh" +note_activity() { :; } +safe_clean() { echo "$1"; } +MOLE_AI_AGENTS_KEEP=2 clean_dev_ai_agents +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/2.1.100"* ]] + [[ "$output" != *"/2.1.101"* ]] + [[ "$output" != *"/2.1.102"* ]] +} + +@test "clean_dev_jetbrains_logs only targets JetBrains logs" { + 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/dev.sh" +safe_clean() { printf '%s|%s\n' "$1" "$2"; } +clean_dev_jetbrains_logs +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/Library/Logs/JetBrains/*|JetBrains IDE logs"* ]] + [[ "$output" != *"Library/Caches/JetBrains"* ]] +} + +@test "clean_developer_tools includes JetBrains logs but not JetBrains cache sweep" { + 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/dev.sh" +stop_section_spinner() { :; } +note_activity() { :; } +safe_clean() { printf '%s|%s\n' "$1" "$2"; } +clean_tool_cache() { :; } +check_rust_toolchains() { :; } +clean_dev_npm() { :; } +clean_dev_python() { :; } +clean_dev_go() { :; } +clean_dev_mise() { :; } +clean_dev_rust() { :; } +clean_dev_docker() { :; } +clean_dev_cloud() { :; } +clean_dev_nix() { :; } +clean_dev_shell() { :; } +clean_dev_frontend() { :; } +clean_project_caches() { :; } +clean_dev_mobile() { :; } +clean_dev_jvm() { :; } +clean_dev_jetbrains_toolbox() { :; } +clean_dev_ai_agents() { :; } +clean_dev_other_langs() { :; } +clean_dev_cicd() { :; } +clean_dev_database() { :; } +clean_dev_api_tools() { :; } +clean_dev_network() { :; } +clean_dev_misc() { :; } +clean_dev_elixir() { :; } +clean_dev_haskell() { :; } +clean_dev_ocaml() { :; } +clean_xcode_tools() { :; } +clean_code_editors() { :; } +clean_homebrew() { :; } +clean_developer_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/Library/Logs/JetBrains/*|JetBrains IDE logs"* ]] + [[ "$output" != *"Library/Caches/JetBrains"* ]] +} + @test "clean_xcode_simulator_runtime_volumes shows scan progress and skips sizing in-use volumes" { local volumes_root="$HOME/sim-volumes" local cryptex_root="$HOME/sim-cryptex" diff --git a/Resources/mole/tests/file_ops_mole_delete.bats b/Resources/mole/tests/file_ops_mole_delete.bats new file mode 100644 index 0000000..97f0b2d --- /dev/null +++ b/Resources/mole/tests/file_ops_mole_delete.bats @@ -0,0 +1,212 @@ +#!/usr/bin/env bats + +# Tests for mole_delete in lib/core/file_ops.sh. +# Exercises permanent mode (default), trash mode (via MOLE_TEST_TRASH_DIR +# so Finder is never invoked), dry-run, and the deletions log. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-mole-delete.XXXXXX")" + export SANDBOX + export MOLE_DELETE_LOG="$SANDBOX/deletions.log" + export MOLE_TEST_TRASH_DIR="$SANDBOX/Trash" + export MOLE_TEST_NO_AUTH=1 + unset MOLE_DELETE_MODE + unset MOLE_DRY_RUN +} + +teardown() { + rm -rf "$SANDBOX" +} + +prelude() { + cat < "$victim/keep.txt" + + run bash --noprofile --norc < /dev/null || true)" ]] +} + +@test "mole_delete trash mode moves the target instead of rm -rf" { + local victim="$SANDBOX/victim_trash" + mkdir -p "$victim" + printf 'payload' > "$victim/data.txt" + + run bash --noprofile --norc < /dev/null || true)" ]] +} + +@test "mole_delete writes a tab-separated log line per call" { + local victim="$SANDBOX/logged" + : > "$victim" + + run bash --noprofile --norc < "$victim" + + run bash --noprofile --norc < "$victim" + + # Pointing MOLE_TEST_TRASH_DIR at a non-writable parent forces the stub + # trash move to fail, exercising the fallback path. + local blocked="$SANDBOX/blocked/Trash" + mkdir -p "$(dirname "$blocked")" + chmod 0555 "$(dirname "$blocked")" + + run bash --noprofile --norc < "$victim" + + run bash --noprofile --norc < "$victim" + local broken_log_dir="$SANDBOX/no_write/logs" + mkdir -p "$(dirname "$broken_log_dir")" + chmod 0555 "$(dirname "$broken_log_dir")" + + run bash --noprofile --norc < "$SANDBOX/second_victim" +mole_delete "$SANDBOX/second_victim" +EOF + + chmod 0755 "$(dirname "$broken_log_dir")" + + [ "$status" -eq 0 ] + # Warning visible exactly once. + local warn_count + warn_count=$(printf '%s\n' "$output" | grep -c "deletions audit log unavailable" || true) + [ "$warn_count" = "1" ] +} diff --git a/Resources/mole/tests/manage_sudo.bats b/Resources/mole/tests/manage_sudo.bats index 32c77bb..7a73c94 100644 --- a/Resources/mole/tests/manage_sudo.bats +++ b/Resources/mole/tests/manage_sudo.bats @@ -70,3 +70,61 @@ setup() { result=$(bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; source '$PROJECT_ROOT/lib/core/sudo.sh'; echo \$MOLE_SUDO_ESTABLISHED") [[ "$result" == "false" ]] || [[ -z "$result" ]] } + +@test "request_sudo_access clears four lines in clamshell mode when Touch ID hint is shown" { + run bash -c ' + unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + source "'"$PROJECT_ROOT"'/lib/core/common.sh" + source "'"$PROJECT_ROOT"'/lib/core/sudo.sh" + + tty_file="$(mktemp)" + chmod 600 "$tty_file" + + sudo() { + case "$1" in + -n) return 1 ;; + -k) return 0 ;; + *) return 1 ;; + esac + } + tty() { printf "%s\n" "$tty_file"; } + is_clamshell_mode() { return 0; } + check_touchid_support() { return 0; } + _request_password() { return 0; } + safe_clear_lines() { printf "CLEAR:%s\n" "$1"; } + + request_sudo_access "Admin access required" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAR:4"* ]] +} + +@test "request_sudo_access keeps three-line cleanup in clamshell mode without Touch ID" { + run bash -c ' + unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + source "'"$PROJECT_ROOT"'/lib/core/common.sh" + source "'"$PROJECT_ROOT"'/lib/core/sudo.sh" + + tty_file="$(mktemp)" + chmod 600 "$tty_file" + + sudo() { + case "$1" in + -n) return 1 ;; + -k) return 0 ;; + *) return 1 ;; + esac + } + tty() { printf "%s\n" "$tty_file"; } + is_clamshell_mode() { return 0; } + check_touchid_support() { return 1; } + _request_password() { return 0; } + safe_clear_lines() { printf "CLEAR:%s\n" "$1"; } + + request_sudo_access "Admin access required" + ' + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAR:3"* ]] +} diff --git a/Resources/mole/tests/manage_whitelist.bats b/Resources/mole/tests/manage_whitelist.bats index fdaa659..e808310 100644 --- a/Resources/mole/tests/manage_whitelist.bats +++ b/Resources/mole/tests/manage_whitelist.bats @@ -116,6 +116,25 @@ setup() { [ "$status" -eq 1 ] } +@test "whitelist validation accepts special and non-ASCII characters (#749)" { + # Verify the [[:cntrl:]] guard accepts valid macOS path chars and rejects control chars. + run bash --noprofile --norc -c " + accept() { [[ ! \"\$1\" =~ [[:cntrl:]] ]] && echo ACCEPT || echo REJECT; } + accept '/Users/me/Library/Application Support/Foo & Bar' + accept '/Users/me/Library/Caches/com.example+beta' + accept '/Users/me/Library/Caches/com.example(Preview)' + accept '/Users/me/Library/Caches/บริษัท' + accept '/Users/me/Library/Caches/app,[test]' + [[ \$'line\nbreak' =~ [[:cntrl:]] ]] && echo REJECT_NEWLINE || echo FAIL + [[ \$'tab\there' =~ [[:cntrl:]] ]] && echo REJECT_TAB || echo FAIL + " + [ "$status" -eq 0 ] + [[ "$output" == *"ACCEPT"* ]] + [[ "$output" != *"REJECT /Users"* ]] + [[ "$output" == *"REJECT_NEWLINE"* ]] + [[ "$output" == *"REJECT_TAB"* ]] +} + @test "is_path_whitelisted protects parent directories of whitelisted nested paths" { local status if HOME="$HOME" bash --noprofile --norc -c " @@ -130,3 +149,113 @@ setup() { fi [ "$status" -eq 0 ] } + +@test "default whitelist protects tealdeer cache parent for tldr pages" { + local status + if HOME="$HOME" bash --noprofile --norc -c " + source '$PROJECT_ROOT/lib/manage/whitelist.sh' + rm -f \"\$HOME/.config/mole/whitelist\" + load_whitelist + WHITELIST_PATTERNS=(\"\${CURRENT_WHITELIST_PATTERNS[@]}\") + is_path_whitelisted \"\$HOME/Library/Caches/tealdeer\" + "; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} + +# Regression for #724: when a caller concats a glob expansion that ends +# in `/` with a sub-path that starts with `/`, the result contains `//`. +# Without slash collapsing, the comparison with a single-slash whitelist +# entry always fails and Chrome MV3 service workers get wiped. +@test "is_path_whitelisted matches entries against paths containing double slashes (#724)" { + local status + if HOME="$HOME" bash --noprofile --norc -c " + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + WHITELIST_PATTERNS=(\"\$HOME/Library/Application Support/Google/Chrome/Default/Service Worker/CacheStorage\") + is_path_whitelisted \"\$HOME/Library/Application Support/Google/Chrome/Default//Service Worker/CacheStorage\" + "; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} + +# safe_find_delete must consult the user whitelist on every match. Per-caller +# gates were missed in past releases (#710, #724, #738, #744); enforcing it +# inside the iterator makes whitelist protection structural rather than +# case-by-case. Regression for #757. +@test "safe_find_delete respects user whitelist for matched paths (#757)" { + local target_dir="$HOME/safe_find_delete_target" + local protected_file="$target_dir/protected.mat" + local removable_file="$target_dir/removable.mat" + mkdir -p "$target_dir" + : > "$protected_file" + : > "$removable_file" + touch -t 202001010000 "$protected_file" "$removable_file" + + HOME="$HOME" bash --noprofile --norc -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + source '$PROJECT_ROOT/lib/core/file_ops.sh' + WHITELIST_PATTERNS=(\"$target_dir/protected.mat\") + safe_find_delete \"$target_dir\" '*' 1 f + " > /dev/null + + [[ -f "$protected_file" ]] || { + printf 'protected file was unexpectedly removed\n' >&2 + return 1 + } + [[ ! -f "$removable_file" ]] || { + printf 'removable file was unexpectedly kept\n' >&2 + return 1 + } +} + +@test "safe_find_delete respects user whitelist glob patterns (#757)" { + local target_dir="$HOME/idleassetsd_target" + local protected_file="$target_dir/Customer/cbbim-w-prod.mat" + local removable_file="$target_dir/other/extra.dat" + mkdir -p "$target_dir/Customer" "$target_dir/other" + : > "$protected_file" + : > "$removable_file" + touch -t 202001010000 "$protected_file" "$removable_file" + + HOME="$HOME" bash --noprofile --norc -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + source '$PROJECT_ROOT/lib/core/file_ops.sh' + WHITELIST_PATTERNS=(\"$target_dir/Customer/*\") + safe_find_delete \"$target_dir\" '*' 1 f + " > /dev/null + + [[ -f "$protected_file" ]] || { + printf 'glob-whitelisted file was unexpectedly removed\n' >&2 + return 1 + } + [[ ! -f "$removable_file" ]] || { + printf 'non-whitelisted file was unexpectedly kept\n' >&2 + return 1 + } +} + +@test "is_path_whitelisted collapses slashes in whitelist entries too (#724)" { + local status + if HOME="$HOME" bash --noprofile --norc -c " + source '$PROJECT_ROOT/lib/core/base.sh' + source '$PROJECT_ROOT/lib/core/app_protection.sh' + WHITELIST_PATTERNS=(\"\$HOME//Library//Caches//chrome-sw\") + is_path_whitelisted \"\$HOME/Library/Caches/chrome-sw\" + "; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} diff --git a/Resources/mole/tests/optimize.bats b/Resources/mole/tests/optimize.bats index 00a7b11..eedf510 100644 --- a/Resources/mole/tests/optimize.bats +++ b/Resources/mole/tests/optimize.bats @@ -116,6 +116,65 @@ EOF [[ "$output" == *"mDNSResponder restarted"* ]] } +@test "opt_quarantine_cleanup reports clean when no database" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +opt_quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] +} + +@test "opt_quarantine_cleanup reports entries in dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Stub whitelist check to always allow. +should_protect_path() { return 1; } +# Create a mock quarantine database with entries. +mkdir -p "$HOME/Library/Preferences" +local_db="$HOME/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2" +sqlite3 "$local_db" "CREATE TABLE IF NOT EXISTS LSQuarantineEvent (id TEXT);" +sqlite3 "$local_db" "INSERT INTO LSQuarantineEvent VALUES ('test1');" +sqlite3 "$local_db" "INSERT INTO LSQuarantineEvent VALUES ('test2');" +opt_quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Quarantine history cleared"* ]] + [[ "$output" == *"2 entries"* ]] +} + +@test "opt_quarantine_cleanup skips when sqlite3 unavailable" { + 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/optimize/tasks.sh" +export PATH="/nonexistent" +opt_quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"sqlite3 unavailable"* ]] +} + +@test "execute_optimization dispatches quarantine_cleanup" { + 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/optimize/tasks.sh" +opt_quarantine_cleanup() { echo "quarantine"; } +execute_optimization quarantine_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"quarantine"* ]] +} + @test "opt_sqlite_vacuum reports sqlite3 unavailable" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc <<'EOF' set -euo pipefail @@ -141,6 +200,60 @@ EOF [[ "$output" == *"Font cache cleared"* ]] } +@test "optimize does not auto-fix Gatekeeper anymore" { + run grep -n "spctl --master-enable\\|SECURITY_FIXES+=([\"']gatekeeper|" "$PROJECT_ROOT/bin/optimize.sh" + + [ "$status" -eq 1 ] +} + +@test "opt_font_cache_rebuild skips when Firefox helpers are 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/optimize/tasks.sh" +pgrep() { + case "$*" in + *"Firefox|org\\.mozilla\\.firefox|firefox .*contentproc|firefox .*plugin-container|firefox .*crashreporter"*) + return 0 + ;; + *) + return 1 + ;; + esac +} +export -f pgrep +opt_font_cache_rebuild +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Font cache rebuild skipped · Firefox still running"* ]] +} + +@test "browser_family_is_running does not treat generic renderer helpers as Zen Browser" { + 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/optimize/tasks.sh" +pgrep() { + case "$*" in + *"renderer|gpu"*) + return 0 + ;; + *) + return 1 + ;; + esac +} +export -f pgrep +if browser_family_is_running "Zen Browser"; then + echo "MATCHED" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"MATCHED"* ]] +} + @test "opt_dock_refresh clears cache files" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' set -euo pipefail @@ -157,6 +270,62 @@ EOF [[ "$output" == *"Dock refreshed"* ]] } +@test "opt_prevent_network_dsstore dry-run reports enabled" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +defaults() { + case "$1" in + read) return 1 ;; + write) return 0 ;; + esac +} +opt_prevent_network_dsstore +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *".DS_Store prevention enabled"* ]] +} + +@test "opt_prevent_network_dsstore idempotent when already set" { + 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/optimize/tasks.sh" +defaults() { + if [[ "$1" == "read" ]]; then + echo "1" + return 0 + fi + return 0 +} +opt_prevent_network_dsstore +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already enabled"* ]] +} + +@test "prevent_network_dsstore is optional in optimize health json" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/check/health_json.sh" +json="$(generate_health_json | tr '\n' ' ')" + +if printf '%s\n' "$json" | grep -q '"action": "prevent_network_dsstore".*"safe": false'; then + echo "optional" +fi +if printf '%s\n' "$json" | grep -q 'persistent Finder preference'; then + echo "described" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"optional"* ]] + [[ "$output" == *"described"* ]] +} + @test "execute_optimization dispatches actions" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -199,3 +368,225 @@ EOF [[ "$output" == *"lsregister not found"* ]] [[ "$output" == *"survived"* ]] } + +@test "opt_launch_agents_cleanup reports healthy when no directory" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +opt_launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Launch Agents all healthy"* ]] +} + +@test "opt_launch_agents_cleanup detects broken agents" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Create mock LaunchAgents with a broken binary reference. +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.broken.plist" <<'PLIST' + + + + + Label + com.test.broken + ProgramArguments + + /nonexistent/binary + + + +PLIST +safe_remove() { return 0; } +opt_launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cleaned 1 broken Launch Agent"* ]] +} + +@test "opt_launch_agents_cleanup skips healthy agents" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Clean up any leftover plists from previous tests. +rm -f "$HOME/Library/LaunchAgents"/*.plist 2>/dev/null || true +# Create mock LaunchAgent pointing to an existing binary. +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.healthy.plist" < + + + + Label + com.test.healthy + ProgramArguments + + /bin/bash + + + +PLIST +opt_launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Launch Agents all healthy"* ]] +} + +@test "execute_optimization dispatches launch_agents_cleanup" { + 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/optimize/tasks.sh" +opt_launch_agents_cleanup() { echo "launch_agents"; } +execute_optimization launch_agents_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"launch_agents"* ]] +} + +@test "opt_periodic_maintenance reports current when log is fresh" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +tmplog="$(mktemp /tmp/mole-test-daily.XXXXXX)" +touch "$tmplog" +MOLE_PERIODIC_LOG="$tmplog" opt_periodic_maintenance +rm -f "$tmplog" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already current"* ]] +} + +@test "opt_periodic_maintenance triggers in dry-run when log is stale" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +tmplog="$(mktemp /tmp/mole-test-daily.XXXXXX)" +touch -t "$(date -v-10d +%Y%m%d%H%M.%S)" "$tmplog" +MOLE_PERIODIC_LOG="$tmplog" opt_periodic_maintenance +rm -f "$tmplog" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance triggered"* ]] +} + +@test "opt_periodic_maintenance triggers in dry-run when log is missing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +MOLE_PERIODIC_LOG="/tmp/mole-test-nonexistent-daily.out" opt_periodic_maintenance +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance triggered"* ]] +} + +@test "execute_optimization dispatches periodic_maintenance" { + 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/optimize/tasks.sh" +opt_periodic_maintenance() { echo "periodic"; } +execute_optimization periodic_maintenance +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"periodic"* ]] +} + +@test "opt_notification_cleanup reports healthy when db is small" { + local tmp_dir nc_db_dir + tmp_dir=$(mktemp -d) + nc_db_dir="$tmp_dir/com.apple.notificationcenter/db2" + mkdir -p "$nc_db_dir" + # Create a 1KB placeholder (below 50MB threshold) + dd if=/dev/zero of="$nc_db_dir/db" bs=1024 count=1 2>/dev/null + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null + + run env HOME="$tmp_dir" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null + + run env HOME="$tmp_dir" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc </dev/null +} + +@test "sort: PURGE_CATEGORY_FULL_PATHS_ARRAY[0] is the largest artifact after size-descending sort" { + # alpha = small (~5 KB), beta = large (~200 KB). + # Alphabetical discovery order puts alpha first; size order puts beta first. + # After the sort, PURGE_CATEGORY_FULL_PATHS_ARRAY[0] must be beta's path. + mkdir -p "$HOME/www/alpha/node_modules" + mkdir -p "$HOME/www/beta/node_modules" + echo '{}' > "$HOME/www/alpha/package.json" + echo '{}' > "$HOME/www/beta/package.json" + dd if=/dev/zero of="$HOME/www/alpha/node_modules/data" bs=1024 count=5 2>/dev/null + dd if=/dev/zero of="$HOME/www/beta/node_modules/data" bs=1024 count=200 2>/dev/null + + local capture_file script_file + capture_file=$(mktemp "$HOME/sort_capture.XXXXXX") + script_file=$(mktemp "$HOME/sort_script.XXXXXX.sh") + + cat > "$script_file" << SCRIPT +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.cache/mole" +export XDG_CACHE_HOME="$HOME/.cache" +export TERM="dumb" +PURGE_SEARCH_PATHS=("$HOME/www") + +# Override the interactive selector: dump the full-path array to the capture +# file then cancel (return 1) so nothing is deleted. +select_purge_categories() { + printf '%s\n' "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}" > "$capture_file" + PURGE_SELECTION_RESULT="" + return 1 +} + +clean_project_artifacts 2>/dev/null || true +SCRIPT + + _run_in_pty "$script_file" + rm -f "$script_file" + + if [[ ! -s "$capture_file" ]]; then + rm -f "$capture_file" + fail "capture file is empty – select_purge_categories was never called (stdin was not a tty?)" + fi + + local first_path + first_path=$(head -1 "$capture_file") + rm -f "$capture_file" + + # With the bug item_display_paths is not sorted, so alpha (alphabetically + # first) appears at index 0 → [[ ... == *beta* ]] fails. + # After the fix beta (largest) is at index 0 → test passes. + [[ "$first_path" == *"beta"* ]] +} + +@test "sort: PURGE_CATEGORY_FULL_PATHS_ARRAY and PURGE_CATEGORY_SIZES indices are consistent" { + mkdir -p "$HOME/www/alpha/node_modules" + mkdir -p "$HOME/www/beta/node_modules" + echo '{}' > "$HOME/www/alpha/package.json" + echo '{}' > "$HOME/www/beta/package.json" + dd if=/dev/zero of="$HOME/www/alpha/node_modules/data" bs=1024 count=5 2>/dev/null + dd if=/dev/zero of="$HOME/www/beta/node_modules/data" bs=1024 count=200 2>/dev/null + + local capture_file script_file + capture_file=$(mktemp "$HOME/sort_capture.XXXXXX") + script_file=$(mktemp "$HOME/sort_script.XXXXXX.sh") + + cat > "$script_file" << SCRIPT +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +mkdir -p "$HOME/.cache/mole" +export XDG_CACHE_HOME="$HOME/.cache" +export TERM="dumb" +PURGE_SEARCH_PATHS=("$HOME/www") + +select_purge_categories() { + echo "SIZES=\${PURGE_CATEGORY_SIZES:-}" > "$capture_file" + local i=0 + for p in "\${PURGE_CATEGORY_FULL_PATHS_ARRAY[@]}"; do + echo "PATH[\$i]=\$p" >> "$capture_file" + i=\$((i + 1)) + done + PURGE_SELECTION_RESULT="" + return 1 +} + +clean_project_artifacts 2>/dev/null || true +SCRIPT + + _run_in_pty "$script_file" + rm -f "$script_file" + + if [[ ! -s "$capture_file" ]]; then + rm -f "$capture_file" + fail "capture file is empty – select_purge_categories was never called (stdin was not a tty?)" + fi + + local sizes_csv + sizes_csv=$(grep '^SIZES=' "$capture_file" | cut -d= -f2-) + IFS=',' read -r -a sizes <<< "$sizes_csv" + + local path0 path1 + path0=$(grep '^PATH\[0\]=' "$capture_file" | head -1 | cut -d= -f2-) + path1=$(grep '^PATH\[1\]=' "$capture_file" | head -1 | cut -d= -f2-) + rm -f "$capture_file" + + # PURGE_CATEGORY_SIZES must be sorted descending (largest first). + [ "${sizes[0]}" -gt "${sizes[1]}" ] + + # Index 0 → largest artifact → beta's path. + # With the bug path0 = alpha (discovery order) → [[ ... == *beta* ]] fails. + [[ "$path0" == *"beta"* ]] + + # Index 1 → smaller artifact → alpha's path. + [[ "$path1" == *"alpha"* ]] +} diff --git a/Resources/mole/tests/purge_config_paths.bats b/Resources/mole/tests/purge_config_paths.bats index 9fe106b..fc6c3cd 100644 --- a/Resources/mole/tests/purge_config_paths.bats +++ b/Resources/mole/tests/purge_config_paths.bats @@ -108,8 +108,52 @@ EOF echo "# Just a comment" > "$config_file" run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${PURGE_SEARCH_PATHS[*]}\"" - + [ "$status" -eq 0 ] - + [[ "$output" == *"$HOME/Projects"* ]] } + +@test "load_purge_config deduplicates case variants on case-insensitive FS" { + # Create a real directory so resolve_path_case can cd into it + mkdir -p "$HOME/code" + + local config_file="$HOME/.config/mole/purge_paths" + cat > "$config_file" << EOF +$HOME/code +$HOME/Code +EOF + + run env HOME="$HOME" bash -c "source '$PROJECT_ROOT/lib/clean/project.sh'; echo \"\${#PURGE_SEARCH_PATHS[@]}\"" + + [ "$status" -eq 0 ] + + # On case-insensitive FS (macOS default) both resolve to the same path, + # so count should be 1. On case-sensitive FS, Code doesn't exist, so + # resolve_path_case returns it unchanged — count may be 2 which is correct + # since they really are different directories. + if [[ -d "$HOME/Code" && "$(cd "$HOME/Code" && pwd -P)" == "$(cd "$HOME/code" && pwd -P)" ]]; then + [ "$output" = "1" ] + fi +} + +@test "discover_project_dirs deduplicates default Code vs actual code" { + # Simulate: $HOME/code exists (actual dir), $HOME/Code is in defaults + mkdir -p "$HOME/code/myproject" + touch "$HOME/code/myproject/package.json" + + # No config file — triggers discovery + run env HOME="$HOME" bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + discover_project_dirs + " + + [ "$status" -eq 0 ] + + # On case-insensitive FS, $HOME/code should appear only once + if [[ -d "$HOME/Code" && "$(cd "$HOME/Code" && pwd -P)" == "$(cd "$HOME/code" && pwd -P)" ]]; then + local count + count=$(echo "$output" | grep -c "$HOME/code" || true) + [ "$count" -le 1 ] + fi +} diff --git a/Resources/mole/tests/regression.bats b/Resources/mole/tests/regression.bats index 3e10baa..5d0b973 100644 --- a/Resources/mole/tests/regression.bats +++ b/Resources/mole/tests/regression.bats @@ -187,3 +187,69 @@ EOF ") [[ "$result" == "loaded" ]] } + +@test "normalize_paths_for_cleanup handles large nested batches without hanging" { + local limit_ms="${MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS:-4000}" + + run env PROJECT_ROOT="$PROJECT_ROOT" LIMIT_MS="$limit_ms" bash --noprofile --norc <<'EOF' +set -euo pipefail + +python - <<'PY' +from pathlib import Path +import os +project_root = Path(os.environ["PROJECT_ROOT"]) +text = (project_root / "bin/clean.sh").read_text() +start = text.index("normalize_paths_for_cleanup() {") +depth = 0 +end = None +for i in range(start, len(text)): + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break +Path("/tmp/normalize_paths_for_cleanup.sh").write_text(text[start:end] + "\n") +PY + +source /tmp/normalize_paths_for_cleanup.sh + +paths=( + "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" + "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches/" +) +for i in $(seq 1 6000); do + paths+=("$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches/item-$i") + paths+=("$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches/item-$i") +done + +start_ns=$(python - <<'PY' +import time +print(time.time_ns()) +PY +) +normalized=() +while IFS= read -r -d '' line; do + normalized+=("$line") +done < <(normalize_paths_for_cleanup "${paths[@]}") +end_ns=$(python - <<'PY' +import time +print(time.time_ns()) +PY +) +elapsed_ms=$(( (end_ns - start_ns) / 1000000 )) + +printf 'COUNT=%s ELAPSED_MS=%s\n' "${#normalized[@]}" "$elapsed_ms" +printf '%s\n' "${normalized[@]}" + +[[ ${#normalized[@]} -eq 2 ]] +[[ "${normalized[0]}" == "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches" || "${normalized[1]}" == "$HOME/Library/Containers/com.microsoft.Excel/Data/Library/Caches" ]] +[[ "${normalized[0]}" == "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" || "${normalized[1]}" == "$HOME/Library/Containers/com.microsoft.Word/Data/Library/Caches" ]] +(( elapsed_ms < LIMIT_MS )) +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=2"* ]] +} diff --git a/Resources/mole/tests/scripts.bats b/Resources/mole/tests/scripts.bats index 8dc0edc..9e3de97 100644 --- a/Resources/mole/tests/scripts.bats +++ b/Resources/mole/tests/scripts.bats @@ -131,3 +131,72 @@ EOF run bash -c "grep -q 'MOLE_VERSION=\"dev\"' '$PROJECT_ROOT/install.sh'" [ "$status" -eq 0 ] } + +@test "update_homebrew_tap_formula.sh updates all release artifacts" { + local formula_file="$HOME/mole.rb" + cat > "$formula_file" <<'EOF' +class Mole < Formula + desc "Mole" + homepage "https://github.com/tw93/Mole" + url "https://github.com/tw93/Mole/archive/refs/tags/V1.32.0.tar.gz" + sha256 "old-source-sha" + + on_arm do + url "https://github.com/tw93/Mole/releases/download/V1.32.0/binaries-darwin-arm64.tar.gz" + sha256 "old-arm-sha" + end + + on_intel do + url "https://github.com/tw93/Mole/releases/download/V1.32.0/binaries-darwin-amd64.tar.gz" + sha256 "old-amd-sha" + end +end +EOF + + run "$PROJECT_ROOT/scripts/update_homebrew_tap_formula.sh" \ + --formula "$formula_file" \ + --tag "V1.33.0" \ + --source-sha "new-source-sha" \ + --arm-sha "new-arm-sha" \ + --amd-sha "new-amd-sha" + [ "$status" -eq 0 ] + + run grep -q 'url "https://github.com/tw93/Mole/archive/refs/tags/V1.33.0.tar.gz"' "$formula_file" + [ "$status" -eq 0 ] + run grep -q 'sha256 "new-source-sha"' "$formula_file" + [ "$status" -eq 0 ] + run grep -q 'url "https://github.com/tw93/Mole/releases/download/V1.33.0/binaries-darwin-arm64.tar.gz"' "$formula_file" + [ "$status" -eq 0 ] + run grep -q 'sha256 "new-arm-sha"' "$formula_file" + [ "$status" -eq 0 ] + run grep -q 'url "https://github.com/tw93/Mole/releases/download/V1.33.0/binaries-darwin-amd64.tar.gz"' "$formula_file" + [ "$status" -eq 0 ] + run grep -q 'sha256 "new-amd-sha"' "$formula_file" + [ "$status" -eq 0 ] +} + +@test "update_homebrew_tap_formula.sh fails when expected sections are missing" { + local formula_file="$HOME/mole-missing-intel.rb" + cat > "$formula_file" <<'EOF' +class Mole < Formula + desc "Mole" + homepage "https://github.com/tw93/Mole" + url "https://github.com/tw93/Mole/archive/refs/tags/V1.32.0.tar.gz" + sha256 "old-source-sha" + + on_arm do + url "https://github.com/tw93/Mole/releases/download/V1.32.0/binaries-darwin-arm64.tar.gz" + sha256 "old-arm-sha" + end +end +EOF + + run "$PROJECT_ROOT/scripts/update_homebrew_tap_formula.sh" \ + --formula "$formula_file" \ + --tag "V1.33.0" \ + --source-sha "new-source-sha" \ + --arm-sha "new-arm-sha" \ + --amd-sha "new-amd-sha" + [ "$status" -ne 0 ] + [[ "$output" == *"Failed to update formula"* ]] +} diff --git a/Resources/mole/tests/test_match_apps_helper.sh b/Resources/mole/tests/test_match_apps_helper.sh new file mode 100644 index 0000000..85cc7e0 --- /dev/null +++ b/Resources/mole/tests/test_match_apps_helper.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Test helper: load match_apps_by_name directly from bin/uninstall.sh for unit testing. +# Requires apps_data and selected_apps arrays to be defined before sourcing. + +# Declared by caller before sourcing this file +: "${apps_data?apps_data array must be set before sourcing this file}" + +_test_helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_repo_root="$(cd "${_test_helper_dir}/.." && pwd)" +_uninstall_script="${_repo_root}/bin/uninstall.sh" + +if [[ ! -f "${_uninstall_script}" ]]; then + echo "Error: unable to find ${_uninstall_script}" >&2 + return 1 +fi + +# Suppress color codes in test output +YELLOW="" +NC="" + +eval "$( + sed -n '/^match_apps_by_name()[[:space:]]*{/,/^}$/p' "${_uninstall_script}" +)" + +unset _test_helper_dir +unset _repo_root +unset _uninstall_script diff --git a/Resources/mole/tests/uninstall.bats b/Resources/mole/tests/uninstall.bats index e6bfe27..f0dfd2c 100644 --- a/Resources/mole/tests/uninstall.bats +++ b/Resources/mole/tests/uninstall.bats @@ -60,6 +60,62 @@ EOF [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] } +@test "find_app_system_files discovers bundle-id-prefixed LaunchDaemons" { + fakebin="$HOME/fakebin" + mkdir -p "$fakebin" + + # The new dot-anchored alternation invokes find with two -name patterns: + # "${bundle_id}.plist" and "${bundle_id}.*.plist". Match on either form. + cat > "$fakebin/find" <<'SCRIPT' +#!/bin/sh +args="$*" + +case "$args" in + *"/Library/LaunchDaemons"*'-name com.west2online.ClashXPro.*.plist'*) + printf '%s\0' "/Library/LaunchDaemons/com.west2online.ClashXPro.ProxyConfigHelper.plist" + ;; +esac +SCRIPT + chmod +x "$fakebin/find" + + run env HOME="$HOME" PATH="$fakebin:$PATH" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +result=$(find_app_system_files "com.west2online.ClashXPro" "ClashX Pro") +[[ "$result" == *"/Library/LaunchDaemons/com.west2online.ClashXPro.ProxyConfigHelper.plist"* ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +# The previous "${bundle_id}*.plist" glob over-matched: bundle "com.foo" +# would harvest "com.foobar.plist" and "com.foobaz.plist" from unrelated +# vendors. The dot-anchored alternation only matches at the dot boundary. +@test "find_app_system_files does not over-match sibling-vendor LaunchDaemons" { + # Use a real /Library/LaunchDaemons-like fixture by isolating PATH so the + # function falls back to the system find binary, then assert only the + # expected files are surfaced. + fakebase="$HOME/fakebase" + mkdir -p "$fakebase/Library/LaunchAgents" "$fakebase/Library/LaunchDaemons" + : > "$fakebase/Library/LaunchDaemons/com.foo.plist" # exact match - keep + : > "$fakebase/Library/LaunchDaemons/com.foo.helper.plist" # dotted - keep + : > "$fakebase/Library/LaunchDaemons/com.foobar.plist" # sibling - reject + : > "$fakebase/Library/LaunchDaemons/com.foobaz.helper.plist" # sibling - reject + + # Verify the find pattern itself, since the production find is hard-coded + # to /Library/* paths. This mirrors what app_protection.sh emits. + run bash --noprofile --norc -c " + cd '$fakebase/Library/LaunchDaemons' + find . -maxdepth 1 \( -name 'com.foo.plist' -o -name 'com.foo.*.plist' \) | sort + " + [ "$status" -eq 0 ] + [[ "$output" == *"com.foo.plist"* ]] + [[ "$output" == *"com.foo.helper.plist"* ]] + [[ "$output" != *"com.foobar.plist"* ]] + [[ "$output" != *"com.foobaz.helper.plist"* ]] +} + @test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -81,14 +137,18 @@ cat > "$app_dir/Contents/Info.plist" << 'PLIST' PLIST touch "$diag_dir/Foo.crash" +touch "$diag_dir/Foo.diag" touch "$diag_dir/Foo_2026-01-01-120000_host.ips" touch "$diag_dir/Foobar.crash" +touch "$diag_dir/Foobar.diag" touch "$diag_dir/Foobar_2026-01-01-120001_host.ips" result=$(get_diagnostic_report_paths_for_app "$app_dir" "Foo" "$diag_dir") [[ "$result" == *"Foo.crash"* ]] || exit 1 +[[ "$result" == *"Foo.diag"* ]] || exit 1 [[ "$result" == *"Foo_2026-01-01-120000_host.ips"* ]] || exit 1 [[ "$result" != *"Foobar.crash"* ]] || exit 1 +[[ "$result" != *"Foobar.diag"* ]] || exit 1 [[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1 EOF @@ -158,6 +218,98 @@ EOF [ "$status" -eq 0 ] } +@test "batch_uninstall_applications warns when removed app declares Local Network usage" { + 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/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } + +app_bundle="$HOME/Applications/NetworkApp.app" +mkdir -p "$app_bundle/Contents" +cat > "$app_bundle/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.NetworkApp + NSLocalNetworkUsageDescription + Discover devices on the local network + + +PLIST + +selected_apps=() +selected_apps+=("0|$app_bundle|NetworkApp|com.example.NetworkApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Local Network permissions"* ]] + [[ "$output" == *"NetworkApp"* ]] + [[ "$output" == *"Recovery mode"* ]] +} + +@test "batch_uninstall_applications skips Local Network warning for regular apps" { + 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/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +remove_apps_from_dock() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } + +app_bundle="$HOME/Applications/PlainApp.app" +mkdir -p "$app_bundle/Contents" +cat > "$app_bundle/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.PlainApp + + +PLIST + +selected_apps=() +selected_apps+=("0|$app_bundle|PlainApp|com.example.PlainApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Local Network permissions"* ]] +} + @test "batch_uninstall_applications preview shows full related file list" { mkdir -p "$HOME/Applications/TestApp.app" mkdir -p "$HOME/Library/Application Support/TestApp" @@ -215,6 +367,91 @@ EOF [[ "$output" != *"more files"* ]] } +@test "uninstall_persist_cache_file heals non-writable destination" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +# Source only the helper by evaluating its function definition. +eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +src="$HOME/cache.src" +dst="$HOME/cache.dst" +printf 'fresh-data\n' > "$src" +printf 'stale-data\n' > "$dst" +chmod 0444 "$dst" +[[ ! -w "$dst" ]] || { echo "precondition: dst should be read-only" >&2; exit 1; } + +uninstall_persist_cache_file "$src" "$dst" + +[[ ! -e "$src" ]] || { echo "src should be gone" >&2; exit 1; } +[[ -f "$dst" ]] || { echo "dst missing" >&2; exit 1; } +grep -q 'fresh-data' "$dst" || { echo "dst not updated"; exit 1; } +EOF + + [ "$status" -eq 0 ] +} + +@test "uninstall_persist_cache_file does not hang when mv would prompt (stdin closed)" { + # Regression for #722: BSD mv without -f prompts on non-writable dst and + # blocks reading stdin. The helper must close stdin and use -f. + # + # The hang detector uses a marker file rather than a PID-based watchdog: + # PIDs get recycled quickly on CI and a stale `kill -9 $pid` can succeed + # against an unrelated process, producing a false HANG. The marker + # approach only cares about whether the helper itself completed. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +src="$HOME/snap.src" +dst="$HOME/snap.dst" +done_marker="$HOME/snap.done" +printf 'x\n' > "$src" +printf 'y\n' > "$dst" +chmod 0444 "$dst" + +( + printf 'n\nn\nn\n' | uninstall_persist_cache_file "$src" "$dst" + : > "$done_marker" +) & +bgpid=$! + +# Poll for completion marker for up to ~5s. +for _ in $(seq 1 50); do + [[ -e "$done_marker" ]] && break + sleep 0.1 +done + +if [[ ! -e "$done_marker" ]]; then + kill -9 "$bgpid" 2>/dev/null || true + echo HANG +fi +wait "$bgpid" 2>/dev/null || true +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"HANG"* ]] +} + +@test "uninstall_persist_cache_file is a no-op when source is empty" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +eval "$(sed -n '/^uninstall_persist_cache_file()/,/^}$/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +src="$HOME/empty.src" +dst="$HOME/keep.dst" +: > "$src" +printf 'untouched\n' > "$dst" + +uninstall_persist_cache_file "$src" "$dst" + +[[ ! -e "$src" ]] || exit 1 +grep -q 'untouched' "$dst" || exit 1 +EOF + + [ "$status" -eq 0 ] +} + @test "safe_remove can remove a simple directory" { mkdir -p "$HOME/test_dir" touch "$HOME/test_dir/file.txt" @@ -260,6 +497,44 @@ EOF [ "$status" -eq 0 ] } +@test "uninstall_resolve_display_name keeps versioned app names when metadata is generic" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +function run_with_timeout() { + shift + "$@" +} + +function mdls() { + echo "Xcode" +} + +function plutil() { + if [[ "$3" == *"Info.plist" ]]; then + echo "Xcode" + return 0 + fi + return 1 +} + +MOLE_UNINSTALL_USER_LC_ALL="" +MOLE_UNINSTALL_USER_LANG="" + +eval "$(sed -n '/^uninstall_resolve_display_name()/,/^}/p' "$PROJECT_ROOT/bin/uninstall.sh")" + +app_path="$HOME/Applications/Xcode 16.4.app" +mkdir -p "$app_path/Contents" +touch "$app_path/Contents/Info.plist" + +result=$(uninstall_resolve_display_name "$app_path" "Xcode 16.4.app") +[[ "$result" == "Xcode 16.4" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + @test "decode_file_list handles empty input" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -360,9 +635,9 @@ EOF mkdir -p "$HOME/.local/bin" touch "$HOME/.local/bin/mole" touch "$HOME/.local/bin/mo" - mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF' set -euo pipefail start_inline_spinner() { :; } stop_inline_spinner() { :; } @@ -402,15 +677,16 @@ EOF [ ! -f "$HOME/.local/bin/mo" ] [ ! -d "$HOME/.config/mole" ] [ ! -d "$HOME/.cache/mole" ] + [ ! -d "$HOME/Library/Logs/mole" ] } @test "remove_mole dry-run keeps manual binaries and caches" { mkdir -p "$HOME/.local/bin" touch "$HOME/.local/bin/mole" touch "$HOME/.local/bin/mo" - mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF' set -euo pipefail start_inline_spinner() { :; } stop_inline_spinner() { :; } @@ -424,4 +700,436 @@ EOF [ -f "$HOME/.local/bin/mo" ] [ -d "$HOME/.config/mole" ] [ -d "$HOME/.cache/mole" ] + [ -d "$HOME/Library/Logs/mole" ] +} + +@test "remove_mole test mode ignores PATH installs outside test HOME" { + mkdir -p "$HOME/.local/bin" "$HOME/.config/mole" "$HOME/.cache/mole" "$HOME/Library/Logs/mole" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + + fake_global_bin="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-remove-path.XXXXXX")" + touch "$fake_global_bin/mole" + touch "$fake_global_bin/mo" + cat > "$fake_global_bin/brew" <<'EOF' +#!/bin/bash +exit 0 +EOF + chmod +x "$fake_global_bin/brew" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_global_bin:/usr/bin:/bin" MOLE_TEST_MODE=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +export -f start_inline_spinner stop_inline_spinner +printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +EOF + + rm -rf "$fake_global_bin" + + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.local/bin/mole"* ]] + [[ "$output" == *"$HOME/.local/bin/mo"* ]] + [[ "$output" != *"$fake_global_bin/mole"* ]] + [[ "$output" != *"$fake_global_bin/mo"* ]] + [[ "$output" != *"brew uninstall --force mole"* ]] +} +@test "match_apps_by_name finds exact match case-insensitively" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" + "1001|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000" + "1002|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "testapp" +echo "count=${#selected_apps[@]}" +echo "match=${selected_apps[0]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] + [[ "$output" == *"TestApp"* ]] +} + +@test "match_apps_by_name finds by directory name" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1002|$HOME/Applications/TestApp.app|Test Application|com.example.TestApp|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "TestApp" +echo "count=${#selected_apps[@]}" +echo "match=${selected_apps[0]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] + [[ "$output" == *"Test Application"* ]] +} + +@test "match_apps_by_name warns on no match" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "nonexistent" +echo "count=${#selected_apps[@]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Warning: No application found matching 'nonexistent'"* ]] + [[ "$output" == *"count=0"* ]] +} + +@test "match_apps_by_name handles multiple app names" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" + "1001|$HOME/Applications/TestApp2.app|TestApp2|com.example.TestApp2|500 MB|1000001|512000" + "1002|$HOME/Applications/TestApp3.app|TestApp3|com.example.TestApp3|300 MB|1000002|307200" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "testapp2" "testapp3" +echo "count=${#selected_apps[@]}" +for app in "${selected_apps[@]}"; do + IFS='|' read -r _ _ name _ _ _ _ <<< "$app" + echo "matched=$name" +done +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=2"* ]] + [[ "$output" == *"matched=TestApp2"* ]] + [[ "$output" == *"matched=TestApp3"* ]] +} + +@test "match_apps_by_name falls back to substring match" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" + "1001|$HOME/Applications/SlackDesktop.app|Slack|com.tinyspeck.slackmacgap|200 MB|1000001|204800" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "test" +echo "count=${#selected_apps[@]}" +for app in "${selected_apps[@]}"; do + IFS='|' read -r _ _ name _ _ _ _ <<< "$app" + echo "matched=$name" +done +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] + [[ "$output" == *"matched=TestApp"* ]] +} + +@test "match_apps_by_name does not duplicate when same name given twice" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +selected_apps=() +apps_data=( + "1000|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|1.2 GB|1000000|1258291" +) +source "$PROJECT_ROOT/tests/test_match_apps_helper.sh" +match_apps_by_name "testapp" "testapp" +echo "count=${#selected_apps[@]}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=1"* ]] +} + +@test "main clears pending input before app selection after scan (#726)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'INNER' +set -euo pipefail + +trace_file="$HOME/uninstall-trace.log" +app_cache_file="$HOME/apps-cache.txt" +touch "$app_cache_file" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$app_cache_file"; } +load_applications() { + printf 'load\n' >> "$trace_file" + return 0 +} +drain_pending_input() { + printf 'drain\n' >> "$trace_file" +} +select_apps_for_uninstall() { + printf 'select\n' >> "$trace_file" + return 1 +} + +eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +main + +expected=$(printf 'load\ndrain\nselect\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + [ "$status" -eq 0 ] +} + + +# --------------------------------------------------------------------------- +# #723: Trash routing default and --permanent flag +# --------------------------------------------------------------------------- + +@test "uninstall main sets MOLE_DELETE_MODE=trash by default" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-723-trash.XXXXXX")" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { return 0; } +drain_pending_input() { :; } +select_apps_for_uninstall() { + printf 'delete_mode=%s\n' "${MOLE_DELETE_MODE:-unset}" + return 1 +} + +eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == *"delete_mode=trash"* ]] +} + +@test "uninstall main sets MOLE_DELETE_MODE=permanent with --permanent flag" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-723-perm.XXXXXX")" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { return 0; } +drain_pending_input() { :; } +select_apps_for_uninstall() { + printf 'delete_mode=%s\n' "${MOLE_DELETE_MODE:-unset}" + return 1 +} + +eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --permanent +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == *"delete_mode=permanent"* ]] +} + +# --------------------------------------------------------------------------- +# --list: read-only inventory of installable app names (PR #755 scope) +# --------------------------------------------------------------------------- + +@test "uninstall --list prints table with NAME, BUNDLE ID, UNINSTALL NAME, SIZE" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-text.XXXXXX")" + # Format matches load_applications: epoch|app_path|app_name|bundle_id|size|last_used|size_kb + cat > "$apps_cache" <<'CACHE' +1700000000|/Applications/Slack.app|Slack|com.tinyspeck.slackmacgap|180MB|Today|184320 +1700000000|/Applications/Zoom.app|Zoom|us.zoom.xos|140MB|Yesterday|143360 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + done < "$1" +} +# Stub Homebrew so test stays hermetic and brew detection never fires. +is_homebrew_available() { return 1; } +get_brew_cask_name() { return 1; } +# Stubbed because the production helper lives earlier in bin/uninstall.sh +# and our sed slice only pulls list-related helpers + main(). +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +# Force text mode by simulating a TTY for stdout via /dev/tty redirect not +# available in bats; instead pipe through a wrapper that fakes -t 1. Simplest: +# call the function directly so [[ -t 1 ]] uses bash's stdout (the bats pipe). +# We accept the function emits JSON when piped; assert against JSON shape too. +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + # Bats pipes stdout, so output is JSON. Assert both apps and uninstall_name. + [[ "$output" == *'"name": "Slack"'* ]] + [[ "$output" == *'"name": "Zoom"'* ]] + [[ "$output" == *'"uninstall_name": "Slack"'* ]] + [[ "$output" == *'"bundle_id": "com.tinyspeck.slackmacgap"'* ]] + [[ "$output" == *'"source": "App"'* ]] +} + +@test "uninstall --list emits JSON array when stdout is piped" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-json.XXXXXX")" + cat > "$apps_cache" <<'CACHE' +1700000000|/Applications/Slack.app|Slack|com.tinyspeck.slackmacgap|180MB|Today|184320 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + done < "$1" +} +is_homebrew_available() { return 1; } +get_brew_cask_name() { return 1; } +# Stubbed because the production helper lives earlier in bin/uninstall.sh +# and our sed slice only pulls list-related helpers + main(). +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + # Output should start with '[' and end with ']' to be a valid JSON array. + [[ "${output:0:1}" == "[" ]] + [[ "${output: -1}" == "]" ]] + # Round-trip via python to confirm it parses as JSON. + if command -v python3 > /dev/null; then + echo "$output" | python3 -c 'import sys, json; d=json.load(sys.stdin); assert isinstance(d, list) and len(d)==1 and d[0]["name"]=="Slack"' + fi +} + +@test "uninstall --list with empty scan returns empty JSON array" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-empty.XXXXXX")" + # Non-empty file so load_applications doesn't bail early on size check. + echo "" > "$apps_cache" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + return 0 +} +is_homebrew_available() { return 1; } +get_brew_cask_name() { return 1; } +# Stubbed because the production helper lives earlier in bin/uninstall.sh +# and our sed slice only pulls list-related helpers + main(). +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == "[]" ]] +} + +@test "uninstall --list flags brew-managed apps with cask uninstall_name" { + local apps_cache + apps_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-list-brew.XXXXXX")" + cat > "$apps_cache" <<'CACHE' +1700000000|/Applications/Visual Studio Code.app|Visual Studio Code|com.microsoft.VSCode|420MB|Today|430080 +CACHE + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 \ + APPS_CACHE_FILE="$apps_cache" bash --noprofile --norc <<'INNER' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +scan_applications() { printf '%s\n' "$APPS_CACHE_FILE"; } +load_applications() { + apps_data=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + done < "$1" +} +# Force brew-managed result. +is_homebrew_available() { return 0; } +get_brew_cask_name() { printf '%s' "visual-studio-code"; return 0; } +uninstall_normalize_size_display() { local s="${1:-}"; [[ -z "$s" || "$s" == "0" || "$s" == "Unknown" ]] && echo "N/A" || echo "$s"; } + +eval "$(sed -n '/^uninstall_list_json_escape()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" +main --list +INNER + + rm -f "$apps_cache" + [ "$status" -eq 0 ] + [[ "$output" == *'"uninstall_name": "visual-studio-code"'* ]] + [[ "$output" == *'"source": "Homebrew"'* ]] } diff --git a/Resources/mole/tests/uninstall_naming_variants.bats b/Resources/mole/tests/uninstall_naming_variants.bats index eee48f9..be084be 100644 --- a/Resources/mole/tests/uninstall_naming_variants.bats +++ b/Resources/mole/tests/uninstall_naming_variants.bats @@ -109,6 +109,24 @@ setup() { [[ "$result" =~ .local/share/firefox ]] } +@test "find_app_files detects bundle-id-derived extension leftovers" { + mkdir -p "$HOME/Library/Application Support/FileProvider/com.tencent.xinWeChat.WeChatFileProviderExtension" + mkdir -p "$HOME/Library/Application Scripts/com.tencent.xinWeChat.WeChatMacShare" + mkdir -p "$HOME/Library/Application Scripts/5A4RE8SF68.com.tencent.xinWeChat" + mkdir -p "$HOME/Library/Containers/com.tencent.xinWeChat.WeChatFileProviderExtension" + mkdir -p "$HOME/Library/Group Containers/5A4RE8SF68.com.tencent.xinWeChat" + mkdir -p "$HOME/Library/Containers/com.tencent.otherapp.Helper" + + result=$(find_app_files "com.tencent.xinWeChat" "WeChat") + + [[ "$result" =~ Library/Application\ Support/FileProvider/com.tencent.xinWeChat.WeChatFileProviderExtension ]] + [[ "$result" =~ Library/Application\ Scripts/com.tencent.xinWeChat.WeChatMacShare ]] + [[ "$result" =~ Library/Application\ Scripts/5A4RE8SF68.com.tencent.xinWeChat ]] + [[ "$result" =~ Library/Containers/com.tencent.xinWeChat.WeChatFileProviderExtension ]] + [[ "$result" =~ Library/Group\ Containers/5A4RE8SF68.com.tencent.xinWeChat ]] + [[ ! "$result" =~ Library/Containers/com.tencent.otherapp.Helper ]] +} + @test "find_app_files does not match empty app name" { mkdir -p "$HOME/Library/Application Support/test" diff --git a/Resources/mole/tests/update.bats b/Resources/mole/tests/update.bats index 19954eb..3e8e45b 100644 --- a/Resources/mole/tests/update.bats +++ b/Resources/mole/tests/update.bats @@ -604,7 +604,7 @@ MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 source "$PROJECT_ROOT/mole" brew() { if [[ "${1:-}" == "outdated" ]]; then - echo "tw93/tap/mole (1.29.0) < 1.30.0" + echo "tw93/tap/mole (1.29.0) < 1.31.0" return 0 fi if [[ "${1:-}" == "info" ]]; then @@ -619,7 +619,7 @@ get_homebrew_latest_version EOF [ "$status" -eq 0 ] - [[ "$output" == "1.30.0" ]] + [[ "$output" == "1.31.0" ]] } @test "get_homebrew_latest_version parses brew info fallback with heading prefix" { diff --git a/Resources/mole/tests/user_file_ops.bats b/Resources/mole/tests/user_file_ops.bats index 086a125..fbad735 100644 --- a/Resources/mole/tests/user_file_ops.bats +++ b/Resources/mole/tests/user_file_ops.bats @@ -76,6 +76,61 @@ setup() { [ -d "$result" ] } +@test "get_mole_temp_root uses writable TMPDIR when available" { + local writable_tmp="$HOME/custom-tmp" + mkdir -p "$writable_tmp" + + result=$(env HOME="$HOME" TMPDIR="$writable_tmp" bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_mole_temp_root") + [ "$result" = "$writable_tmp" ] +} + +@test "get_mole_temp_root falls back to user cache when TMPDIR is not writable" { + local blocked_tmp="$HOME/blocked-tmp" + mkdir -p "$blocked_tmp" + chmod 500 "$blocked_tmp" + + result=$(env HOME="$HOME" TMPDIR="$blocked_tmp" bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_mole_temp_root") + [ "$result" = "$HOME/.cache/mole/tmp" ] + [ -d "$HOME/.cache/mole/tmp" ] +} + +@test "get_mole_temp_root caches the first resolved directory" { + local first_tmp="$HOME/first-tmp" + local second_tmp="$HOME/second-tmp" + mkdir -p "$first_tmp" "$second_tmp" + + result=$(env HOME="$HOME" TMPDIR="$first_tmp" bash -c " + source '$PROJECT_ROOT/lib/core/base.sh' + ensure_mole_temp_root + first=\$MOLE_RESOLVED_TMPDIR + export TMPDIR='$second_tmp' + ensure_mole_temp_root + second=\$MOLE_RESOLVED_TMPDIR + printf '%s|%s\n' \"\$first\" \"\$second\" + ") + + [ "$result" = "$first_tmp|$first_tmp" ] +} + +@test "get_mole_temp_root falls back to /tmp when TMPDIR and invoking home are unavailable" { + result=$(env HOME="$HOME" TMPDIR="/var/empty" bash -c " + source '$PROJECT_ROOT/lib/core/base.sh' + get_invoking_home() { echo '/var/empty'; } + get_mole_temp_root + ") + + [ "$result" = "/tmp" ] +} + +@test "common.sh exports resolved TMPDIR for runtime callers" { + local blocked_tmp="$HOME/common-blocked-tmp" + mkdir -p "$blocked_tmp" + chmod 500 "$blocked_tmp" + + result=$(env HOME="$HOME" TMPDIR="$blocked_tmp" bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; printf '%s\n' \"\$TMPDIR\"") + [ "$result" = "$HOME/.cache/mole/tmp" ] +} + @test "get_user_home returns home for valid user" { current_user="${USER:-$(whoami)}" result=$(bash -c "source '$PROJECT_ROOT/lib/core/base.sh'; get_user_home '$current_user'")