diff --git a/.mole-cli-version b/.mole-cli-version index 034552a..3987c47 100644 --- a/.mole-cli-version +++ b/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.43.1 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..3987c47 100644 --- a/MoleUI/.mole-cli-version +++ b/MoleUI/.mole-cli-version @@ -1 +1 @@ -1.30.0 +1.43.1 diff --git a/Resources/mole/.claude/agents/bash32-portability-reviewer.md b/Resources/mole/.claude/agents/bash32-portability-reviewer.md new file mode 100644 index 0000000..e7e2f80 --- /dev/null +++ b/Resources/mole/.claude/agents/bash32-portability-reviewer.md @@ -0,0 +1,76 @@ +--- +name: bash32-portability-reviewer +description: Scans Mole shell diffs for bash 3.2 / macOS-default-bash landmines that have shipped bugs before — nounset on empty arrays, `[[ -n ]] && cmd` short-circuit, heredoc `read -n1` byte theft, `run_with_timeout` exec bypassing function mocks. Use after any change under `bin/`, `lib/`, `install.sh`, or `tests/*.bats`. +tools: Read, Grep, Glob, Bash +--- + +You are a bash portability reviewer for Mole. Your only job is to catch the specific macOS-default-bash (3.2) and `set -euo pipefail` pitfalls that CLAUDE.md documents as having already cost this project a release-day bug. You read code, you never write it. + +## The four documented landmines + +These come from the "Shell and release pitfalls" section of `CLAUDE.md`. Each one shipped or nearly shipped a real bug. Re-read that section before reporting. + +### Landmine 1 — nounset on empty arrays + +**Pattern**: `"${arr[@]}"` expansion when `arr=()` is possibly empty, under `set -u` (or any file that sources `set -euo pipefail`). +**Symptom**: `unbound variable` on macOS bash 3.2. Real example: `DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=()` in `lib/manage/whitelist.sh`. +**Fix**: guard the expansion with `[[ ${#arr[@]} -gt 0 ]] && ...`. + +How to find: grep the diff for `"${[A-Z_]*\[@\]}"` and check whether the surrounding array is initialized as possibly empty. + +### Landmine 2 — `[[ -n "$var" ]] && cmd` returns 1 when var is empty + +**Pattern**: `[[ -n "$x" ]] && something` used as an inline guard. The `&&` short-circuit means the whole expression returns 1 when `$x` is empty, even though the intent was "skip silently". Under `set -e`, or inside a `{...} > file ||` redirect, this silently breaks the success path. +**Symptom**: silent failure of a wrapper / redirect block. Real example: `write_install_channel_metadata` in `install.sh` — stable channel always tripped a warning because the inline short-circuit returned 1. +**Fix**: switch to `if [[ -n "$x" ]]; then cmd; fi` whenever the conditional sits inside an exit-code-sensitive block. + +How to find: grep the diff for `\[\[ -n .* \]\] &&` or `\[\[ -z .* \]\] &&`. Inspect whether the line is the last command of a `{ ... }` block, a function body, a subshell, or under a `||` clause. + +### Landmine 3 — bats heredoc steals bytes from `read -n1` + +**Pattern**: a function under test calls `read -r -s -n1` (typical for confirmation prompts in `lib/core/ui.sh`), and the test runs it via `bash <<'EOF' ... EOF`. The `-n1` read consumes the next byte from the heredoc source, corrupting the next command (e.g., `echo` becomes `cho`, exit 127). +**Symptom**: bats test with garbled stderr like `cho: command not found`. +**Fix**: redirect the function's stdin from `/dev/null` inside the test, e.g. `the_function < /dev/null`. + +How to find: in `tests/*.bats`, look for `bash <<` heredocs that exercise functions reading single bytes. Confirm a `< /dev/null` redirect on the function call. + +### Landmine 4 — `run_with_timeout` execs the binary, bypassing bash function mocks + +**Pattern**: a test overrides a command via a bash function (e.g., `osascript() { ... }`), then exercises code that wraps the command in `run_with_timeout`. `gtimeout`/`timeout` exec the real PATH binary, so the function shadow is invisible. +**Symptom**: a test that "should" hit the mock reaches the real binary, or fails because the mocked behavior never ran. +**Fix**: use a PATH stub directory: write a real executable script at `$TMP/bin/osascript`, prepend `$TMP/bin` to `PATH` for the test, do not use function shadowing. + +How to find: in `tests/*.bats`, grep for function-style command shadows (`osascript()`, `sudo()`, `launchctl()`) on functions that are reached through `run_with_timeout` in the production code path. + +## Out of scope + +- General style or readability nits. +- `unwrap` / `panic` / `expect` in any context. +- Suggestions to refactor unrelated code. +- Anything outside `bin/`, `lib/`, `install.sh`, `mole`, `tests/*.bats`, `scripts/*.sh`. + +## How to review + +1. `git diff` against the branch base. Restrict attention to in-scope files. +2. For each in-scope file, run the four landmine searches above. +3. For every match, read enough surrounding context to decide whether the landmine actually fires (the pattern is necessary but not sufficient; e.g., a `[[ -n ]] && cmd` outside an exit-code-sensitive block is fine). +4. Report only confirmed or strongly-suspected fires. Do not report the pattern as a finding if the surrounding code already handles it. + +## Output format + +``` +LANDMINE : : + Pattern: + Why it fires here: + Fix: +``` + +End with one of: +- `VERDICT: no landmines found` +- `VERDICT: landmines, fix before merge` + +If a pattern matched but you could not verify the surrounding context, say so as `UNVERIFIED: :` instead of inventing a finding. + +Keep it terse. No preamble. + +**Zero-findings case**: if you have no landmines and no UNVERIFIED items, emit only the single line `VERDICT: no landmines found`. Do not write a justification paragraph. Do not summarize what you looked at. The absence of findings is the message. diff --git a/Resources/mole/.claude/agents/safety-reviewer.md b/Resources/mole/.claude/agents/safety-reviewer.md new file mode 100644 index 0000000..41ffa4e --- /dev/null +++ b/Resources/mole/.claude/agents/safety-reviewer.md @@ -0,0 +1,70 @@ +--- +name: safety-reviewer +description: Audits Mole shell/Go changes against this repo's destructive-action safety contract — file deletion, app protection, sudo/osascript/launchctl guards, and operation logging. Use before merging anything that touches lib/clean/**, lib/uninstall/**, lib/manage/**, bin/clean.sh, bin/purge.sh, bin/uninstall.sh, lib/core/file_ops.sh, lib/core/app_protection*.sh. +tools: Read, Grep, Glob, Bash +--- + +You are a safety reviewer for Mole, a macOS cleanup tool. Your job is to catch destructive-action regressions before they ship. You read code, you never write it. + +## What this repo treats as P0 + +A change is P0-unsafe if any of these are true: + +1. **Raw delete**: introduces `rm -rf`, `rm -r`, `find ... -delete`, `unlink`, or `trash` invocations outside `lib/core/file_ops.sh`. Every removal in user-visible flows must route through `mole_delete` from `lib/core/file_ops.sh` so Trash routing, oplog, dry-run, and path protection stay consistent. +2. **Unprotected path**: adds a cleanup, purge, or uninstall path that does not first pass through `should_protect_path` (or a domain helper like `is_app_protected`, `is_bundle_protected`, or the `app_protection*` lookups in `lib/core/`). +3. **Protected-path write**: writes into `/System`, `/Library/Apple`, anything under `com.apple.*`, or any path the app_protection data marks as protected. +4. **Unguarded privileged call**: adds a new direct use of `sudo`, `osascript`, `launchctl`, `defaults write`, `pkill`, `killall`, `mdutil`, or `dscl` that is not gated by `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH`, and not fully mocked in tests. +5. **Bypassed dry-run**: destructive code path that does not check `MOLE_DRY_RUN` or call the helper that does. +6. **Lost oplog**: change that drops or routes around `record_operation` / `MO_NO_OPLOG` semantics for a user-visible delete. + +## What this repo treats as P1 + +- AI-tool cache cleanup that is not conservative. Claude Code, opencode, Copilot CLI, Zed, Warp, Ghostty, Codex caches may contain config, credentials, or session state. Removing the whole cache dir is unsafe — list specific subpaths. +- New bundle-ID matcher in `lib/core/app_protection_data.sh` without a corresponding test case in `tests/uninstall_*.bats` or `tests/bundle_resolver.bats`. +- Changes to ESC timeout in `lib/core/ui.sh` (CLAUDE.md says: do not change without explicit ask). +- Edits to any of the three intentionally-divergent implementations of `start_section` / `end_section` / `note_activity` in `lib/core/base.sh`, `bin/clean.sh`, `bin/purge.sh` without reading the cross-reference comment in `lib/core/base.sh` first. Source order decides which one wins; the wording, color, and dry-run export semantics differ on purpose. + +## What this repo treats as P2 + +- A delete-adjacent code path missing a Bats test under `tests/`. +- Dry-run flow not verified with `MOLE_DRY_RUN=1 ./mole ` or `MOLE_TEST_NO_AUTH=1 ./mole --dry-run`. +- Sudo-bearing path not verified with `MOLE_TEST_NO_AUTH=1`. + +## How to review + +1. `git diff` against the branch base. Identify files under `lib/clean/`, `lib/uninstall/`, `lib/manage/`, `bin/clean.sh`, `bin/purge.sh`, `bin/uninstall.sh`, `lib/core/file_ops.sh`, `lib/core/app_protection*.sh`, `cmd/analyze/`. These are the in-scope surfaces. +2. For each in-scope file, grep the diff for: `rm -rf`, `rm -r`, `find.*-delete`, `unlink`, bare `sudo`, `osascript`, `launchctl`, `defaults write`, `pkill`, `killall`, new path literals. +3. For every match, verify the safety contract above. If a contract isn't met, that's a finding. +4. Read the surrounding 10-20 lines of the file (not just the diff) to confirm the dry-run/protection guard isn't already present upstream in the function. +5. Cross-check that the listed test commands in CLAUDE.md "Hotspot Ownership" for the touched area actually exist and cover the change. If the hotspot says "run `bats tests/clean_app_caches.bats`" and the diff is in `lib/clean/user.sh`, that test file should have been added to or exercised. + +## What NOT to flag + +- `unwrap` / `panic` / `expect` in test files (`*_test.go`, `tests/*.bats`), doctests, or string literals. CLAUDE.md flags this as a credibility-loss pattern. +- Style nits unrelated to safety. +- "Could be refactored" suggestions. Stick to the contract. +- Files outside the in-scope list, unless the diff actually changes deletion or privilege behavior there. + +## Output format + +Report findings in this exact shape, ordered by severity: + +``` +P0: : + Why unsafe: + Fix: + +P1: : — ... + +P2: : — ... +``` + +End with a one-line verdict: +- `VERDICT: safe to merge` if no P0/P1 findings. +- `VERDICT: changes required` if any P0 or P1. + +If you cannot tell from the diff whether a guard is present (e.g., the function under edit calls a helper you didn't read), say so explicitly: `UNVERIFIED: `. Do not assume the safety guard exists. + +Keep findings terse. No preamble. No closing summary. Maintainers read the verdict and the P0 lines first. + +**Zero-findings case**: if you have no P0, P1, P2, or UNVERIFIED items, emit only the single line `VERDICT: safe to merge`. Do not write a justification paragraph. Do not summarize what you looked at. The absence of findings is the message. diff --git a/Resources/mole/.claude/hooks/format-on-edit.sh b/Resources/mole/.claude/hooks/format-on-edit.sh new file mode 100755 index 0000000..3642839 --- /dev/null +++ b/Resources/mole/.claude/hooks/format-on-edit.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Format the just-edited file with the project's configured formatters. +# Invoked by .claude/settings.json as a PostToolUse hook on Edit/MultiEdit/Write. +# Stdin is the Claude Code hook payload (JSON). Failures must not block the edit. + +set -u + +# Extract file path from hook payload. Tolerate missing jq. +if ! command -v jq > /dev/null 2>&1; then + exit 0 +fi + +FILE=$(jq -r '.tool_input.file_path // empty' 2> /dev/null || true) +[[ -z "$FILE" ]] && exit 0 +[[ ! -f "$FILE" ]] && exit 0 + +case "$FILE" in + *.sh | */mole) + if command -v shfmt > /dev/null 2>&1; then + shfmt -i 4 -ci -sr -w "$FILE" > /dev/null 2>&1 || true + fi + ;; + *.go) + if command -v goimports > /dev/null 2>&1; then + goimports -w -local github.com/tw93/Mole "$FILE" > /dev/null 2>&1 || true + elif command -v gofmt > /dev/null 2>&1; then + gofmt -w "$FILE" > /dev/null 2>&1 || true + fi + ;; +esac + +exit 0 diff --git a/Resources/mole/.claude/settings.json b/Resources/mole/.claude/settings.json new file mode 100644 index 0000000..2585317 --- /dev/null +++ b/Resources/mole/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/format-on-edit.sh" + } + ] + } + ] + } +} diff --git a/Resources/mole/.claude/skills/release-notes/SKILL.md b/Resources/mole/.claude/skills/release-notes/SKILL.md new file mode 100644 index 0000000..3f34959 --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/SKILL.md @@ -0,0 +1,101 @@ +--- +name: release-notes +description: Publish curated release notes for a Mole `V` tag. Encodes the V1.37.0+ bilingual format, the gh release edit (not create) flow, sponsors graphql query, and the six-reaction set. User-only because publishing is a side effect that touches the public release page. +disable-model-invocation: true +--- + +# Mole release notes + +This skill drives the curated-notes step that runs **after** `release.yml` has finished. The workflow creates the GitHub Release with assets but with `generate_release_notes: false`, so notes must be added in a follow-up `gh release edit` (never `gh release create` — the release already exists, and `create` will conflict). + +## Inputs to gather + +Before drafting, confirm: + +1. **Version**. Capital `V`, e.g. `V1.38.0`. Lowercase `v` does not trigger the workflow and may indicate a botched tag. +2. **CodeName + emoji**. Ask the user. The title format is `V `. +3. **Release commit range**. `git log ..V --oneline` gives the raw material. +4. **User-visible behavior changes**. Scan the full commit message bodies (not just subjects) for any narrowed detection, removed feature, or "controlled regression" wording. Examples that have shipped before: the V1.40 VPN narrowing (37a446c9) silently stopped detecting split-tunnel third-party VPNs, the Bluetooth reset removal (357ee057) dropped a flow some users depended on. These belong in notes even when not bug-fix-shaped, because users will hit them in production and won't know what changed. +5. **Sponsors**. Run `scripts/sponsors.sh` from this skill dir. +6. **Contributors in this range**. `git log ..V --pretty='%an' | sort -u`. Exclude `tw93` and bots. +7. **Verify release exists**. `gh release view V --repo tw93/Mole --json id,name` should return non-empty. If it doesn't, the workflow hasn't finished — wait, don't `gh release create`. + +## Pre-flight (cross-check against CLAUDE.md) + +These should already be true if the tag was pushed correctly. Confirm before publishing notes: + +- `grep '^VERSION=' mole` matches ``. +- `SECURITY_AUDIT.md` opening line reflects the new version and date. +- `./scripts/check.sh --format` clean. +- `MOLE_TEST_NO_AUTH=1 MOLE_TEST_JOBS=2 BATS_FORMATTER=tap ./scripts/test.sh` exits 0. +- `go test ./cmd/...` and `make build` pass. + +If any fail, stop. The notes can wait; a bad release tag cannot. + +## Format + +Strictly follow V1.37.0+ shape. Compare against a recent release if unsure: +`gh release view V1.37.0 --repo tw93/Mole --json body --jq .body`. + +Structure: + +``` +## What's new in V + +1. ****: . +2. ... + +## V 更新内容 + +1. **<中文 headline>**:<一句中文说明>。 +2. ... + +## Thanks 💖 + +Sponsors: <@handle1> <@handle2> ... +Contributors: <@handle1> <@handle2> ... + +> Mole · macOS cleanup · https://github.com/tw93/Mole +``` + +### Format rules (all are documented bugs that have shipped before) + +- **No em dash anywhere**. Use commas, periods, colons, semicolons, or parentheses. +- **No emoji except the version emoji** in the two section headers and `💖` in the Thanks header. +- **No inline PR refs, no inline `@handle` thanks**. PRs and people belong in the closing Thanks block only. +- **English block first, 中文 block second**. Same numbered order in both blocks. Same number of items. +- **Order items by user-perceived impact, not commit chronology**. Headline change first; internal safety hardening, performance, and bug fixes follow. +- **Verify every command mentioned in the notes actually exists in HEAD**. CLAUDE.md cites `mo check / mo doctor` as a case where a removed command nearly shipped as a "feature". +- **Pick icons that match the action, not the category**. Broom (🧹) implies "safe to delete" — never use it on rows like iOS Backups, Xcode Archives, Old Downloads where that's a false promise. Eyes (👀) is the safe "look here" choice. + +## Publish + +Once the user approves the draft: + +```bash +gh release edit V --repo tw93/Mole \ + --title "V " \ + --notes-file +``` + +**Never** `gh release create` — it conflicts with the release the workflow already made. + +Then add the six reactions: `bash scripts/post-reactions.sh V`. + +## After publish + +- `gh release view V --repo tw93/Mole --web` (open in browser) so the user can eyeball it. +- Remind the user: Homebrew tap + Homebrew core PR are workflow-driven and should already be in flight; do not re-run them manually unless the workflow log shows a failure. + +## When NOT to act + +This skill is user-invocable only. It must not run unprompted: + +- If the user mentions release notes in passing, draft only; do not call `gh release edit`. +- If `gh release view` shows the release does not exist yet, wait. The workflow takes about 2 minutes for an Mn.m.0. +- If the user has not given an explicit "publish" / "提交" signal, stop after the draft. + +## Helper scripts + +- `scripts/sponsors.sh` — fetches the 30 most recent sponsors via `gh api graphql`. Uses the minimal query that works on a token without `read:user` scope. +- `scripts/post-reactions.sh ` — adds the six reactions (`+1`, `laugh`, `hooray`, `heart`, `rocket`, `eyes`) to the release. diff --git a/Resources/mole/.claude/skills/release-notes/scripts/post-reactions.sh b/Resources/mole/.claude/skills/release-notes/scripts/post-reactions.sh new file mode 100755 index 0000000..25c76ba --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/scripts/post-reactions.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Add the standard six reactions (+1, laugh, hooray, heart, rocket, eyes) to a +# tw93/Mole release. Usage: post-reactions.sh V + +set -euo pipefail + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "Usage: $0 V" >&2 + exit 1 +fi + +if [[ "$TAG" != V* ]]; then + echo "Tag must start with capital V (release.yml ignores lowercase v): $TAG" >&2 + exit 1 +fi + +if ! command -v gh > /dev/null 2>&1; then + echo "gh CLI is required" >&2 + exit 1 +fi + +RELEASE_ID=$(gh api "repos/tw93/Mole/releases/tags/$TAG" --jq '.id') +if [[ -z "$RELEASE_ID" ]]; then + echo "Release not found for tag: $TAG" >&2 + exit 1 +fi + +for r in +1 laugh hooray heart rocket eyes; do + gh api "repos/tw93/Mole/releases/$RELEASE_ID/reactions" \ + -X POST -f content="$r" --silent +done + +echo "Posted 6 reactions to $TAG (release id $RELEASE_ID)" diff --git a/Resources/mole/.claude/skills/release-notes/scripts/sponsors.sh b/Resources/mole/.claude/skills/release-notes/scripts/sponsors.sh new file mode 100755 index 0000000..98d4442 --- /dev/null +++ b/Resources/mole/.claude/skills/release-notes/scripts/sponsors.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Fetch the 30 most recent sponsors of tw93 via gh api graphql. +# Uses the minimal query that works on a token without `read:user` scope. +# Adding createdAt or privacyLevel requires `read:user`. + +set -euo pipefail + +if ! command -v gh > /dev/null 2>&1; then + echo "gh CLI is required" >&2 + exit 1 +fi + +gh api graphql -f query='{ + user(login:"tw93"){ + sponsorshipsAsMaintainer(first:30, orderBy:{field:CREATED_AT, direction:DESC}){ + nodes{ + sponsorEntity{ + ... on User{login} + ... on Organization{login} + } + } + } + } +}' --jq '.data.user.sponsorshipsAsMaintainer.nodes[].sponsorEntity.login' diff --git a/Resources/mole/.cursor/rules/mole-test-safety.mdc b/Resources/mole/.cursor/rules/mole-test-safety.mdc new file mode 100644 index 0000000..db996fd --- /dev/null +++ b/Resources/mole/.cursor/rules/mole-test-safety.mdc @@ -0,0 +1,11 @@ +--- +description: Mole test safety and verification defaults +alwaysApply: true +--- + +# Mole Test Safety + +- Default test and verification commands must run with `MOLE_TEST_NO_AUTH=1` or through `scripts/test.sh`, which sets it automatically. +- Tests must never trigger real `sudo`, Touch ID, password prompts, AppleScript permission dialogs, or system service changes. +- Any new direct use of `sudo`, `osascript`, or `launchctl` must either be guarded by `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH` or be fully mocked in the test path. +- Prefer focused regression tests first, then run full no-auth verification before release-sensitive changes. 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/FUNDING.yml b/Resources/mole/.github/FUNDING.yml index 494d63e..db73b16 100644 --- a/Resources/mole/.github/FUNDING.yml +++ b/Resources/mole/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: ['tw93'] -custom: ['https://miaoyan.app/cats.html?name=Mole'] +custom: ['https://cats.tw93.fun?name=Mole'] 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/ISSUE_TEMPLATE/mac_app_bug.yml b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_bug.yml new file mode 100644 index 0000000..93795d3 --- /dev/null +++ b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_bug.yml @@ -0,0 +1,101 @@ +name: Mac App Bug +description: Report a bug in the paid Mole Mac app. +title: "[Mac App Bug] " +labels: ["mac-app", "bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a Mole Mac app bug. + + The Mole Mac app is a paid native app. Its source code is not public. This repository hosts the open-source Mole CLI and public feedback for the Mac app. + + Do not include license keys, order numbers, private paths, or logs with personal data. For payment, refund, license, or security issues, email hi@mole.fit instead. + - type: input + id: app_version + attributes: + label: Mole Mac app version + description: Open Mole, choose About Mole, and paste the version shown there. + placeholder: "1.0.0" + validations: + required: true + - type: input + id: macos_version + attributes: + label: macOS version + placeholder: "macOS 15.5" + validations: + required: true + - type: input + id: mac_model + attributes: + label: Mac model + placeholder: "MacBook Pro M3 Pro, Mac mini M2, etc." + validations: + required: false + - type: dropdown + id: area + attributes: + label: Affected area + options: + - Full Disk Access / permissions + - Clean + - Uninstall + - Optimize + - Analyze + - Status + - Activation / license + - Download / update + - Other + validations: + required: true + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the bug clearly. Screenshots are welcome if they do not reveal private data. + placeholder: "Mole did..." + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open Mole + 2. Go to ... + 3. Click ... + 4. See ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + placeholder: "I expected Mole to..." + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + placeholder: "Instead, Mole..." + validations: + required: true + - type: textarea + id: attachments + attributes: + label: Screenshots or safe logs + description: Attach screenshots or paste logs only after removing private paths, license keys, and order details. + placeholder: "Drag screenshots here, or paste safe logs." + validations: + required: false + - type: checkboxes + id: checklist + attributes: + label: Before submitting + options: + - label: I am using the latest Mole Mac app build available from https://mole.fit. + required: true + - label: I did not include license keys, order numbers, or private data. + required: true diff --git a/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_feature.yml b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_feature.yml new file mode 100644 index 0000000..a975096 --- /dev/null +++ b/Resources/mole/.github/ISSUE_TEMPLATE/mac_app_feature.yml @@ -0,0 +1,67 @@ +name: Mac App Feature +description: Suggest an improvement for the paid Mole Mac app. +title: "[Mac App Feature] " +labels: ["mac-app", "enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement for the Mole Mac app. + + The Mole Mac app is a paid native app. Its source code is not public. This repository hosts the open-source Mole CLI and public feedback for the Mac app. + + Please keep requests concrete: what workflow is hard today, what you expected Mole to do, and why it matters. + - type: dropdown + id: area + attributes: + label: Area + options: + - Clean + - Uninstall + - Optimize + - Analyze + - Status + - Activation / license + - Onboarding / permissions + - Website / help + - Other + validations: + required: true + - type: textarea + id: problem + attributes: + label: What problem would this solve? + placeholder: "When I use Mole, I want to..." + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What would you like Mole to do? + placeholder: "A good solution could be..." + validations: + required: true + - type: textarea + id: current_workaround + attributes: + label: Current workaround + placeholder: "Today I work around it by..." + validations: + required: false + - type: textarea + id: examples + attributes: + label: Examples, screenshots, or references + description: Add screenshots, links, or examples if they help explain the request. + placeholder: "Optional context." + validations: + required: false + - type: checkboxes + id: checklist + attributes: + label: Before submitting + options: + - label: This request is for the Mole Mac app, not the Mole CLI. + required: true + - label: I did not include license keys, order numbers, or private data. + required: true 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/bundle_audit.yml b/Resources/mole/.github/workflows/bundle_audit.yml new file mode 100644 index 0000000..9bfeb9a --- /dev/null +++ b/Resources/mole/.github/workflows/bundle_audit.yml @@ -0,0 +1,47 @@ +name: Bundle Drift Audit + +# Monthly check that mole's protected bundle lists still cover every system +# app shipped in the latest macOS image. Opens an issue if /System/Applications +# contains a bundle ID not matched by the protection patterns. + +on: + schedule: + - cron: '0 12 1 * *' # 1st of each month, 12:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + audit: + name: Bundle drift + runs-on: macos-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + + - name: Run drift audit + id: drift + continue-on-error: true + run: | + ./scripts/audit_bundle_drift.sh | tee /tmp/audit.txt + echo "rc=$?" >> "$GITHUB_OUTPUT" + + - name: Open issue on drift + if: ${{ steps.drift.outcome != 'success' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + MACOS=$(sw_vers -productVersion) + TITLE="Bundle drift on macOS $MACOS" + # Avoid duplicate issues for the same macOS version. + EXISTING=$(gh issue list --label bundle-drift --search "$TITLE in:title" --json number --jq '.[0].number' || true) + if [[ -n "$EXISTING" ]]; then + echo "Updating existing issue #$EXISTING" + gh issue comment "$EXISTING" --body "$(cat /tmp/audit.txt)" + else + gh issue create \ + --title "$TITLE" \ + --label bundle-drift \ + --body "$(cat /tmp/audit.txt)" + fi diff --git a/Resources/mole/.github/workflows/check.yml b/Resources/mole/.github/workflows/check.yml index 6f7b0e0..0e0ff22 100644 --- a/Resources/mole/.github/workflows/check.yml +++ b/Resources/mole/.github/workflows/check.yml @@ -6,7 +6,7 @@ on: pull_request: permissions: - contents: write + contents: read jobs: format: @@ -16,12 +16,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - with: - ref: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref) || github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} - name: Cache Homebrew - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 with: path: | ~/Library/Caches/Homebrew @@ -33,48 +30,52 @@ jobs: ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck golangci-lint + run: brew install shfmt shellcheck - 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 - - name: Format all code + - name: Check shell formatting (shfmt -d) run: | - export PATH=$(go env GOPATH)/bin:$PATH - ./scripts/check.sh --format + SHELL_FILES=$(find . -type f \( -name "*.sh" -o -name "mole" \) \ + -not -path "./.git/*" \ + -not -path "*/node_modules/*" \ + -not -path "*/tests/tmp-*/*" \ + -not -path "*/.*") + if ! echo "$SHELL_FILES" | xargs shfmt -i 4 -ci -sr -d; then + echo "::error::Shell files are not formatted. Run './scripts/check.sh --format' locally and commit." + exit 1 + fi - - name: Commit formatting changes - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + - name: Check Go formatting (goimports -l) run: | - git config user.name "Tw93" - git config user.email "tw93@qq.com" - if [[ -n $(git status --porcelain) ]]; then - git add . - git commit -m "chore: auto format code" - git push - echo "✓ Formatting changes committed" - else - echo "✓ No formatting changes needed" + export PATH=$(go env GOPATH)/bin:$PATH + UNFORMATTED=$(goimports -l -local github.com/tw93/mole ./cmd ./internal) + if [[ -n "$UNFORMATTED" ]]; then + echo "::error::Go files are not formatted:" + echo "$UNFORMATTED" + echo "Run './scripts/check.sh --format' locally and commit." + exit 1 fi quality: name: Check runs-on: macos-latest needs: format + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - with: - 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 @@ -86,12 +87,17 @@ jobs: ${{ runner.os }}-brew-quality-v2- - name: Install tools - run: brew install shfmt shellcheck golangci-lint + run: brew install shfmt shellcheck - 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 golangci-lint + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - 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..40edcf6 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,14 +26,15 @@ 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: | make ${{ matrix.target }} ls -l bin/ + ./scripts/check_release_minos.sh - name: Package binaries for Homebrew run: | @@ -48,7 +49,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 +59,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 +74,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 +108,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,32 +121,109 @@ 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 - - name: Update Homebrew formula (Official Core) - uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3.6 - with: - formula-name: mole - homebrew-tap: Homebrew/homebrew-core - tag-name: ${{ steps.tag_version.outputs.tag }} - commit-message: | - mole ${{ steps.tag_version.outputs.version }} + 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}') - Automated release via GitHub Actions + 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) env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} + GH_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} + TAG: ${{ steps.tag_version.outputs.tag }} + VERSION: ${{ steps.tag_version.outputs.version }} + # mislav/bump-homebrew-formula-action@v4.1 fatals on the GitHub tarball + # HEAD's HTTP 303 redirect (upstream issue #340). Until that lands, do + # the bump by hand: sync our fork, edit Formula/m/mole.rb, push, and + # open the PR via gh. Keeps the rest of the release flow on the same + # rails as the personal-tap step above. continue-on-error: true + run: | + set -euo pipefail + + SOURCE_SHA=$(curl -fsSL "https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" | sha256sum | awk '{print $1}') + if [[ -z "$SOURCE_SHA" ]]; then + echo "Failed to resolve source tarball sha256" + exit 1 + fi + + export GIT_TERMINAL_PROMPT=0 + REMOTE="https://x-access-token:${GH_TOKEN}@github.com/tw93/homebrew-core.git" + UPSTREAM="https://github.com/Homebrew/homebrew-core.git" + WORK_DIR=$(mktemp -d) + BRANCH="mole-${VERSION}" + + git clone --depth=1 "$REMOTE" "$WORK_DIR" + cd "$WORK_DIR" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote add upstream "$UPSTREAM" + git fetch upstream main --depth=1 + git reset --hard upstream/main + git checkout -B "$BRANCH" + + # Only rewrite url and the source sha256 right after it. The bottle + # block stays untouched: Homebrew's check-bottle-block CI hook fails + # PRs that modify it because BrewTestBot rebuilds bottles after the + # url change lands. + awk -v new_url="https://github.com/tw93/Mole/archive/refs/tags/${TAG}.tar.gz" \ + -v new_sha="$SOURCE_SHA" ' + BEGIN { url_done = 0; sha_done = 0 } + !url_done && /^ url "/ { print " url \"" new_url "\""; url_done = 1; next } + url_done && !sha_done && /^ sha256 "/ { print " sha256 \"" new_sha "\""; sha_done = 1; next } + { print } + ' Formula/m/mole.rb > Formula/m/mole.rb.new + mv Formula/m/mole.rb.new Formula/m/mole.rb + + if git diff --quiet -- Formula/m/mole.rb; then + echo "Formula already on ${VERSION}, nothing to push" + exit 0 + fi + + git add Formula/m/mole.rb + git commit -m "mole ${VERSION}" -m "Automated release via GitHub Actions" + git push --force-with-lease origin "$BRANCH" + + # Open or refresh the PR. If a PR is already open for this branch the + # push above is enough; gh pr create only runs when there isn't one. + if ! gh pr view "$BRANCH" --repo Homebrew/homebrew-core > /dev/null 2>&1; then + gh pr create --repo Homebrew/homebrew-core \ + --base main --head "tw93:${BRANCH}" \ + --title "mole ${VERSION}" \ + --body "Release notes: https://github.com/tw93/Mole/releases/tag/${TAG}" + fi - name: Verify formula updates if: success() @@ -131,4 +232,4 @@ jobs: echo " Version: ${{ steps.tag_version.outputs.version }}" echo " Tag: ${{ steps.tag_version.outputs.tag }}" echo " Personal tap: tw93/homebrew-tap" - echo " Official core: Homebrew/homebrew-core (PR created)" + echo " Official core: Homebrew/homebrew-core (PR opened)" diff --git a/Resources/mole/.github/workflows/test.yml b/Resources/mole/.github/workflows/test.yml index 4151314..3245731 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,17 +17,19 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - name: Install tools - run: brew install bats-core shellcheck + run: brew install bats-core shellcheck coreutils parallel - 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: MOLE_PERF_BYTES_TO_HUMAN_LIMIT_MS: "6000" MOLE_PERF_GET_FILE_SIZE_LIMIT_MS: "3000" + MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS: "10000" + MOLE_TEST_JOBS: "6" BATS_FORMATTER: tap LANG: en_US.UTF-8 LC_ALL: en_US.UTF-8 @@ -39,24 +44,56 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 - - name: Test on ${{ matrix.os }} + - name: Install bats + run: brew install bats-core coreutils + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5 + with: + go-version-file: go.mod + + - name: Smoke load on ${{ matrix.os }} run: | echo "Testing on ${{ matrix.os }}..." bash -n mole source lib/core/common.sh echo "✓ Successfully loaded on ${{ matrix.os }}" + - name: Run core bats subset + env: + MOLE_TEST_NO_AUTH: "1" + MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS: "10000" + BATS_FORMATTER: tap + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + run: | + bats tests/core_safe_functions.bats \ + tests/core_common.bats \ + tests/core_timeout.bats \ + tests/regression.bats \ + tests/cli.bats + security: name: Security Checks runs-on: macos-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4 + with: + fetch-depth: 0 + + - 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 - echo "✗ Unsafe rm -rf usage found" + # Filters in order: + # safe_remove / validate_path: known safe wrapper helpers in file_ops.sh + # # SAFE: explicit annotation that a direct rm is gated by narrow checks + # ^path:line:[ws]#: pure comment lines that mention rm -rf in docs + # echo : help text or messages that print rm -rf as documentation + if grep -rn "rm -rf" --include="*.sh" lib/ | grep -v -E "safe_remove|validate_path|# SAFE|^[^:]+:[0-9]+:[[:space:]]*#|echo "; then + echo "✗ Unsafe rm -rf usage found (annotate gated calls with '# SAFE: ' or route through safe_remove)" exit 1 fi echo "✓ No unsafe rm usage found" @@ -74,15 +111,16 @@ jobs: fi ' - - name: Check for secrets - run: | - echo "Checking for hardcoded secrets..." - matches=$(grep -r "password\|secret\|api_key" --include="*.sh" . \ - | grep -v "# \|test" \ - | grep -v -E "lib/core/sudo\.sh|lib/core/app_protection\.sh|lib/clean/user\.sh|lib/clean/brew\.sh|bin/optimize\.sh|lib/clean/apps\.sh|lib/uninstall/batch\.sh" || true) - if [[ -n "$matches" ]]; then - echo "$matches" - echo "✗ Potential secrets found" - exit 1 - fi - echo "✓ No secrets found" + - name: Scan for secrets (gitleaks) + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_CONFIG: .gitleaks.toml + + - name: Run high-risk path regression tests + env: + MOLE_TEST_NO_AUTH: "1" + 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/.github/workflows/update-contributors.yml b/Resources/mole/.github/workflows/update-contributors.yml index 7934087..089c2e8 100644 --- a/Resources/mole/.github/workflows/update-contributors.yml +++ b/Resources/mole/.github/workflows/update-contributors.yml @@ -59,4 +59,3 @@ jobs: commit_user_name: github-actions[bot] commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> - push_options: '--force' diff --git a/Resources/mole/.gitignore b/Resources/mole/.gitignore index 451942f..ab4bca2 100644 --- a/Resources/mole/.gitignore +++ b/Resources/mole/.gitignore @@ -41,16 +41,25 @@ tests/tmp-* *.pid *.lock -# AI Assistant Instructions -.claude/ -.agents/ +# Local AI Assistant Instructions +# CLAUDE.md is a symlink to AGENTS.md (shared source of truth, tracked). +# Personal overrides go in *.local.md, which stay gitignored. +CLAUDE.local.md +AGENTS.local.md +AGENTS.override.md +.claude/* +!.claude/agents/ +!.claude/hooks/ +!.claude/skills/ +!.claude/settings.json +.claude/settings.local.json +.agents/settings.local.json .gemini/ .kiro/ -CLAUDE.md GEMINI.md ANTIGRAVITY.md WARP.md -AGENTS.md +journal/ .cursorrules # Go build artifacts (development) @@ -77,6 +86,4 @@ coverage.html session.json run_tests.ps1 -AGENTS.md mole_guidelines.md -CLAUDE.md diff --git a/Resources/mole/.gitleaks.toml b/Resources/mole/.gitleaks.toml new file mode 100644 index 0000000..353edba --- /dev/null +++ b/Resources/mole/.gitleaks.toml @@ -0,0 +1,27 @@ +# Mole gitleaks configuration +# Replaces grep-based secret scan in .github/workflows/test.yml +# Uses gitleaks default ruleset (AWS / GitHub / Slack / private keys / etc). + +[extend] +useDefault = true + +[allowlist] +description = "Mole-specific allowlist" + +# Test fixtures and mock binaries never hold real secrets. +paths = [ + '''tests/.*''', + '''.*\.bats$''', + # Bundle ID lists in app protection use "keychain*", "security*" etc. as + # wildcard patterns, not secrets. + '''lib/core/app_protection\.sh''', + '''lib/core/app_protection_data\.sh''', +] + +# Mole talks about "password" / "Touch ID" in UI prompts and sudo helpers. +# These are user-visible strings, not credentials. +regexes = [ + '''password( and| or| dialog| prompt| input|,)''', + '''Touch ID or password''', + '''_request_password''', +] diff --git a/Resources/mole/.golangci.yml b/Resources/mole/.golangci.yml index 11bb2c8..504a3e0 100644 --- a/Resources/mole/.golangci.yml +++ b/Resources/mole/.golangci.yml @@ -18,6 +18,11 @@ linters: - unused # Additional linters - modernize + - revive + - unparam + # Security scanner. mole deletes files and shells out, so gosec is + # high-value here even with some false positives. + - gosec settings: govet: @@ -30,8 +35,26 @@ linters: - (io.Closer).Close - (*os/exec.Cmd).Run - (*os/exec.Cmd).Start + revive: + rules: + - name: use-waitgroup-go staticcheck: checks: ["all", "-QF1003", "-SA9003"] + gosec: + # mole is a system tool: scanning user-supplied paths and shelling out + # to mdls/du/osascript are core features, not vulnerabilities. We rely + # on validatePath (covered by FuzzValidatePath) for path safety and + # on osascript escaping in delete.go for command construction. + excludes: + - G104 # already covered by errcheck + - G115 # integer overflow on stat.{Bavail,Bsize,Dev,Ino} and FileInfo.Size; macOS-safe + - G204 # subprocess with variable; required for du/mdls/osascript/mdfind + - G301 # mkdir perms; we use 0o755 intentionally + - G302 # chmod perms; same + - G304 # file inclusion via variable; we scan user paths by design + - G306 # WriteFile perms; we use 0o644 intentionally + - G702 # command injection taint; mdfind/du take user paths by design + - G703 # path traversal taint; scanning user paths is mole's purpose exclusions: rules: @@ -39,6 +62,8 @@ linters: - path: _test\.go linters: - errcheck + - gosec + - unparam # Ignore errors from os.Remove in cleanup code - text: "os.Remove" linters: diff --git a/Resources/mole/AGENTS.md b/Resources/mole/AGENTS.md new file mode 100644 index 0000000..8a454e0 --- /dev/null +++ b/Resources/mole/AGENTS.md @@ -0,0 +1,190 @@ +# Mole Agent Guide + +This file is the shared source of truth for any AI agent working on this repo (Claude Code, Codex, etc.). `CLAUDE.md` is a symlink to this file. Put machine-specific or personal overrides in `AGENTS.local.md` / `CLAUDE.local.md`; both are gitignored. + +## Project + +Mole is a macOS system cleanup and optimization tool with shell and Go components. It performs file cleanup, app protection checks, and maintenance tasks, so safety rules matter more than speed. + +## Repository Map + +- `mole` - main shell entrypoint. +- `bin/` - command entry scripts such as clean, analyze, status, uninstall, purge, installer, completion, and touchid. +- `lib/core/` - shared shell safety, UI, file operations, operation logs, app protection logic, and centralized timeout constants (`timeouts.sh`). +- `lib/core/app_protection_data.sh` - readonly bundle ID and pattern arrays consumed by `app_protection.sh`. Data only, no logic. +- `lib/clean/` - cleanup flows. +- `lib/manage/` - whitelist, update, autofix, and purge path management. +- `lib/optimize/` - optimization tasks. +- `lib/check/` - health, diagnostics, and dev environment checks. +- `lib/uninstall/` - app uninstall flows and package-manager removal helpers. +- `lib/ui/` - reusable menus and app selectors. +- `cmd/analyze/` - Go disk-analysis TUI. `main.go` is bootstrap only; `model.go` holds types and accessor methods; `update.go` holds the Bubble Tea Update chain. +- `cmd/status/` - Go status dashboard. +- `tests/` - Bats and shell test coverage. `tests/fuzz_corpus/` holds property-test corpora consumed by `path_validation_fuzz.bats`. +- `scripts/` - check, test, build, and release helpers. `audit_bundle_drift.sh` backs the monthly bundle audit; per-PR perf is covered by `tests/core_performance.bats`. +- `docs/SECURITY_DESIGN.md` - design doc for the path validation / app protection / # SAFE annotation contract. +- `SECURITY_AUDIT.md` - security review notes. + +## Commands + +```bash +./scripts/check.sh --format +MOLE_TEST_NO_AUTH=1 ./scripts/test.sh +MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats +MOLE_DRY_RUN=1 ./mole clean +MOLE_TEST_NO_AUTH=1 ./mole clean --dry-run +MOLE_TEST_NO_AUTH=1 ./mole purge --dry-run +MOLE_TEST_NO_AUTH=1 ./mole installer --dry-run +find bin lib -name '*.sh' -print0 | xargs -0 -n1 bash -n +make build +go test ./... +``` + +Public docs and examples should prefer the installed `mo` command. Use `./mole` in this repository when verifying source-tree behavior before installation. `analyze` and `analyse` are both accepted command spellings. + +## Critical Safety Rules + +- Never use raw `rm -rf` or `find -delete`; use safe deletion helpers. +- Use `mole_delete` from `lib/core/file_ops.sh` for removals so Trash routing, operation logs, dry-run behavior, and path protection stay consistent. +- Never modify protected paths such as `/System`, `/Library/Apple`, or `com.apple.*`. +- Route user-facing cleanup through Trash where the project expects recoverability, especially for analyze-driven ad hoc cleanup. +- Never let verification block on sudo, AppleScript, or macOS authorization prompts unless the task explicitly targets auth behavior. +- Use `MOLE_DRY_RUN=1` before destructive cleanup flows. +- Use `MOLE_TEST_NO_AUTH=1` for tests, manual repro, and verification unless real auth behavior is being tested. +- Any new direct use of `sudo`, `osascript`, or `launchctl` must have a `MOLE_TEST_MODE` / `MOLE_TEST_NO_AUTH` guard or be fully mocked in tests. +- Do not change ESC timeout behavior in `lib/core/ui.sh` unless explicitly requested. +- Preserve operation logging to the project log path unless the user explicitly asks to change `MO_NO_OPLOG` behavior. +- **AI-generated PRs touching destructive sinks need line-by-line review.** Any PR touching `find_app_files`, `mole_delete`, `remove_file_list`, Group Container / `~/Library/Containers` traversal, `TeamID.*.prefix*` style wildcards, or any `find` recursion that ends in deletion must be audited per branch (fallback branches often regress to broad globs even when the primary branch looks correct), per protected-path coverage (does `should_protect_path` already include the new entry point?), and per user-confirmation step (does the PR silently skip an existing prompt?). When the PR is plausibly AI-generated, raise the bar: ask the contributor to narrow matchers to the exact bundle ID or app path before merge; do not approve "this looks fine." PR #874 (Group Container + diagnostic discovery) and PR #875 (interactive file selector) were merged and then reverted (`6ea1987`, `b4e9205`) precisely because a TeamID-prefix wildcard in a fallback branch matched far more than intended. Same shape, same revert risk. + +## Working Rules + +- Use helpers from `lib/core/file_ops.sh` for deletion logic. +- Check `should_protect_path()` before adding cleanup behavior. +- Check app protection helpers before adding app cache, uninstall, or leftover cleanup behavior. +- Keep AI-tool cache cleanup conservative. Claude Code, opencode, Copilot CLI, Zed, Warp, Ghostty, and similar developer tools may have active versions, config, credentials, or session state that must not be removed accidentally. +- Keep shell code formatted with `./scripts/check.sh --format`. +- Prefer targeted Bats tests during development; run the full suite before committing. +- Do not add AI attribution trailers to commits. +- `start_section` / `end_section` / `note_activity` have three intentionally different implementations in `lib/core/base.sh`, `bin/clean.sh`, and `bin/purge.sh`. Source order decides which one wins, and the wording, color, and dry-run export semantics differ on purpose. Read the cross-reference comment in `lib/core/base.sh` before changing any of them. +- **Test-orphan pattern: grep the whole repo including top-level entry scripts before declaring a function dead.** Mole has a recurring shape where a helper is defined in `lib/core/base.sh` (or similar core lib), has full bats coverage in `tests/`, and is referenced by zero production callers. Known instances: `is_sip_enabled`, `is_darwin_ge`, `get_invoking_user`, `get_brand_name`, `get_mole_temp_root`, `scan_external_volumes`, `clean_dev_editors`, `perform_updates`, `format_brew_update_label`, `brew_has_outdated`. A "zero callers" verdict requires three checks: (1) grep across `lib`, `bin`, `cmd`, `scripts`, `tests`, AND the top-level entry (`mole` shim, install/uninstall scripts), not just core lib dirs; (2) check for string-built call sites (`eval`, `declare -f`, `compgen`); (3) re-grep after removal to confirm nothing was hand-wired. When deleting a write-only helper, also trace every variable it wrote and every config it read; the entire data path may be orphaned. Sub-agent "dead code" reports are starting points, not verdicts. + +## Hotspot Ownership + +These files are intentionally large. Do not start by splitting them. Keep edits narrow, preserve local safety boundaries, and run the listed tests when touching each area. + +- `lib/clean/user.sh` owns user-level cleanup flows, browser caches, cloud/app support cleanup, device firmware, and Apple Silicon caches. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_user_core.bats tests/clean_app_caches.bats tests/clean_cached_device_firmware.bats` when touching this area, or `MOLE_TEST_NO_AUTH=1 ./scripts/test.sh` if behavior crosses sections. +- `lib/core/app_protection.sh` owns uninstall/data/path protection policy and bundle matching; `lib/core/app_protection_data.sh` owns the protected app category lists. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall_safety.bats tests/uninstall_naming_variants.bats tests/bundle_resolver.bats`. +- `lib/clean/project.sh` owns purge discovery, project artifact filtering, purge menus, and purge config. Run `MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats`. +- `bin/uninstall.sh` owns uninstall command orchestration, app inventory, metadata refresh, and list/json output. Run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_scan_bash32.bats`. +- `lib/clean/dev.sh` owns developer-tool cleanup, language/toolchain caches, AI agent caches, and Codex runtime handling. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_dev_caches.bats tests/dev_extended.bats`. +- `lib/optimize/tasks.sh` owns optimize task registration and system maintenance actions. Run `MOLE_TEST_NO_AUTH=1 bats tests/optimize.bats tests/optimize_db.bats`. +- `bin/clean.sh` owns clean command orchestration, section output, and safe cleanup execution. Run `MOLE_TEST_NO_AUTH=1 bats tests/clean_core.bats tests/clean_apps.bats tests/cli.bats`. +- `cmd/analyze/update.go` owns the Bubble Tea `Update` chain and message handlers (Init, scanCmd, updateKey, goBack, switchToOverviewMode, enterSelectedDir). This is the largest file in `cmd/analyze/` and the natural landing spot for new key bindings, message types, or navigation behavior. Run `go test ./cmd/analyze`. `cmd/analyze/main.go` is bootstrap only (flag parsing, `main()`, helpers); `cmd/analyze/model.go` holds types and the model struct. +- `cmd/analyze/analyze_test.go` and `cmd/status/view_test.go` are test hotspots. Add new cases near related behavior; split later only when touching many adjacent cases. Run `go test ./cmd/...`. + +## Command Surface + +- `mo clean` - deep cleanup and leftovers for apps that are already gone. +- `mo uninstall` - remove installed apps and related leftovers. +- `mo optimize` - maintenance and diagnostics, with `--whitelist` support. +- `mo analyze` / `mo analyse` - Go disk explorer; safer for ad hoc cleanup because it uses Trash routing. +- `mo status` - live health dashboard and JSON output for automation. +- `mo purge` - project build artifact cleanup, with configurable scan paths through `mo purge --paths`. +- `mo installer` - installer-file discovery and cleanup. +- `mo completion`, `mo touchid`, `mo update`, and `mo remove` manage shell integration, sudo auth convenience, updates, and uninstalling Mole itself. + +## Verification + +- Shell changes: run `./scripts/check.sh --format`, then the relevant Bats test or `MOLE_TEST_NO_AUTH=1 ./scripts/test.sh`. +- Go changes: run `go test ./...`. +- Cleanup behavior: verify with dry-run or test mode first. +- File operation changes: run `MOLE_TEST_NO_AUTH=1 bats tests/file_ops_mole_delete.bats tests/user_file_ops.bats`. +- Installer changes: run `MOLE_TEST_NO_AUTH=1 bats tests/installer.bats tests/installer_fd.bats tests/installer_zip.bats`. +- Purge changes: run `MOLE_TEST_NO_AUTH=1 bats tests/purge.bats tests/purge_config_paths.bats`. +- Whitelist or management changes: run `MOLE_TEST_NO_AUTH=1 bats tests/manage_whitelist.bats tests/manage_sudo.bats`. +- Uninstall changes: run `MOLE_TEST_NO_AUTH=1 bats tests/uninstall.bats tests/uninstall_remove_file_list.bats`. +- Documentation-only changes: check links and commands. + +`make check`, `make format`, `make test`, `make test-go`, and `make verify` are wrappers around the scripts above. `make verify` intentionally runs `check` plus Go tests only; use the full Bats suite before risky cleanup, uninstall, or release work. + +If `golangci-lint` reports issues from deleted temporary worktrees or non-existent paths, clear its local cache and rerun the linter: + +```bash +golangci-lint cache clean +golangci-lint run ./cmd/... +``` + +## GitHub Operations + +- When closing a fixed bug or shipped feature, use project wording from the issue context and include the expected release path only when confirmed. + +## Release + +Tag-driven flow. The `release.yml` workflow watches `'V*'` tag pushes (capital `V`), builds amd64 and arm64 binaries on macOS, generates `SHA256SUMS`, attaches build provenance, creates the GitHub Release without notes, then bumps the personal Homebrew tap and opens a Homebrew core PR. + +### Pre-flight checklist + +1. `grep '^VERSION=' mole` matches the new version. +2. `SECURITY_AUDIT.md` opening line reflects the new version and date. +3. `git status -s` is empty or only contains intentionally staged release work. +4. `git log origin/main..HEAD --oneline` shows only commits you intend to ship. +5. `./scripts/check.sh --format` and `MOLE_TEST_NO_AUTH=1 MOLE_TEST_JOBS=2 BATS_FORMATTER=tap ./scripts/test.sh` both exit 0. +6. `go test ./cmd/...` and `make build` both pass. + +### Tag and publish + +```bash +git push origin main +git tag V # capital V; release workflow ignores lowercase v +git push origin V +``` + +Wait for the workflow to finish (typically 2 minutes for V1.38.0). The workflow creates the release with assets but `generate_release_notes: false`, so notes must be added in a follow-up step. + +### Apply curated release notes + +```bash +gh release edit V --repo tw93/Mole \ + --title "V " \ + --notes-file +``` + +Format follows V1.37.0 onward: bilingual numbered changelog (English first, 中文 second), then a `Thanks 💖` block with sponsors and contributors, ending with the repo blockquote link. Order changelog items by user-perceived impact, not chronological commit order. + +Recent sponsors via `gh api graphql`: + +```bash +gh api graphql -f query='{user(login:"tw93"){sponsorshipsAsMaintainer(first:30, orderBy:{field:CREATED_AT, direction:DESC}){nodes{sponsorEntity{... on User{login} ... on Organization{login}}}}}}' +``` + +The minimal query above works on a token without `read:user` scope. Adding `createdAt` or `privacyLevel` requires `read:user`. + +Add the standard reaction set (`+1`, `laugh`, `hooray`, `heart`, `rocket`, `eyes`): + +```bash +RELEASE_ID=$(gh api repos/tw93/Mole/releases/tags/V --jq '.id') +for r in +1 laugh hooray heart rocket eyes; do + gh api "repos/tw93/Mole/releases/$RELEASE_ID/reactions" -X POST -f content="$r" --silent +done +``` + +### Shell and release pitfalls (cumulative) + +These are real bugs hit on this codebase. Each one cost time. Re-read before touching the same area. + +- **bash 3.2 nounset on empty arrays**: macOS default bash raises "unbound variable" when expanding `"${arr[@]}"` on an empty array under `set -u`. Always guard with `[[ ${#arr[@]} -gt 0 ]]` before expansion. Hit in `lib/manage/whitelist.sh` for `DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=()`. +- **`[[ -n "$var" ]] && cmd` returns 1 when var is empty**: under `set -e` (or any caller that reads the exit code), this short-circuit form propagates exit 1 from the test, even though the intent was "skip silently". If the surrounding compound command relies on exit 0 (for example a `{...} > file ||` redirect), the optional cmd silently breaks the success path. Use plain `if/fi` whenever the conditional sits inside an exit-code-sensitive block. Hit in `install.sh` `write_install_channel_metadata` (stable channel always tripped the warning). +- **bats heredoc steals bytes from `read -n1`**: when the inner script runs via `bash <<'EOF' ... EOF`, a `read -r -s -n1` in the function under test consumes the next byte from the heredoc source itself, corrupting the next command (e.g. `echo` becomes `cho`, exit 127). Fix is to redirect the function's stdin from `/dev/null` inside the test. +- **`run_with_timeout` execs the binary, bypassing bash function mocks**: gtimeout/timeout exec the real PATH binary, so a shell-function override of (e.g.) `osascript` is invisible. Tests must use a PATH stub directory and prepend it to `PATH`, not function shadowing. +- **CI runners lack `/Library/PrivilegedHelperTools`**: `clean_orphaned_system_services` guards that scan with `[[ -d /Library/PrivilegedHelperTools ]]`, which is false on GitHub macOS runners, so a test that feeds an orphan helper through that path finds zero orphans in CI even though it passes locally (the dir exists on dev machines). Route orphan-service tests through `/Library/LaunchDaemons`, which always exists on macOS. Hit fixing #1082. +- **A test can pass vacuously when the function early-returns**: `clean_apps.bats` `setup_file` exports `MOLE_TEST_MODE=1`, and `clean_orphaned_system_services` returns immediately under that flag, leaving `$output` empty. A test whose *last* assertion is a `[[ "$output" != *"..."* ]]` (true on empty) then passes green while its real `==` assertion in the middle is silently swallowed (same shape as #886). Always end each assertion with `|| return 1`, and override `MOLE_TEST_MODE=0` (plus a `sudo -n true` mock) when the test needs the function body to actually run. +- **`gh release create` conflicts with the workflow-created release**: the workflow already creates the release on tag push, so post-tag note publishing must use `gh release edit`, never `create`. +- **Tag prefix is case-sensitive**: `release.yml` filters on `'V*'`. A lowercase `v1.38.0` tag will not trigger the workflow. + +### Release-notes craft + +- **Order items by user-perceived impact, not commit chronology**. The headline change goes first; internal safety hardening, performance, and bug fixes follow. +- **Verify every mentioned command still exists in HEAD before listing it**. `mo check / mo doctor` was removed in the same release cycle that I almost shipped notes claiming it as a feature. +- **Pick icons that match the action, not the category**. A broom (🧹) on insight rows mis-signalled "all of these are safe to delete", which is wrong for iOS Backups, Xcode Archives, and Old Downloads. Eyes (👀) match "look here" without that false promise. +- **No em dash anywhere in user-facing text**. Use commas, periods, colons, or semicolons. (Global rule, but worth re-stating because it has been violated repeatedly in release drafts.) +- **No parenthesised PR refs or thanks inline**. Move PR numbers and contributor handles to a single closing thanks block to keep the changelog scannable. 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..eccd794 100644 --- a/Resources/mole/CONTRIBUTORS.svg +++ b/Resources/mole/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -8,602 +8,1108 @@ - + tw93 - + - - - JackPhallen + + + sebastianbreguel - + - - - bhadraagada + + + JackPhallen - + - - - iamxorum + + + bhadraagada - + - - - dwjoss + + + yuzeguitarist - + - - - alexandear + + + M-Hassan-Raza - + - - - imnotnoahhh + + + iamxorum - + - - - amanthanvi + + + dwjoss - + - - - Angelk90 + + + alexandear - + - - - Sizk + + + imnotnoahhh - + - - - rubnogueira + + + xronocode - + - - - biplavbarua + + + amanthanvi - + - - - bsisduck + + + yhy0 - + - - - spider-yamet + + + PremPrakashCodes - + - - - jimmystridh + + + Angelk90 - + - - - fte-jjmartres + + + OWConnoi - + - - - Else00 + + + RajvardhanPatil07 - + - - - carolyn-sun + + + guobosheng - + - - - ndbroadbent + + + Sizk - + - - - MohammedTarigg + + + yetval - + - - - onurtashan + + + fabdelgado - + - - - ppauel + + + biplavbarua - + - - - shakeelmohamed + + + bevanjkay - + - - - Harsh-Kapoorr + + + rubnogueira - + - - - thijsvanhal + + + bsisduck - + - - - TomP0 + + + philippb - + - - - yuzeguitarist + + + byronwang2005 - + - - - bikraj2 + + + carolyn-sun - + - - - bunizao + + + Else00 - + - - - rans0 + + + fte-jjmartres - + - - - frozturk + + + MohammedTarigg - + - - - huyixi + + + jimmystridh - + - - - purofle + + + spider-yamet - + - - - yamamel + + + KoukeNeko - + - - - NanmiCoder + + + bikraj2 - + - - - KoukeNeko + + + abcreativ - + - - - andmev + + + IMZihad21 - + - - - uluumbch + + + Puhavik - + - - - ClathW + + + unnipv - + - - - Copper-Eye + + + TyceHerrman - + - - - DimitarNestorov + + + TomP0 - + - - - gokulp01 + + + thijsvanhal - + - - - Hensell + + + Harsh-Kapoorr - + - - - jalen0x + + + shakeelmohamed - + - - - kowyo + + + Shahfarzane - + - - - kwakubiney + + + ndbroadbent - + - - - LmanTW + + + imrajyavardhan12 - + - - - injuxtice + + + pranahonk - + - - - khipu-luke + + + ppauel - + - - - mariovtor + + + onurtashan - + - - - anonymort + + + NanmiCoder - + - - - Schlauer-Hax + + + Parsifa1 - + - - - mickyyy68 + + + yamamel - + - - - EastSun5566 + + + sibisai + + + + + + + + purofle + + + + + + + + + + + mehmetcansahin + + + + + + + + + + + iwen-conf + + + + + + + + + + + iuhoay + + + + + + + + + + + huyixi + + + + + + + + + + + frozturk + + + + + + + + + + + fishwww-ww + + + + + + + + + + + honghoker + + + + + + + + + + + rans0 + + + + + + + + + + + corevibe555 + + + + + + + + + + + connorscott1 + + + + + + + + + + + guangjun-super + + + + + + + + + + + bunizao + + + - + MohammedEsafi + + + + + + + + + Hensell + + + + + + + + + + + HaraldNordgren + + + + + + + + + + + gokulp01 + + + + + + + + + + + FelixLyfe + + + + + + + + + + + DimitarNestorov + + + + + + + + + + + Copper-Eye + + + + + + + + + + + ClathW + + + + + + + + + + + bashu + + + + + + + + + + + uluumbch + + + + + + + + + + + aronprins + + + + + + + + + + + MASNathan + + + + + + + + + + + andmev + + + + + + + + + + + AmanSikarwar + + + + + + + + + + + AlexanderAverin + + + + + + + + + + + AilfredBitworth + + + + + + + + + + + rafay99-epic + + + + + + + + + + + mshavliuk + + + + + + + + + + + EastSun5566 + + + + + + + + + + + mickyyy68 + + + + + + + + + + + Schlauer-Hax + + + + + + + + + + + anonymort + + + + + + + + + + + degouville + + + + + + + + + + + mariovtor + + + + + + + + + + + manuelguido + + + + + + + + + + + khipu-luke + + + + + + + + + + + injuxtice + + + + + + + + + + + LmanTW + + + + + + + + + + + kwakubiney + + + + + + + + + + + kowyo + + + + + + + + + + + khisby + + + + + + + + + + + Jiaweeee + + + + + + + + + + + jason-costello + + + + + + + + + + + jalen0x + + \ No newline at end of file diff --git a/Resources/mole/LICENSE b/Resources/mole/LICENSE index 8ff99f4..f288702 100644 --- a/Resources/mole/LICENSE +++ b/Resources/mole/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2025 tw93 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Resources/mole/Makefile b/Resources/mole/Makefile index 4db8f37..47acb20 100644 --- a/Resources/mole/Makefile +++ b/Resources/mole/Makefile @@ -1,6 +1,6 @@ # Makefile for Mole -.PHONY: all build clean release +.PHONY: all build clean check format test test-go verify release release-amd64 release-arm64 mod-download # Output directory BIN_DIR := bin @@ -19,6 +19,7 @@ STATUS_SRC := ./cmd/status # Build flags LDFLAGS := -s -w +RELEASE_GO_ENV := CGO_ENABLED=0 all: build @@ -42,16 +43,31 @@ build: mod-download $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC) $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC) -# Release build targets (run on native architectures for CGO support) +check: + ./scripts/check.sh --no-format + +format: + ./scripts/check.sh --format + +test: + MOLE_TEST_NO_AUTH=1 ./scripts/test.sh + +test-go: + $(GO) test ./... + +verify: check test-go + +# Release build targets. Keep these pure-Go so the macOS SDK on the +# release runner cannot raise the Mach-O minimum OS version via cgo. release-amd64: mod-download @echo "Building release binaries (amd64)..." - GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) release-arm64: mod-download @echo "Building release binaries (arm64)..." - GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) + $(RELEASE_GO_ENV) GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) clean: @echo "Cleaning binaries..." diff --git a/Resources/mole/README.md b/Resources/mole/README.md index 2c7ad76..058ae12 100644 --- a/Resources/mole/README.md +++ b/Resources/mole/README.md @@ -1,54 +1,58 @@

Mole

-

Deep clean and optimize your Mac.

+

🐹 Clean, uninstall, analyze, optimize, and monitor your Mac from the terminal.

Stars Version - License + License Commits Twitter Telegram

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

+> 💡 The CLI is free and open source. Prefer a native Mac app? [Mole for Mac](https://mole.fit) adds visual cleanup review, app updates, uninstall, maintenance, disk maps, live status, and a menu bar HUD. One license covers 2 Macs, with lifetime updates and a 14-day refund. + ## 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:** +Homebrew follows Homebrew's supported macOS tiers. Use macOS 14 or later for the Homebrew path; older macOS versions should use the script installer below on a best-effort basis. + +**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 +64,18 @@ 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 +``` -# Safe preview before applying changes +**Preview safely** + +```bash mo clean --dry-run mo uninstall --dry-run +mo history +mo history --json 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 +83,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` and can be reviewed with `mo history`. 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 +142,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 ==================================================================== @@ -145,12 +167,12 @@ System: 5/32 GB RAM | 333/460 GB Disk (72%) | Uptime 6d System optimization completed ==================================================================== -Use `mo optimize --whitelist` to exclude specific optimizations. +Use `mo optimize --whitelist` to exclude specific optimizations. Path patterns work too, so you can keep a long-lived mounted disk image around (for example `/Volumes/mail`) without it showing up as a detach candidate. ``` ### 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 +219,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 +280,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,16 +355,21 @@ 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. +- Getting [Mole for Mac](https://mole.fit) is the most direct way to support Mole's development. +- 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 🥩. - +
+These lovely people already did 🐱 +
+ +
## License -MIT License. Feel free to use Mole and contribute. +Mole is open source under GPL-3.0, see [LICENSE](LICENSE). A version you modify and share stays open under the same license, and if you fork Mole into your own product, to avoid confusion please give it a different name and credit Mole as the source. [Mole for Mac](https://mole.fit) is a separate, proprietary app, and Mole is here for the long run. 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..5c71dfc 100644 --- a/Resources/mole/SECURITY_AUDIT.md +++ b/Resources/mole/SECURITY_AUDIT.md @@ -1,18 +1,63 @@ -# 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, updated for V1.43.1 on 2026-06-17. 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()` +- uninstall removal flows that move items to Trash use `mole_delete`, which validates the path again and records the operation result. `mole_delete` now also validates symlinks instead of skipping them, and normalizes the target by collapsing repeated slashes and stripping a trailing slash before the protected-path check, so equivalent path spellings cannot slip past protection. +- incomplete download cleanup skips files currently open (lsof check) and uses quoted glob patterns to prevent word-splitting on filenames that contain spaces +- stale LaunchServices cleanup in `mo clean` (`lib/clean/launch_services.sh`) only unregisters records with `lsregister -u` and never deletes files; it acts on an entry only when the dump marks it `Bundle node not found on disk` and the referenced `.app` is confirmed absent (`[[ ! -e ]]`), rejects `/System`, `/Library/Apple`, `..` traversal, and newline/carriage-return paths, honors dry-run, is bounded by `MOLE_LAUNCH_SERVICES_STALE_LIMIT` (default 50), and never performs a global `lsregister -r -f` rebuild +- orphaned system-service cleanup in `mo clean` (`lib/clean/apps.sh` `clean_orphaned_system_services`) runs only when sudo is already available, scans `/Library/{LaunchDaemons,LaunchAgents,PrivilegedHelperTools}` while skipping `com.apple.*`, and flags an entry only when its launchd `Program`/`ProgramArguments[0]` path is absolute and missing, or a `PrivilegedHelperTools` helper whose parent app is uninstalled (`bundle_has_installed_app`). Package-manager and system binary locations, a known-helper protect list, mdfind-resolved installed apps, the whitelist, and `should_protect_path` (with `SYSTEM_CRITICAL_BUNDLES` still enforced) all exclude entries before removal. Root-owned plists are read with non-interactive sudo and fail closed, so an unreadable plist is never misread as a missing binary; removal runs `launchctl unload` then the guarded `safe_sudo_remove`, and honors dry-run (issue #1082) + +Blocked paths remain protected even with sudo. Examples include: ```text / @@ -26,7 +71,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 +82,271 @@ 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. +This design keeps cleanup scoped to known-safe maintenance targets instead of broad root-level deletion patterns. -## Cleanup Rules +## Path Protection Reference -### Orphan Detection +### Protected Prefixes (Never Deleted) -Orphaned app data is handled in `lib/clean/apps.sh`. +```text +/ +/System +/bin +/sbin +/usr +/etc +/var +/private +/Library/Extensions +``` -- 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. +### Whitelist Exceptions (Allowlisted for Cleanup) -Installed-app detection is broader than a simple `/Applications` scan and includes: +Some subpaths under protected roots are explicitly allowlisted: -- `/Applications` -- `/System/Applications` -- `~/Applications` -- Homebrew Caskroom locations -- Setapp application paths +- `/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` -Spotlight fallback checks are bounded with short timeouts to avoid hangs. +### Protected Categories -### Uninstall Matching +In addition to path blocking, these categories are protected: -App uninstall behavior is implemented in `lib/uninstall/batch.sh` and related helpers. +- Keychains, password managers, credentials +- VPN/proxy tools (Shadowsocks, V2Ray, Clash, Tailscale, AmneziaWG, WireGuard, NetworkExtension preferences) +- AI tools (Cursor, Claude, ChatGPT, Ollama) +- Codex Desktop runtime state and active VM/runtime caches +- OrbStack and similar local container/VM runtimes: live container and machine images under `~/Library/Group Containers/*dev.orbstack` and `~/.orbstack`, plus protected bundles `dev.orbstack.*` and `dev.kdrag0n.MacVirt`. Rebuildable caches such as `~/Library/Caches/dev.orbstack.OrbStack` remain cleanable. +- Browser history and cookies +- Apple-owned app group containers, including `group.com.apple.notes` +- Time Machine data (during active backup) +- `com.apple.*` LaunchAgents/LaunchDaemons +- user-owned `~/Library/LaunchAgents/*.plist` automation/configuration +- iCloud-synced `Mobile Documents` -- 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. +## Implementation Details -### Developer and Project Cleanup +All deletion routes pass through `lib/core/file_ops.sh`: -Project artifact cleanup in `lib/clean/project.sh` protects recently modified targets: +- `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 -- 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 +The current design rationale is kept in this audit document so the safety model stays next to the implementation notes. -Developer-cache cleanup preserves toolchains and other high-value state. Examples intentionally left alone include: +## Protected Directories and Categories -- `~/.cargo/bin` -- `~/.rustup` -- `~/.mix/archives` -- `~/.stack/programs` - -## 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, Tailscale, AmneziaWG, WireGuard, and NetworkExtension preferences - AI tools in generic protected-data logic, including Cursor, Claude, ChatGPT, and Ollama +- Codex Desktop runtime state and active VM/runtime caches +- OrbStack and similar local container/VM runtimes, including live data under `~/Library/Group Containers/*dev.orbstack` and `~/.orbstack`, while rebuildable runtime caches stay eligible for cleanup - `~/Library/Messages/Attachments` +- Apple Notes and other Apple-owned app group containers, including `~/Library/Group Containers/group.com.apple.notes` - 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 +- `mole_delete` validates symlinks rather than skipping them, so a symlink whose target resolves into a protected root is refused instead of silently moved +- `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 +- sudo-required uninstall paths are routed to the invoking user's Trash where possible instead of root-owned Trash or direct deletion +- sudo Trash routing refuses unsafe Trash locations, including symlinked Trash directories +- 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 +- Touch ID PAM configuration (`mo touchid`) uses `sudo install -m 444 -o root -g wheel` for atomic file writes, preventing temporary permission windows where PAM files could be user-writable (fixed in V1.39.0; prior versions used `sudo mv` which preserved temp-file ownership) +- the perl-based command timeout fallback creates a new process group with `setpgid(0, 0)` rather than calling `setsid()`, so the timed child keeps the controlling terminal. This lets nested sudo inside a Homebrew cask uninstall script reuse the already-cached credential instead of failing on a detached tty, while the group-kill cleanup semantics (`kill TERM -pid`) are unchanged. -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; bundle ID matching uses boundary-aware comparisons (`mole_name_starts_with_bundle_id_boundary`, `mole_name_has_bundle_id_boundary`) to prevent cross-app false matches (e.g. `com.example` not matching `com.example123`), and `defaults delete` is guarded by `mole_is_reverse_dns_bundle_id()` to reject malformed or adversarial domain strings +- LaunchAgents that only declare `MachServices` are unload-only and are not treated as safe deletion targets without a backing executable or bundle match +- `force_kill_app()` refuses to terminate a process whose resolved name matches a known system process, and this guard runs before the entire pgrep/AppleScript/pkill escalation ladder, so a third-party app cannot weaponize it by setting a system-like `CFBundleExecutable` +- receipt payload removal is gated by `receipt_payload_path_is_allowlisted()`, which requires a well-formed reverse-DNS bundle ID and only allows files whose basename is anchored to that bundle ID under `/Library/LaunchAgents`, `/Library/LaunchDaemons`, `/Library/PrivilegedHelperTools`, or `/private/var/db/receipts` +- apps managed by an official vendor uninstaller are excluded from Mole's own removal list, so the vendor's uninstall flow remains authoritative +- XDG-style dotdirs belonging to a standalone CLI tool that shares a name with a GUI app are preserved during uninstall, preventing collateral removal of unrelated CLI state (issue #993, for example a CLI sharing a name with `Claude.app` or `OpenCode.app`) +- batch uninstall now displays system-level remnants for review instead of deleting them; the confirmation prompt is retained and any `launchctl unload`/`bootout` runs under dry-run and `MOLE_TEST_MODE` guards -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` +- `mole_delete` Trash and permanent deletion attempts are also recorded by the file-operation layer with result status, target path, and error context where available +- `mo history` (`lib/core/history.sh`) is read-only: it reads `operations.log` and `deletions.log` to surface recent cleanup activity and performs no deletion or out-of-bounds writes +- 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 +- LaunchServices stale registration cleanup in clean: dump bounded to 10s, each unregister bounded to 3s +- Homebrew uninstall cask flow: 300s by default, extended for large apps when needed +- project scans and sizing operations: bounded to avoid whole-home stalls + +## Optimize and System Maintenance Safety -CI additionally runs shell and Go validation on push. +Optimize tasks are maintenance actions rather than bulk deletion, but they still touch user-visible state, so they are bounded conservatively: -## Dependencies +- Dock Refresh no longer deletes any `*.db` under `~/Library/Application Support/Dock`. The previous implementation wiped `desktoppicture.db` and reset the user's wallpaper (#995); refreshing the Dock now relies on `killall` plus touching the plist instead. +- Spotlight orphan rule cleanup operates only in the user domain through `defaults`, runs under a dry-run guard, removes only entries whose app is confirmed no longer installed (`bundle_has_installed_app`), requires a well-formed reverse-DNS bundle ID, and never touches `System.*` or `com.apple.*` rules. +- Font Cache Rebuild (`atsutil databases -remove`) was removed because clearing the font cache could corrupt font rendering with no reliable benefit. -Primary Go dependencies are pinned in `go.mod`, including: +## Release Integrity and Continuous Security Signals -- `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` +Mole treats release trust as part of its security posture, not just a packaging detail. -System tooling relies mainly on Apple-provided binaries and standard macOS utilities such as: +Repository-level signals include: -- `tmutil` -- `diskutil` -- `plutil` -- `launchctl` -- `osascript` -- `find` -- `stat` +- 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 +- macOS 14 and macOS 15 compatibility coverage for core Bats suites +- CodeQL scanning for Go and GitHub Actions workflows, with workflow permission hardening +- curated changelog-driven release notes for user-visible changes +- published SHA-256 checksums for release assets +- GitHub artifact attestations for release assets +- install-time verification of the GitHub Actions build-provenance attestation: `install.sh` runs `gh attestation verify` (with `--deny-self-hosted-runners`) on the downloaded asset when the GitHub CLI is available, and a mismatch is treated as fatal before checksums are read. This moves attestation from a release-side artifact to an install-side check. -Dependency vulnerability status should be checked separately from this document. +These controls do not eliminate all supply-chain risk, but they make release changes easier to review and verify. -## Limitations +## Testing Coverage -- 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. +There is no single `tests/security.bats` file. Instead, security-relevant behavior is covered by focused suites, including: + +- `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/clean_launch_services.bats` +- `tests/file_ops_mole_delete.bats` +- `tests/purge.bats` +- `tests/installer.bats` +- `tests/optimize.bats` +- `tests/uninstall_safety.bats` +- `tests/uninstall_naming_variants.bats` +- `tests/path_validation_fuzz.bats` +- `tests/history.bats` +- `tests/core_timeout.bats` +- `cmd/analyze/*_test.go` + +Key coverage areas include: + +- 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 +- LaunchAgent unload-only handling, Homebrew Cask paths, and sudo-required Trash routing +- Apple Notes group containers and other Apple-owned group containers remain protected +- 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`) +- stale LaunchServices unregister limited to missing apps, dry-run preview, fail-closed on dump failure, and a path-safety filter that rejects live, system, traversal, and injection paths (`tests/clean_launch_services.bats`) +- Touch ID PAM file permission enforcement (`tests/cli.bats`) +- bundle ID boundary matching and malformed-ID rejection (`tests/uninstall_safety.bats`) +- official-uninstaller exclusion and receipt payload allowlisting (`tests/uninstall_safety.bats`) +- uninstall behavior across localized and naming-variant app names (`tests/uninstall_naming_variants.bats`) +- property-style path validation fuzzing over the corpus in `tests/fuzz_corpus/` (`tests/path_validation_fuzz.bats`) +- read-only history rendering from operation logs (`tests/history.bats`) +- command timeout behavior including process-group cleanup (`tests/core_timeout.bats`) +- bash 3.2 empty-array nounset compatibility (`tests/uninstall_scan_bash32.bats`) + +## Known Limitations and Future Work + +- Cleanup is destructive. Most cleanup flows do not provide undo. +- `mo analyze` delete is safer because it uses Trash, but other cleanup flows are permanent once confirmed. +- `mo uninstall` now routes more removals through Trash, but Trash availability, permissions, and volume behavior still depend on the local macOS environment. +- 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 artifacts include checksums and attestations, but downstream package-manager trust also depends on external distribution infrastructure. +- `mo history --json` escapes strings byte by byte under `LC_ALL=C` (`history_json_escape`) for portable behavior on bash 3.2. Printable multibyte bytes are emitted verbatim, so the emitted JSON stays valid UTF-8, but the escaper does not perform Unicode-aware codepoint iteration. This is a known display-layer detail, not a correctness issue. +- 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/TRADEMARK.md b/Resources/mole/TRADEMARK.md new file mode 100644 index 0000000..a73b696 --- /dev/null +++ b/Resources/mole/TRADEMARK.md @@ -0,0 +1,16 @@ +# Trademark Policy + +The "Mole" name and logo are trademarks of the Mole project. GPL-3.0 covers the +code, not the brand. Open source licenses grant copyright, not trademark. + +We want users to trust that something called "Mole" really is this project. So if +you publish a fork, please: + +- Use your own name and icon, not "Mole" or the Mole logo. +- Don't imply your fork is endorsed by or affiliated with Mole. +- Don't use the Mole name to market a paid or competing product. + +Mole for Mac at https://mole.fit is a separate, proprietary product with its own +reserved name and assets. + +Permission requests: open an issue or reach out via https://mole.fit. diff --git a/Resources/mole/bin/analyze-go b/Resources/mole/bin/analyze-go index ebb96ba..99ab78c 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 deleted file mode 100755 index 24e4594..0000000 --- a/Resources/mole/bin/check.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Fix locale issues (similar to Issue #83) -export LC_ALL=C -export LANG=C - -# Load common functions -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$SCRIPT_DIR/lib/core/common.sh" -source "$SCRIPT_DIR/lib/core/sudo.sh" -source "$SCRIPT_DIR/lib/manage/update.sh" -source "$SCRIPT_DIR/lib/manage/autofix.sh" - -source "$SCRIPT_DIR/lib/check/all.sh" - -cleanup_all() { - stop_inline_spinner 2> /dev/null || true - stop_sudo_session - cleanup_temp_files -} - -handle_interrupt() { - cleanup_all - exit 130 -} - -main() { - # Register unified cleanup handler - trap cleanup_all EXIT - trap handle_interrupt INT TERM - - if [[ -t 1 ]]; then - clear - fi - - printf '\n' - - # Create temp files for parallel execution - local updates_file=$(mktemp_file) - local health_file=$(mktemp_file) - local security_file=$(mktemp_file) - local config_file=$(mktemp_file) - - # Run all checks in parallel with spinner - if [[ -t 1 ]]; then - echo -ne "${PURPLE_BOLD}System Check${NC} " - start_inline_spinner "Running checks..." - else - echo -e "${PURPLE_BOLD}System Check${NC}" - echo "" - fi - - # Parallel execution - { - check_all_updates > "$updates_file" 2>&1 & - check_system_health > "$health_file" 2>&1 & - check_all_security > "$security_file" 2>&1 & - check_all_config > "$config_file" 2>&1 & - wait - } - - if [[ -t 1 ]]; then - stop_inline_spinner - printf '\n' - fi - - # Display results - echo -e "${BLUE}${ICON_ARROW}${NC} System updates" - 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" - - # Show suggestions - show_suggestions - - # Ask about auto-fix - if ask_for_auto_fix; then - perform_auto_fix - fi - - # Ask about updates - if ask_for_updates; then - perform_updates - fi - - printf '\n' -} - -main "$@" diff --git a/Resources/mole/bin/clean.sh b/Resources/mole/bin/clean.sh index 039fd33..e8f63a9 100755 --- a/Resources/mole/bin/clean.sh +++ b/Resources/mole/bin/clean.sh @@ -18,20 +18,41 @@ source "$SCRIPT_DIR/../lib/clean/apps.sh" source "$SCRIPT_DIR/../lib/clean/dev.sh" source "$SCRIPT_DIR/../lib/clean/app_caches.sh" source "$SCRIPT_DIR/../lib/clean/hints.sh" +source "$SCRIPT_DIR/../lib/clean/launch_services.sh" source "$SCRIPT_DIR/../lib/clean/system.sh" 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 +74,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 +150,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 +159,118 @@ 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 +} + +read_clean_sudo_choice() { + local had_force_char=false + local previous_force_char="${MOLE_READ_KEY_FORCE_CHAR:-}" + if [[ ${MOLE_READ_KEY_FORCE_CHAR+x} ]]; then + had_force_char=true + fi + + export MOLE_READ_KEY_FORCE_CHAR=1 + local choice + choice=$(read_key) + + if [[ "$had_force_char" == "true" ]]; then + export MOLE_READ_KEY_FORCE_CHAR="$previous_force_char" + else + unset MOLE_READ_KEY_FORCE_CHAR + fi + + printf '%s\n' "$choice" +} + +read_clean_sudo_password_remainder() { + local __remainder_var="$1" + local remainder="" + + if [[ -r /dev/tty ]]; then + IFS= read -r -s remainder < /dev/tty || true + else + IFS= read -r -s remainder || true + fi + + printf -v "$__remainder_var" '%s' "$remainder" +} + +prompt_for_system_clean() { + local prompt_attempt=0 + while true; do + echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " + + local choice + choice=$(read_clean_sudo_choice) + + # ESC aborts, Space skips, Enter (or any typed key, e.g. someone who + # starts typing their password) proceeds to authentication. + if [[ "$choice" == "QUIT" ]]; then + echo -e " ${GRAY}Canceled${NC}" + exit 0 + fi + + if [[ "$choice" == "SPACE" ]]; then + echo -e " ${GRAY}Skipped${NC}" + echo "" + SYSTEM_CLEAN=false + break + elif [[ "$choice" == "ENTER" ]]; then + printf "\r\033[K" # Clear the prompt line + if ensure_sudo_session "System cleanup requires admin access"; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + echo "" + else + SYSTEM_CLEAN=false + echo "" + echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" + fi + break + elif [[ "$choice" == CHAR:* ]]; then + local typed_password="${choice#CHAR:}" + local password_remainder="" + read_clean_sudo_password_remainder password_remainder + typed_password="${typed_password}${password_remainder}" + + printf "\r\033[K" # Clear the prompt line + if ensure_sudo_session_with_password "$typed_password" "System cleanup requires admin access"; then + SYSTEM_CLEAN=true + echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" + echo "" + else + SYSTEM_CLEAN=false + echo "" + echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" + fi + unset typed_password password_remainder + break + else + prompt_attempt=$((prompt_attempt + 1)) + drain_pending_input 0.05 + if [[ $prompt_attempt -ge 2 ]]; then + SYSTEM_CLEAN=false + echo -e " ${GRAY}Skipped${NC}" + echo "" + break + fi + printf "\r\033[K" + echo -e "${YELLOW}${ICON_WARNING}${NC} Press Enter to continue, or Space to skip" + fi + done +} + CLEANUP_DONE=false # shellcheck disable=SC2329 cleanup() { @@ -161,6 +295,11 @@ trap 'cleanup EXIT $?' EXIT trap 'cleanup INT 130; exit 130' INT trap 'cleanup TERM 143; exit 143' TERM +# IMPORTANT: This file overrides start_section / end_section from +# lib/core/base.sh by virtue of being sourced after it. The clean variant adds +# CURRENT_SECTION tracking, dry-run EXPORT_LIST_FILE writes and a section +# spinner stop. See the cross-reference block in lib/core/base.sh and the +# differing purge variant in bin/purge.sh before changing any of these three. start_section() { TRACK_SECTION=1 SECTION_ACTIVITY=0 @@ -187,11 +326,91 @@ end_section() { # shellcheck disable=SC2329 normalize_paths_for_cleanup() { local -a input_paths=("$@") + + local _normalized_cleanup_path="" + _normalize_single_cleanup_path() { + local raw_path="$1" + local normalized="${raw_path%/}" + [[ -z "$normalized" ]] && normalized="$raw_path" + + local gradle_caches_root="$HOME/.gradle/caches" + case "$normalized" in + "$gradle_caches_root"/*/groovy-dsl/*/* | "$gradle_caches_root"/*/kotlin-dsl/*/*) + local rel version dsl_dir rest hash + rel="${normalized#"$gradle_caches_root"/}" + version="${rel%%/*}" + rest="${rel#*/}" + dsl_dir="${rest%%/*}" + rest="${rest#*/}" + hash="${rest%%/*}" + if [[ -n "$version" && -n "$hash" && + ("$dsl_dir" == "groovy-dsl" || "$dsl_dir" == "kotlin-dsl") ]]; then + _normalized_cleanup_path="$gradle_caches_root/$version/$dsl_dir/$hash" + return + fi + ;; + esac + + _normalized_cleanup_path="$normalized" + } + + # 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 + # The gradle-DSL collapse below is intentionally inlined (not a call to + # _normalize_single_cleanup_path): this path runs for thousands of items + # and per-item function-call overhead trips the large-batch time budget + # in tests/regression.bats. Keep it in sync with that helper. + local -a _fast_pipeline=() + local _fast_path _fast_raw + for _fast_path in "${input_paths[@]}"; do + if [[ "$_fast_path" == *$'\n'* ]]; then + printf '%s\0' "$_fast_path" + else + _fast_raw="$_fast_path" + _fast_path="${_fast_path%/}" + [[ -z "$_fast_path" ]] && _fast_path="$_fast_raw" + local _gradle_caches_root="$HOME/.gradle/caches" + case "$_fast_path" in + "$_gradle_caches_root"/*/groovy-dsl/*/* | "$_gradle_caches_root"/*/kotlin-dsl/*/*) + local _rel _version _dsl_dir _rest _hash + _rel="${_fast_path#"$_gradle_caches_root"/}" + _version="${_rel%%/*}" + _rest="${_rel#*/}" + _dsl_dir="${_rest%%/*}" + _rest="${_rest#*/}" + _hash="${_rest%%/*}" + if [[ -n "$_version" && -n "$_hash" && + ("$_dsl_dir" == "groovy-dsl" || "$_dsl_dir" == "kotlin-dsl") ]]; then + _fast_path="$_gradle_caches_root/$_version/$_dsl_dir/$_hash" + fi + ;; + esac + _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 - local normalized="${path%/}" - [[ -z "$normalized" ]] && normalized="$path" + local normalized + _normalize_single_cleanup_path "$path" + normalized="$_normalized_cleanup_path" local found=false if [[ ${#unique_paths[@]} -gt 0 ]]; then for existing in "${unique_paths[@]}"; do @@ -204,9 +423,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 +457,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 } @@ -235,26 +471,18 @@ normalize_paths_for_cleanup() { get_cleanup_path_size_kb() { local path="$1" - if [[ -f "$path" && ! -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then - local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") - if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then - echo $(((bytes + 1023) / 1024)) - return 0 - fi + # A plain file or a symlink is a single stat. Directories and the + # stat-unavailable case fall back to get_path_size_kb. For a regular file + # with a zero/invalid stat we also fall back; a symlink reports 0 directly. + if [[ -L "$path" || -f "$path" ]] && command -v stat > /dev/null 2>&1; then + local bytes + bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") + if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then + echo $(((bytes + 1023) / 1024)) + return 0 fi - fi - - if [[ -L "$path" ]]; then - if command -v stat > /dev/null 2>&1; then - local bytes - bytes=$(stat -f%z "$path" 2> /dev/null || echo "0") - if [[ "$bytes" =~ ^[0-9]+$ && "$bytes" -gt 0 ]]; then - echo $(((bytes + 1023) / 1024)) - else - echo 0 - fi + if [[ -L "$path" ]]; then + echo 0 return 0 fi fi @@ -328,7 +556,7 @@ safe_clean() { if [[ "$base_path" == */ ]]; then parent_dir="${base_path%/}" else - parent_dir=$(dirname "$base_path") + parent_dir="${base_path%/*}" fi if [[ ! -d "$parent_dir" ]]; then @@ -362,6 +590,9 @@ safe_clean() { MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning ${#targets[@]} items..." fi + local _perf_scan_start + debug_timer_start _perf_scan_start + local -a existing_paths=() for path in "${targets[@]}"; do local skip=false @@ -380,9 +611,16 @@ 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 + debug_timer_end "$description: path scan" _perf_scan_start + if [[ "$show_scan_feedback" == "true" ]]; then stop_section_spinner fi @@ -419,7 +657,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[@]}") @@ -439,6 +677,9 @@ safe_clean() { local cleaning_spinner_started=false + local _perf_size_start + debug_timer_start _perf_size_start + # For larger batches, precompute sizes in parallel for better UX/stat accuracy. if [[ ${#existing_paths[@]} -gt 3 ]]; then local temp_dir @@ -453,7 +694,7 @@ safe_clean() { [[ -d "${existing_paths[i]}" ]] && ((dir_count++)) done - # Heuristic: mostly files -> sequential stat is faster than subshells. + # Heuristic: mostly files -> bulk stat is faster than per-file subshells. if [[ $dir_count -lt 5 && ${#existing_paths[@]} -gt 20 ]]; then if [[ -t 1 && "$show_spinner" == "false" ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning items..." @@ -461,23 +702,31 @@ safe_clean() { fi local idx=0 - local last_progress_update - last_progress_update=$(get_epoch_seconds) - for path in "${existing_paths[@]}"; do - local size - size=$(get_cleanup_path_size_kb "$path") - [[ ! "$size" =~ ^[0-9]+$ ]] && size=0 - - if [[ "$size" -gt 0 ]]; then - echo "$size 1" > "$temp_dir/result_${idx}" + local _bytes + while IFS= read -r _bytes; do + [[ "$_bytes" =~ ^[0-9]+$ ]] || _bytes=0 + local _kb=$(((_bytes + 1023) / 1024)) + if [[ "$_kb" -gt 0 ]]; then + echo "$_kb 1" > "$temp_dir/result_${idx}" else echo "0 0" > "$temp_dir/result_${idx}" fi - idx=$((idx + 1)) - if [[ $((idx % 20)) -eq 0 && "$show_spinner" == "true" && -t 1 ]]; then - update_progress_if_needed "$idx" "${#existing_paths[@]}" last_progress_update 1 || true - last_progress_update=$(get_epoch_seconds) + done < <(stat -f%z "${existing_paths[@]}" 2> /dev/null) + while [[ $idx -lt ${#existing_paths[@]} ]]; do + echo "0 0" > "$temp_dir/result_${idx}" + idx=$((idx + 1)) + done + for ((idx = 0; idx < ${#existing_paths[@]}; idx++)); do + if [[ -d "${existing_paths[$idx]}" && ! -L "${existing_paths[$idx]}" ]]; then + local _dsize + _dsize=$(get_cleanup_path_size_kb "${existing_paths[$idx]}") + [[ "$_dsize" =~ ^[0-9]+$ ]] || _dsize=0 + if [[ "$_dsize" -gt 0 ]]; then + echo "$_dsize 1" > "$temp_dir/result_${idx}" + else + echo "0 0" > "$temp_dir/result_${idx}" + fi fi done else @@ -529,6 +778,11 @@ safe_clean() { fi fi + debug_timer_end "$description: size calc" _perf_size_start + + local _perf_del_start + debug_timer_start _perf_del_start + # Read results back in original order. # Start spinner for cleaning phase if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then @@ -543,7 +797,7 @@ safe_clean() { read -r size count < "$result_file" 2> /dev/null || true local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if safe_remove "$path" true; then + if safe_remove "$path" true "$size"; then removed=1 fi else @@ -566,7 +820,14 @@ safe_clean() { done fi + debug_timer_end "$description: deletion" _perf_del_start + else + debug_timer_end "$description: size calc" _perf_size_start + + local _perf_del_start + debug_timer_start _perf_del_start + # Start spinner for cleaning phase (small batch) if [[ "$DRY_RUN" != "true" && ${#existing_paths[@]} -gt 0 && -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Cleaning..." @@ -581,7 +842,7 @@ safe_clean() { local removed=0 if [[ "$DRY_RUN" != "true" ]]; then - if safe_remove "$path" true; then + if safe_remove "$path" true "$size_kb"; then removed=1 fi else @@ -602,6 +863,8 @@ safe_clean() { idx=$((idx + 1)) done fi + + debug_timer_end "$description: deletion" _perf_del_start fi if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then @@ -630,7 +893,9 @@ safe_clean() { fi if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${YELLOW}$size_human dry${NC}" + local size_display + size_display=$(colorize_human_size "$size_human") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $label${NC}, ${size_display} ${YELLOW}dry${NC}" local paths_temp paths_temp=$(create_temp_file) @@ -690,7 +955,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 +972,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 "" @@ -736,7 +1017,7 @@ start_cleanup() { EOF # Preview system section when sudo is already cached (no password prompt). - if has_sudo_session; then + if adopt_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access available, system preview included" echo "" @@ -749,47 +1030,17 @@ EOF fi if [[ -t 0 ]]; then - if has_sudo_session; then + if adopt_sudo_session; then SYSTEM_CLEAN=true echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access already available" echo "" else - echo -ne "${PURPLE}${ICON_ARROW}${NC} System caches need sudo. ${GREEN}Enter${NC} continue, ${GRAY}Space${NC} skip: " - - local choice - choice=$(read_key) - - # ESC/Q aborts, Space skips, Enter enables system cleanup. - if [[ "$choice" == "QUIT" ]]; then - echo -e " ${GRAY}Canceled${NC}" - exit 0 - fi - - if [[ "$choice" == "SPACE" ]]; then - echo -e " ${GRAY}Skipped${NC}" - echo "" - SYSTEM_CLEAN=false - elif [[ "$choice" == "ENTER" ]]; then - printf "\r\033[K" # Clear the prompt line - if ensure_sudo_session "System cleanup requires admin access"; then - SYSTEM_CLEAN=true - echo -e "${GREEN}${ICON_SUCCESS}${NC} Admin access granted" - echo "" - else - SYSTEM_CLEAN=false - echo "" - echo -e "${YELLOW}Authentication failed${NC}, continuing with user-level cleanup" - fi - else - SYSTEM_CLEAN=false - echo -e " ${GRAY}Skipped${NC}" - echo "" - fi + prompt_for_system_clean fi else echo "" echo "Running in non-interactive mode" - if has_sudo_session; then + if adopt_sudo_session; then SYSTEM_CLEAN=true echo " ${ICON_LIST} System-level cleanup enabled, sudo session active" else @@ -802,9 +1053,18 @@ EOF } perform_cleanup() { + if [[ -n "$EXTERNAL_VOLUME_TARGET" ]]; then + total_items=0 + files_cleaned=0 + total_size_cleaned=0 + fi + + local initial_free_space_kb="" + local initial_free_space_display="Unknown" + # 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" @@ -831,15 +1091,19 @@ perform_cleanup() { fi if [[ "$DRY_RUN" == "true" ]]; then echo "" - echo "Potential space: 0.00GB" + echo -e "Potential space: $(colorize_human_size "0.00GB")" fi total_items=1 files_cleaned=0 total_size_cleaned=0 fi - if [[ "$test_mode_enabled" == "false" ]]; then - echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $(get_free_space)" + if [[ "$test_mode_enabled" == "false" && -z "$EXTERNAL_VOLUME_TARGET" ]]; then + if ! initial_free_space_kb=$(get_free_space_kb 2> /dev/null); then + initial_free_space_kb="" + fi + initial_free_space_display=$(format_free_space_kb "$initial_free_space_kb") + echo -e "${BLUE}${ICON_ADMIN}${NC} $(detect_architecture) | Free space: $initial_free_space_display" fi if [[ "$test_mode_enabled" == "true" ]]; then @@ -853,7 +1117,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 +1180,120 @@ 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 - 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 + # ===== 2. User essentials ===== + start_section "User essentials" + clean_user_essentials + clean_finder_metadata + 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" + # Force shell fallback so timeout runs in this shell context. + # The Cloud/Office cleaners rely on helpers (safe_clean, whitelist checks) + # defined in this script and sourced modules. + if run_with_shell_timeout 300 run_cloud_and_office_cleanup; then + : # completed successfully + else + local ret=$? + if [[ $ret -eq 124 ]]; then + log_warning "Cloud & Office cleanup timed out after 5 minutes, skipping remaining items" + elif [[ $ret -eq 130 ]]; then + return 130 + else + log_warning "Cloud & Office cleanup failed with exit code $ret" + fi + fi + 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 + clean_orphaned_container_stubs + clean_stale_launch_services_registrations + show_user_launch_agent_hint_notice + show_orphan_dotdir_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 "" @@ -1020,12 +1308,32 @@ perform_cleanup() { local -a summary_details=() + # Emit the "Free space change" (when measurable) and "Free space now" lines. + # $1 is the free space in KB captured before cleanup started. Caller appends + # each printed line to summary_details. + emit_free_space_summary() { + local initial_kb="$1" + if [[ "$DRY_RUN" == "true" ]]; then + printf 'Free space now: %s\n' "$(get_free_space)" + return 0 + fi + + local final_kb + if ! final_kb=$(get_free_space_kb 2> /dev/null); then + final_kb="" + fi + if [[ "$initial_kb" =~ ^[0-9]+$ && "$final_kb" =~ ^[0-9]+$ ]]; then + printf 'Free space change: %s\n' "$(format_free_space_delta_kb "$((final_kb - initial_kb))")" + fi + printf 'Free space now: %s\n' "$(format_free_space_kb "$final_kb")" + } + if [[ $total_size_cleaned -gt 0 ]]; then local freed_size_human freed_size_human=$(bytes_to_human_kb "$total_size_cleaned") if [[ "$DRY_RUN" == "true" ]]; then - local stats="Potential space: ${GREEN}${freed_size_human}${NC}" + local stats="Potential space: $(colorize_human_size "$freed_size_human")" [[ $files_cleaned -gt 0 ]] && stats+=" | Items: $files_cleaned" [[ $total_items -gt 0 ]] && stats+=" | Categories: $total_items" summary_details+=("$stats") @@ -1043,7 +1351,7 @@ perform_cleanup() { summary_details+=("Detailed file list: ${GRAY}$EXPORT_LIST_FILE${NC}") summary_details+=("Use ${GRAY}mo clean --whitelist${NC} to add protection rules") else - local summary_line="Space freed: ${GREEN}${freed_size_human}${NC}" + local summary_line="Tracked cleanup: ${GREEN}${freed_size_human}${NC}" if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then summary_line+=" | Items cleaned: $files_cleaned | Categories: $total_items" @@ -1055,9 +1363,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 @@ -1069,9 +1377,10 @@ perform_cleanup() { fi fi - local final_free_space - final_free_space=$(get_free_space) - summary_details+=("Free space now: $final_free_space") + local free_space_line + while IFS= read -r free_space_line; do + summary_details+=("$free_space_line") + done < <(emit_free_space_summary "$initial_free_space_kb") fi else summary_status="info" @@ -1080,7 +1389,10 @@ perform_cleanup() { else summary_details+=("System was already clean; no additional space freed.") fi - summary_details+=("Free space now: $(get_free_space)") + local free_space_line + while IFS= read -r free_space_line; do + summary_details+=("$free_space_line") + done < <(emit_free_space_summary "$initial_free_space_kb") fi if [[ $had_errexit -eq 1 ]]; then @@ -1094,9 +1406,23 @@ perform_cleanup() { printf '\n' } +run_with_shell_timeout() { + local duration="$1" + shift || true + # Functions (for example safe_clean) are available only in the current shell. + # Force the shell fallback path so timeout can execute shell functions directly. + MO_TIMEOUT_BIN="" MO_TIMEOUT_PERL_BIN="" run_with_timeout "$duration" "$@" +} + +# shellcheck disable=SC2329 # Invoked indirectly via run_with_timeout fallback. +run_cloud_and_office_cleanup() { + clean_cloud_storage + clean_office_applications +} + main() { - for arg in "$@"; do - case "$arg" in + while [[ $# -gt 0 ]]; do + case "$1" in "--help" | "-h") show_clean_help exit 0 @@ -1108,12 +1434,36 @@ 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 ;; + "--select" | "--categories" | "--exclude") + echo "mo clean $1 was removed in this release." >&2 + echo "Use 'mo clean --dry-run' to preview cleanup and 'mo clean --whitelist' to protect paths." >&2 + exit 1 + ;; + -*) + echo "Unknown option for mo clean: $1" >&2 + echo "Run 'mo clean --help' for usage." >&2 + exit 1 + ;; + *) + echo "Unexpected argument for mo clean: $1" >&2 + echo "Run 'mo clean --help' for usage." >&2 + exit 1 + ;; esac + shift done start_cleanup @@ -1123,4 +1473,6 @@ main() { exit 0 } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/Resources/mole/bin/completion.sh b/Resources/mole/bin/completion.sh index a575929..0aa55b8 100755 --- a/Resources/mole/bin/completion.sh +++ b/Resources/mole/bin/completion.sh @@ -11,6 +11,10 @@ for entry in "${MOLE_COMMANDS[@]}"; do command_names+=("${entry%%:*}") done command_words="${command_names[*]}" +clean_option_words="--dry-run -n --external --whitelist --debug --help -h" +analyze_option_words="--json --help -h" +history_option_words="--json --limit --help -h" +purge_option_words="--paths --dry-run -n --include-empty --debug --help -h" emit_zsh_subcommands() { for entry in "${MOLE_COMMANDS[@]}"; do @@ -23,13 +27,49 @@ 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 "__fish_seen_subcommand_from clean" -l dry-run -s n -d "Preview cleanup without making changes"\n' "$cmd" + printf 'complete -c %s -n "__fish_seen_subcommand_from clean" -l external -r -a "(__fish_complete_directories)" -d "Clean OS metadata from an external volume"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l whitelist -d "Manage protected paths"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l debug -d "Show detailed logs"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from clean" -l help -s h -d "Show help"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from analyze analyse" -l json -d "Output analysis as JSON"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from analyze analyse" -l help -s h -d "Show help"\n' "$cmd" + printf 'complete -c %s -n "__fish_seen_subcommand_from analyze analyse; and not __fish_seen_argument -l json -l help -s h" -a "(__fish_complete_directories)" -d "Path to analyze"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l json -d "Output history as JSON"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l limit -r -d "Limit recent entries"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from history" -l help -s h -d "Show help"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l paths -d "Edit custom scan directories"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l dry-run -s n -d "Preview purge actions without making changes"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l include-empty -d "Show zero-size project artifact directories"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l debug -d "Show detailed logs"\n' "$cmd" + printf 'complete -f -c %s -n "__fish_seen_subcommand_from purge" -l help -s h -d "Show help"\n' "$cmd" + printf '\n' + 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 +111,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 +192,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 " @@ -209,14 +313,38 @@ case "$1" in cat << EOF _mole_completions() { - local cur_word prev_word + local cur_word prev_word subcommand cur_word="\${COMP_WORDS[\$COMP_CWORD]}" prev_word="\${COMP_WORDS[\$COMP_CWORD-1]}" + subcommand="\${COMP_WORDS[1]}" if [ "\$COMP_CWORD" -eq 1 ]; then COMPREPLY=( \$(compgen -W "$command_words" -- "\$cur_word") ) else - case "\$prev_word" in + case "\$subcommand" in + clean) + case "\$prev_word" in + --external) + COMPREPLY=( \$(compgen -d -- "\$cur_word") ) + ;; + *) + COMPREPLY=( \$(compgen -W "$clean_option_words" -- "\$cur_word") ) + ;; + esac + ;; + analyze|analyse) + if [[ "\$cur_word" == -* ]]; then + COMPREPLY=( \$(compgen -W "$analyze_option_words" -- "\$cur_word") ) + else + COMPREPLY=( \$(compgen -f -- "\$cur_word") ) + fi + ;; + history) + COMPREPLY=( \$(compgen -W "$history_option_words" -- "\$cur_word") ) + ;; + purge) + COMPREPLY=( \$(compgen -W "$purge_option_words" -- "\$cur_word") ) + ;; completion) COMPREPLY=( \$(compgen -W "bash zsh fish" -- "\$cur_word") ) ;; @@ -237,7 +365,48 @@ EOF printf ' subcommands=(\n' emit_zsh_subcommands printf ' )\n' - printf " _describe 'subcommand' subcommands\n" + printf ' if (( CURRENT == 2 )); then\n' + printf " _describe 'subcommand' subcommands\n" + printf ' return\n' + printf ' fi\n' + printf " case \"\$words[2]\" in\n" + printf ' clean)\n' + printf ' _arguments \\\n' + printf " '--dry-run[Preview cleanup without making changes]' \\\\\n" + printf " '-n[Preview cleanup without making changes]' \\\\\n" + printf " '--external[Clean OS metadata from an external volume]:path:_files -/' \\\\\n" + printf " '--whitelist[Manage protected paths]' \\\\\n" + printf " '--debug[Show detailed logs]' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]'\n" + printf ' ;;\n' + printf ' analyze|analyse)\n' + printf ' _arguments \\\n' + printf " '--json[Output analysis as JSON]' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]' \\\\\n" + printf " '*:path:_files'\n" + printf ' ;;\n' + printf ' history)\n' + printf ' _arguments \\\n' + printf " '--json[Output history as JSON]' \\\\\n" + printf " '--limit[Limit recent entries]:limit:' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]'\n" + printf ' ;;\n' + printf ' purge)\n' + printf ' _arguments \\\n' + printf " '--paths[Edit custom scan directories]' \\\\\n" + printf " '--dry-run[Preview purge actions without making changes]' \\\\\n" + printf " '-n[Preview purge actions without making changes]' \\\\\n" + printf " '--include-empty[Show zero-size project artifact directories]' \\\\\n" + printf " '--debug[Show detailed logs]' \\\\\n" + printf " '(-h --help)'{-h,--help}'[Show help]'\n" + printf ' ;;\n' + printf ' completion)\n' + printf " _arguments '1:shell:(bash zsh fish)'\n" + printf ' ;;\n' + printf ' *)\n' + printf " _describe 'subcommand' subcommands\n" + printf ' ;;\n' + printf ' esac\n' printf '}\n\n' printf 'compdef _mole mole mo\n' ;; diff --git a/Resources/mole/bin/history.sh b/Resources/mole/bin/history.sh new file mode 100755 index 0000000..97a0b1c --- /dev/null +++ b/Resources/mole/bin/history.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Mole - History command. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$ROOT_DIR/lib/core/history.sh" + +HISTORY_JSON=false +HISTORY_LIMIT="$MOLE_HISTORY_DEFAULT_LIMIT" + +show_history_help() { + echo "Usage: mo history [OPTIONS]" + echo "" + echo "Review recent Mole operation and deletion activity." + echo "" + echo "Options:" + echo " --json Output history as JSON" + echo " --limit N Show the most recent N entries, 1-200" + echo " -h, --help Show this help message" +} + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + "--json") + HISTORY_JSON=true + ;; + "--limit") + shift + if [[ $# -eq 0 ]]; then + echo "Missing value for --limit" >&2 + exit 1 + fi + if ! HISTORY_LIMIT=$(history_parse_limit "$1"); then + echo "Invalid value for --limit: $1" >&2 + exit 1 + fi + ;; + "--help" | "-h") + show_history_help + exit 0 + ;; + -*) + echo "Unknown option for mo history: $1" >&2 + echo "Run 'mo history --help' for usage." >&2 + exit 1 + ;; + *) + echo "Unexpected argument for mo history: $1" >&2 + echo "Run 'mo history --help' for usage." >&2 + exit 1 + ;; + esac + shift + done + + history_load_operations "$(history_operations_log_file)" + history_load_deletions "$(history_deletions_log_file)" + + if [[ "$HISTORY_JSON" == "true" ]]; then + history_render_json "$HISTORY_LIMIT" + else + history_render_text "$HISTORY_LIMIT" + fi +} + +main "$@" diff --git a/Resources/mole/bin/installer.sh b/Resources/mole/bin/installer.sh index 864404a..b741662 100755 --- a/Resources/mole/bin/installer.sh +++ b/Resources/mole/bin/installer.sh @@ -11,6 +11,7 @@ declare MOLE_INSTALLER_SCAN_MAX_DEPTH export LC_ALL=C export LANG=C +export MOLE_CURRENT_COMMAND="${MOLE_CURRENT_COMMAND:-installer}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" @@ -44,6 +45,7 @@ readonly INSTALLER_SCAN_PATHS=( "$HOME/Downloads/Telegram Desktop" ) readonly MAX_ZIP_ENTRIES=50 +readonly INSTALLER_EXIT_INCOMPLETE=3 ZIP_LIST_CMD=() IN_ALT_SCREEN=0 @@ -128,12 +130,17 @@ scan_all_installers() { # Initialize stats declare -i total_deleted=0 declare -i total_size_freed_kb=0 +declare -i total_delete_failed=0 # Global arrays for installer data declare -a INSTALLER_PATHS=() declare -a INSTALLER_SIZES=() declare -a INSTALLER_SOURCES=() declare -a DISPLAY_NAMES=() +declare -a INSTALLER_DELETE_PATHS=() +declare -a INSTALLER_DELETE_SIZES=() +declare -a INSTALLER_DELETE_IDENTITIES=() +declare -a INSTALLER_DELETE_FAILURES=() # Get source directory display name - for example "Downloads" or "Desktop" get_source_display() { @@ -184,9 +191,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" @@ -518,41 +535,150 @@ show_installer_menu() { return 0 } +reset_installer_delete_results() { + total_deleted=0 + total_size_freed_kb=0 + total_delete_failed=0 + INSTALLER_DELETE_FAILURES=() +} + +reset_installer_delete_plan() { + INSTALLER_DELETE_PATHS=() + INSTALLER_DELETE_SIZES=() + INSTALLER_DELETE_IDENTITIES=() +} + +record_installer_delete_failure() { + local file_path="$1" + local reason="$2" + + INSTALLER_DELETE_FAILURES+=("$file_path ($reason)") + total_delete_failed=$((total_delete_failed + 1)) +} + +installer_file_size_bytes() { + local file_path="$1" + local file_size + + file_size=$(get_file_size "$file_path" 2> /dev/null || echo "") + [[ "$file_size" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$file_size" +} + +build_installer_delete_plan() { + reset_installer_delete_plan + + local idx + for idx in "$@"; do + if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then + record_installer_delete_failure "$idx" "stale selection" + continue + fi + + local file_path="${INSTALLER_PATHS[$idx]}" + local file_size="${INSTALLER_SIZES[$idx]:-0}" + if [[ ! "$file_size" =~ ^[0-9]+$ ]]; then + file_size=0 + fi + + INSTALLER_DELETE_PATHS+=("$file_path") + INSTALLER_DELETE_SIZES+=("$file_size") + INSTALLER_DELETE_IDENTITIES+=("$(mole_path_identity "$file_path")") + done + + [[ ${#INSTALLER_DELETE_PATHS[@]} -gt 0 ]] +} + +execute_installer_delete_plan() { + local plan_index + for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do + local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}" + local planned_size="${INSTALLER_DELETE_SIZES[$plan_index]}" + local planned_identity="${INSTALLER_DELETE_IDENTITIES[$plan_index]}" + + if [[ ! -e "$file_path" && ! -L "$file_path" ]]; then + record_installer_delete_failure "$file_path" "missing" + continue + fi + + local current_identity + current_identity=$(mole_path_identity "$file_path") + if [[ "$current_identity" != "$planned_identity" ]]; then + record_installer_delete_failure "$file_path" "changed since scan" + continue + fi + + local current_size + if ! current_size=$(installer_file_size_bytes "$file_path"); then + record_installer_delete_failure "$file_path" "size unavailable" + continue + fi + if [[ "$current_size" != "$planned_size" ]]; then + record_installer_delete_failure "$file_path" "changed since scan" + continue + fi + + if mole_delete "$file_path" false; then + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]] || [[ ! -e "$file_path" && ! -L "$file_path" ]]; then + total_size_freed_kb=$((total_size_freed_kb + ((current_size + 1023) / 1024))) + total_deleted=$((total_deleted + 1)) + else + record_installer_delete_failure "$file_path" "still exists" + fi + else + record_installer_delete_failure "$file_path" "delete failed" + fi + done + + if [[ $total_delete_failed -gt 0 ]]; then + return "$INSTALLER_EXIT_INCOMPLETE" + fi + return 0 +} + # Delete selected installers delete_selected_installers() { + reset_installer_delete_results + # Parse selection indices local -a selected_indices=() - [[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" + if [[ -n "$MOLE_SELECTION_RESULT" ]]; then + IFS=',' read -ra selected_indices <<< "$MOLE_SELECTION_RESULT" + fi if [[ ${#selected_indices[@]} -eq 0 ]]; then return 1 fi - # Calculate total size for confirmation - local confirm_size=0 - for idx in "${selected_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then - confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0})) + if ! build_installer_delete_plan "${selected_indices[@]}"; then + if [[ $total_delete_failed -gt 0 ]]; then + return "$INSTALLER_EXIT_INCOMPLETE" fi + return 1 + fi + + local confirm_size=0 + local plan_index + for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_SIZES[@]}; plan_index++)); do + confirm_size=$((confirm_size + ${INSTALLER_DELETE_SIZES[$plan_index]})) done + local confirm_human confirm_human=$(bytes_to_human "$confirm_size") # Show files to be deleted echo -e "${PURPLE_BOLD}Files to be removed:${NC}" - for idx in "${selected_indices[@]}"; do - if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then - local file_path="${INSTALLER_PATHS[$idx]}" - local file_size="${INSTALLER_SIZES[$idx]}" - local size_human - size_human=$(bytes_to_human "$file_size") - echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}" - fi + for ((plan_index = 0; plan_index < ${#INSTALLER_DELETE_PATHS[@]}; plan_index++)); do + local file_path="${INSTALLER_DELETE_PATHS[$plan_index]}" + local file_size="${INSTALLER_DELETE_SIZES[$plan_index]}" + local size_human + size_human=$(bytes_to_human "$file_size") + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}, ${size_human}${NC}" done # Confirm deletion echo "" - echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " + echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#INSTALLER_DELETE_PATHS[@]} installers, ${confirm_human} ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: " IFS= read -r -s -n1 confirm || confirm="" case "$confirm" in @@ -569,38 +695,18 @@ delete_selected_installers() { esac # Delete each selected installer with spinner - total_deleted=0 - total_size_freed_kb=0 - if [[ -t 1 ]]; then start_inline_spinner "Removing installers..." fi - for idx in "${selected_indices[@]}"; do - if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then - continue - fi - - local file_path="${INSTALLER_PATHS[$idx]}" - local file_size="${INSTALLER_SIZES[$idx]}" - - # Validate path before deletion - if ! validate_path_for_deletion "$file_path"; then - continue - fi - - # Delete the file - if safe_remove "$file_path" true; then - total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024))) - total_deleted=$((total_deleted + 1)) - fi - done + local delete_status=0 + execute_installer_delete_plan || delete_status=$? if [[ -t 1 ]]; then stop_inline_spinner fi - return 0 + return "$delete_status" } # Perform the installers cleanup @@ -640,8 +746,10 @@ perform_installers() { fi # Delete selected - if ! delete_selected_installers; then - return 1 + local delete_status=0 + delete_selected_installers || delete_status=$? + if [[ $delete_status -ne 0 ]]; then + return "$delete_status" fi return 0 @@ -654,6 +762,8 @@ show_summary() { if [[ "$dry_run_mode" == "1" ]]; then summary_heading="Dry run complete - no changes made" + elif [[ $total_delete_failed -gt 0 ]]; then + summary_heading="Installer cleanup incomplete" fi if [[ $total_deleted -gt 0 ]]; then @@ -664,12 +774,36 @@ show_summary() { summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}") else summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") - summary_details+=("Your Mac is cleaner now!") + if [[ $total_delete_failed -eq 0 ]]; then + summary_details+=("Your Mac is cleaner now!") + fi fi else summary_details+=("No installers were removed") fi + if [[ $total_delete_failed -gt 0 ]]; then + local failure_label="installers" + [[ $total_delete_failed -eq 1 ]] && failure_label="installer" + summary_details+=("Failed to remove ${YELLOW}$total_delete_failed${NC} $failure_label") + + local failure_count=${#INSTALLER_DELETE_FAILURES[@]} + local failure_limit=5 + if [[ $failure_count -lt $failure_limit ]]; then + failure_limit=$failure_count + fi + + local failure_index + for ((failure_index = 0; failure_index < failure_limit; failure_index++)); do + local failure_detail="${INSTALLER_DELETE_FAILURES[$failure_index]}" + summary_details+=("${ICON_WARNING} $failure_detail") + done + + if [[ $failure_count -gt $failure_limit ]]; then + summary_details+=("${ICON_WARNING} $((failure_count - failure_limit)) more failed") + fi + fi + print_summary_block "$summary_heading" "${summary_details[@]}" printf '\n' } @@ -708,6 +842,10 @@ main() { 0) show_summary ;; + "$INSTALLER_EXIT_INCOMPLETE") + show_summary + return 1 + ;; 1) printf '\n' ;; diff --git a/Resources/mole/bin/optimize.sh b/Resources/mole/bin/optimize.sh index 1a24451..c27adb2 100755 --- a/Resources/mole/bin/optimize.sh +++ b/Resources/mole/bin/optimize.sh @@ -1,6 +1,6 @@ #!/bin/bash # Mole - Optimize command. -# Runs system maintenance checks and fixes. +# Runs system maintenance tasks. # Supports dry-run where applicable. set -euo pipefail @@ -15,74 +15,87 @@ source "$SCRIPT_DIR/lib/core/common.sh" # Clean temp files on exit. trap cleanup_temp_files EXIT INT TERM source "$SCRIPT_DIR/lib/core/sudo.sh" -source "$SCRIPT_DIR/lib/manage/update.sh" -source "$SCRIPT_DIR/lib/manage/autofix.sh" +source "$SCRIPT_DIR/lib/optimize/diagnostics.sh" 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/manage/whitelist.sh" print_header() { printf '\n' - echo -e "${PURPLE_BOLD}Optimize and Check${NC}" + echo -e "${PURPLE_BOLD}Optimize${NC}" } -run_system_checks() { - # Skip checks in dry-run mode. - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then - return 0 - fi - - unset AUTO_FIX_SUMMARY AUTO_FIX_DETAILS - unset MOLE_SECURITY_FIXES_SHOWN - unset MOLE_SECURITY_FIXES_SKIPPED - echo "" - - check_all_updates - echo "" - - check_system_health - echo "" - - check_all_security - if ask_for_security_fixes; then - perform_security_fixes - fi - if [[ "${MOLE_SECURITY_FIXES_SKIPPED:-}" != "true" ]]; then - echo "" - fi - - check_all_config - echo "" +# 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}" +} - show_suggestions +# Validate JSON has expected structure (basic check). +json_validate() { + local json="$1" + # Check for required keys + [[ "$json" == *'"memory_used_gb"'* ]] && + [[ "$json" == *'"optimizations"'* ]] && + [[ "$json" == *'{'* ]] && [[ "$json" == *'}'* ]] +} - if ask_for_updates; then - perform_updates - fi - if ask_for_auto_fix; then - perform_auto_fix - fi +# 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" } 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)); 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" summary_details+=("Would apply ${YELLOW}${total_applied:-0}${NC} optimizations") summary_details+=("Run without ${YELLOW}--dry-run${NC} to apply these changes") else - summary_title="Optimization and Check Complete" + summary_title="Optimization Complete" # Build statistics summary local -a stats=() @@ -120,16 +133,6 @@ show_optimization_summary() { summary_details+=("Applied ${GREEN}${total_applied:-0}${NC} optimizations, all services tuned") fi - local summary_line3="" - if [[ -n "${AUTO_FIX_SUMMARY:-}" ]]; then - summary_line3="${AUTO_FIX_SUMMARY}" - if [[ -n "${AUTO_FIX_DETAILS:-}" ]]; then - local detail_join - detail_join=$(echo "${AUTO_FIX_DETAILS}" | paste -sd ", " -) - [[ -n "$detail_join" ]] && summary_line3+=": ${detail_join}" - fi - summary_details+=("$summary_line3") - fi summary_details+=("System fully optimized") fi @@ -139,12 +142,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 +160,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" @@ -175,185 +173,6 @@ announce_action() { echo -e "${BLUE}${ICON_ARROW} ${name}${NC}" } -touchid_configured() { - local pam_file="/etc/pam.d/sudo" - [[ -f "$pam_file" ]] && grep -q "pam_tid.so" "$pam_file" 2> /dev/null -} - -touchid_supported() { - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -qi "Touch ID"; then - return 0 - fi - fi - - # Fallback: Apple Silicon Macs usually have Touch ID. - if [[ "$(uname -m)" == "arm64" ]]; then - return 0 - fi - return 1 -} - -cleanup_path() { - local raw_path="$1" - local label="$2" - - local expanded_path="${raw_path/#\~/$HOME}" - if [[ ! -e "$expanded_path" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" - return - fi - if should_protect_path "$expanded_path"; then - echo -e "${GRAY}${ICON_WARNING}${NC} Protected $label" - return - fi - - local size_kb - size_kb=$(get_path_size_kb "$expanded_path") - local size_display="" - if [[ "$size_kb" =~ ^[0-9]+$ && "$size_kb" -gt 0 ]]; then - size_display=$(bytes_to_human "$((size_kb * 1024))") - fi - - local removed=false - if safe_remove "$expanded_path" true; then - removed=true - elif request_sudo_access "Removing $label requires admin access"; then - if safe_sudo_remove "$expanded_path"; then - removed=true - fi - fi - - if [[ "$removed" == "true" ]]; then - if [[ -n "$size_display" ]]; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label${NC}, ${GREEN}${size_display}${NC}" - else - echo -e "${GREEN}${ICON_SUCCESS}${NC} $label" - fi - else - echo -e "${GRAY}${ICON_WARNING}${NC} Skipped $label${NC}" - echo -e "${GRAY}${ICON_REVIEW}${NC} ${GRAY}Grant Full Disk Access to your terminal, then retry${NC}" - fi -} - -ensure_directory() { - local raw_path="$1" - local expanded_path="${raw_path/#\~/$HOME}" - ensure_user_dir "$expanded_path" -} - -declare -a SECURITY_FIXES=() - -collect_security_fix_actions() { - SECURITY_FIXES=() - if [[ "${FIREWALL_DISABLED:-}" == "true" ]]; then - if ! is_whitelisted "firewall"; then - 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 - if touchid_supported && ! touchid_configured; then - if ! is_whitelisted "check_touchid"; then - SECURITY_FIXES+=("touchid|Enable Touch ID for sudo") - fi - fi - - ((${#SECURITY_FIXES[@]} > 0)) -} - -ask_for_security_fixes() { - if ! collect_security_fix_actions; then - return 1 - fi - - echo "" - echo -e "${BLUE}SECURITY FIXES${NC}" - for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r _ label <<< "$entry" - echo -e " ${ICON_LIST} $label" - done - echo "" - export MOLE_SECURITY_FIXES_SHOWN=true - echo -ne "${GRAY}${ICON_REVIEW}${NC} ${YELLOW}Apply now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: " - - local key - if ! key=$(read_key); then - export MOLE_SECURITY_FIXES_SKIPPED=true - echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "" - return 0 - else - export MOLE_SECURITY_FIXES_SKIPPED=true - echo -e "\n ${GRAY}${ICON_WARNING}${NC} Security fixes skipped" - echo "" - return 1 - fi -} - -apply_firewall_fix() { - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Firewall enabled" - FIREWALL_DISABLED=false - return 0 - fi - echo -e " ${GRAY}${ICON_WARNING}${NC} Failed to enable firewall, check permissions" - 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 - fi - return 1 -} - -perform_security_fixes() { - if ! ensure_sudo_session "Security changes require admin access"; then - echo -e "${GRAY}${ICON_WARNING}${NC} Skipped security fixes, sudo denied" - return 1 - fi - - local applied=0 - for entry in "${SECURITY_FIXES[@]}"; do - IFS='|' read -r action _ <<< "$entry" - case "$action" in - firewall) - apply_firewall_fix && ((applied++)) - ;; - gatekeeper) - apply_gatekeeper_fix && ((applied++)) - ;; - touchid) - apply_touchid_fix && ((applied++)) - ;; - esac - done - - if ((applied > 0)); then - log_success "Security settings updated" - fi - SECURITY_FIXES=() -} - cleanup_all() { stop_inline_spinner 2> /dev/null || true stop_sudo_session @@ -388,6 +207,11 @@ main() { manage_whitelist "optimize" exit 0 ;; + *) + echo "Unknown optimize option: $arg" + echo "Use 'mo optimize --help' for supported options." + exit 1 + ;; esac done @@ -406,12 +230,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 +249,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 @@ -445,8 +263,6 @@ main() { stop_inline_spinner fi - show_system_health "$health_json" - load_whitelist "optimize" if [[ ${#CURRENT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then local count=${#CURRENT_WHITELIST_PATTERNS[@]} @@ -459,59 +275,49 @@ main() { fi fi - local -a safe_items=() - local -a confirm_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 + show_system_health "$health_json" - 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') + run_optimize_diagnostics - local item="${name}|${desc}|${action}|${path}" + local -a items=() + local opts_file + opts_file=$(mktemp_file) + parse_optimization_items "$health_json" > "$opts_file" - 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 "" - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - ensure_sudo_session "System optimization requires admin access" || true + # Track sudo availability so individual tasks can skip cleanly when admin + # access was denied. Without this, every sudo task re-prompts for the + # password and half-runs after a refusal. Default true in dry-run so the + # task list still expands fully for inspection. + export MOLE_OPTIMIZE_SUDO_AVAILABLE="false" + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + MOLE_OPTIMIZE_SUDO_AVAILABLE="true" + elif ensure_sudo_session "System optimization requires admin access"; then + MOLE_OPTIMIZE_SUDO_AVAILABLE="true" + else + opt_msg "Skipping sudo-required optimizations: admin access not granted" 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 - - local safe_count=${#safe_items[@]} - local confirm_count=${#confirm_items[@]} + for item in "${items[@]}"; do + IFS='|' read -r name desc action path <<< "$item" + if command -v is_whitelisted > /dev/null && is_whitelisted "$action"; then + opt_msg "Skipped (whitelisted): $name" + continue + fi + announce_action "$name" "$desc" "safe" + execute_optimization "$action" "$path" + done - run_system_checks + local safe_count=${#items[@]} 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..a41e69d 100755 --- a/Resources/mole/bin/purge.sh +++ b/Resources/mole/bin/purge.sh @@ -13,15 +13,26 @@ export LANG=C SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" -# Set up cleanup trap for temporary files -trap cleanup_temp_files EXIT INT TERM +# Restores cursor and clears temp files even when set -e aborts (#915). +cleanup() { + show_cursor 2> /dev/null || true + cleanup_temp_files +} +trap cleanup EXIT +trap 'trap - EXIT; cleanup; exit 130' INT TERM source "$SCRIPT_DIR/../lib/core/log.sh" source "$SCRIPT_DIR/../lib/clean/project.sh" # Configuration CURRENT_SECTION="" -# Section management +# IMPORTANT: This file overrides start_section / end_section / note_activity +# from lib/core/base.sh by virtue of being sourced after it. The purge variant +# uses a blue ━━━ box header, has no fallback "Nothing to ..." message, and +# writes every note_activity call straight to EXPORT_LIST_FILE (purge always +# wants the export list, not just under DRY_RUN). See the cross-reference in +# lib/core/base.sh and the clean variant in bin/clean.sh before changing any +# of these three. start_section() { local section_name="$1" CURRENT_SECTION="$section_name" @@ -33,13 +44,59 @@ end_section() { CURRENT_SECTION="" } -# Note activity for export list note_activity() { if [[ -n "$CURRENT_SECTION" ]]; then printf '%s\n' "$CURRENT_SECTION" >> "$EXPORT_LIST_FILE" fi } +# 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 +184,18 @@ 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}" - } - + local _parent_pid=$$ while [[ -f "$stats_dir/purge_scanning" ]]; do + # Exit if parent process died (prevents orphaned spinner) + if ! kill -0 "$_parent_pid" 2> /dev/null; then + break + fi 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 @@ -241,6 +292,7 @@ show_help() { echo -e "${YELLOW}Options:${NC}" echo " --paths Edit custom scan directories" echo " --dry-run Preview purge actions without making changes" + echo " --include-empty Show zero-size project artifact directories" echo " --debug Enable debug logging" echo " --help Show this help message" echo "" @@ -252,9 +304,6 @@ show_help() { # Main entry point main() { - # Set up signal handling - trap 'show_cursor; exit 130' INT TERM - # Parse arguments for arg in "$@"; do case "$arg" in @@ -273,6 +322,9 @@ main() { "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; + "--include-empty") + export MOLE_PURGE_INCLUDE_EMPTY=1 + ;; *) echo "Unknown option: $arg" echo "Use 'mo purge --help' for usage information" @@ -288,7 +340,14 @@ main() { fi hide_cursor perform_purge - 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..5f66c94 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..b97cac9 100755 --- a/Resources/mole/bin/touchid.sh +++ b/Resources/mole/bin/touchid.sh @@ -22,6 +22,11 @@ readonly PAM_SUDO_FILE readonly PAM_SUDO_LOCAL_FILE readonly PAM_TID_LINE="auth sufficient pam_tid.so" +secure_install_pam() { + local src="$1" dst="$2" + sudo install -m 444 -o root -g wheel "$src" "$dst" && rm -f "$src" +} + # Check if Touch ID is already configured is_touchid_configured() { # Check sudo_local first @@ -108,7 +113,7 @@ enable_touchid() { # Clean up legacy config temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then echo -e "${GREEN}${ICON_SUCCESS} Cleanup legacy configuration${NC}" fi fi @@ -138,9 +143,7 @@ enable_touchid() { temp_file=$(create_temp_file) cp "$PAM_SUDO_LOCAL_FILE" "$temp_file" echo "$PAM_TID_LINE" >> "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" - sudo chmod 444 "$PAM_SUDO_LOCAL_FILE" - sudo chown root:wheel "$PAM_SUDO_LOCAL_FILE" + secure_install_pam "$temp_file" "$PAM_SUDO_LOCAL_FILE" write_success=true else write_success=true # Already there (should be caught by first check, but safe fallback) @@ -152,7 +155,7 @@ enable_touchid() { if $is_legacy_configured; then temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_FILE" + secure_install_pam "$temp_file" "$PAM_SUDO_FILE" log_success "Touch ID migrated to sudo_local" else log_success "Touch ID enabled, via sudo_local, try: sudo ls" @@ -201,7 +204,7 @@ enable_touchid() { fi # Apply the changes - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then log_success "Touch ID enabled, try: sudo ls" return 0 else @@ -236,12 +239,12 @@ disable_touchid() { temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_LOCAL_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_LOCAL_FILE" 2> /dev/null; then # Since we modified sudo_local, we should also check if it's in sudo file (legacy cleanup) if grep -q "pam_tid.so" "$PAM_SUDO_FILE"; then temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - sudo mv "$temp_file" "$PAM_SUDO_FILE" + secure_install_pam "$temp_file" "$PAM_SUDO_FILE" fi echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled, removed from sudo_local${NC}" echo "" @@ -266,7 +269,7 @@ disable_touchid() { temp_file=$(create_temp_file) grep -v "pam_tid.so" "$PAM_SUDO_FILE" > "$temp_file" - if sudo mv "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then + if secure_install_pam "$temp_file" "$PAM_SUDO_FILE" 2> /dev/null; then echo -e "${GREEN}${ICON_SUCCESS} Touch ID disabled${NC}" echo "" return 0 @@ -292,7 +295,7 @@ show_menu() { echo "" case "$key" in - $'\e') # ESC + $'\e' | q | Q) # ESC or Q return 0 ;; "" | $'\n' | $'\r') # Enter @@ -310,7 +313,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..be82b6a 100755 --- a/Resources/mole/bin/uninstall.sh +++ b/Resources/mole/bin/uninstall.sh @@ -35,41 +35,11 @@ readonly MOLE_UNINSTALL_META_CACHE_DIR="$HOME/.cache/mole" readonly MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstall_app_metadata_v1" readonly MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" readonly MOLE_UNINSTALL_META_REFRESH_TTL=604800 # 7 days -readonly MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC="0.25" -readonly MOLE_UNINSTALL_INLINE_METADATA_LIMIT=8 -readonly MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC="0.08" - -uninstall_relative_time_from_epoch() { - local value_epoch="${1:-0}" - local now_epoch="${2:-0}" - - if [[ ! "$value_epoch" =~ ^[0-9]+$ || $value_epoch -le 0 ]]; then - echo "Unknown" - return 0 - fi - - local days_ago=$(((now_epoch - value_epoch) / 86400)) - if [[ $days_ago -lt 0 ]]; then - days_ago=0 - fi - - if [[ $days_ago -eq 0 ]]; then - echo "Today" - elif [[ $days_ago -eq 1 ]]; then - echo "Yesterday" - elif [[ $days_ago -lt 7 ]]; then - echo "${days_ago} days ago" - elif [[ $days_ago -lt 30 ]]; then - local weeks_ago=$((days_ago / 7)) - [[ $weeks_ago -eq 1 ]] && echo "1 week ago" || echo "${weeks_ago} weeks ago" - elif [[ $days_ago -lt 365 ]]; then - local months_ago=$((days_ago / 30)) - [[ $months_ago -eq 1 ]] && echo "1 month ago" || echo "${months_ago} months ago" - else - local years_ago=$((days_ago / 365)) - [[ $years_ago -eq 1 ]] && echo "1 year ago" || echo "${years_ago} years ago" - fi -} +readonly MOLE_UNINSTALL_EPOCH_FLOOR=978307200 +# Display-name mdls lookup budget during scan; overridable for slow disks or +# cold Spotlight. +readonly MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC:-0.04}" +readonly MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC:-0.04}" uninstall_normalize_size_display() { local size="${1:-}" @@ -91,6 +61,23 @@ uninstall_normalize_last_used_display() { echo "$display" } +uninstall_quick_app_size_kb() { + local app_path="$1" + [[ -n "$app_path" && -d "$app_path" ]] || { + echo "0" + return 0 + } + + local logical_size + logical_size=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC" mdls -name kMDItemLogicalSize -raw "$app_path" 2> /dev/null || echo "") + if [[ "$logical_size" =~ ^[0-9]+$ && "$logical_size" -gt 0 ]]; then + echo $(((logical_size + 1023) / 1024)) + return 0 + fi + + echo "0" +} + uninstall_resolve_display_name() { local app_path="$1" local app_name="$2" @@ -99,11 +86,11 @@ uninstall_resolve_display_name() { if [[ -f "$app_path/Contents/Info.plist" ]]; then local md_display_name if [[ -n "$MOLE_UNINSTALL_USER_LC_ALL" ]]; then - md_display_name=$(run_with_timeout 0.04 env LC_ALL="$MOLE_UNINSTALL_USER_LC_ALL" LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" env LC_ALL="$MOLE_UNINSTALL_USER_LC_ALL" LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") elif [[ -n "$MOLE_UNINSTALL_USER_LANG" ]]; then - md_display_name=$(run_with_timeout 0.04 env LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" env LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") else - md_display_name=$(run_with_timeout 0.04 mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") + md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") fi local bundle_display_name @@ -135,6 +122,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,32 +173,29 @@ uninstall_release_metadata_lock() { [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true } -uninstall_collect_inline_metadata() { - local app_path="$1" - local app_mtime="${2:-0}" - local now_epoch="${3:-0}" - - local size_kb - size_kb=$(get_path_size_kb "$app_path") - [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 +# 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" - local last_used_epoch=0 - local metadata_date - metadata_date=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_TIMEOUT_SEC" mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") - if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then - last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") - fi + [[ -s "$src" ]] || { + rm -f "$src" 2> /dev/null || true + return 0 + } - # Fallback to app mtime so first scan does not show "...". - if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then - if [[ "$app_mtime" =~ ^[0-9]+$ && $app_mtime -gt 0 ]]; then - last_used_epoch="$app_mtime" - else - last_used_epoch=0 - fi + # 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 - printf "%s|%s|%s\n" "$size_kb" "$last_used_epoch" "$now_epoch" + # 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 + } } start_uninstall_metadata_refresh() { @@ -258,12 +252,12 @@ start_uninstall_metadata_refresh() { ( local last_used_epoch=0 local metadata_date - metadata_date=$(run_with_timeout 0.2 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") + metadata_date=$(run_with_timeout 0.2 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") # 0.2s: per-app probe in tight scan loop, see lib/core/timeouts.sh if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then last_used_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") fi - if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 ]]; then + if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 || $last_used_epoch -lt $MOLE_UNINSTALL_EPOCH_FLOOR ]]; then last_used_epoch=0 fi @@ -304,8 +298,8 @@ start_uninstall_metadata_refresh() { exit 0 fi - local merged_file - merged_file=$(mktemp 2> /dev/null) || { + local refresh_merged_file + refresh_merged_file=$(mktemp 2> /dev/null) || { _refresh_debug "mktemp for merge failed, aborting" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" rm -f "$updates_file" @@ -320,131 +314,26 @@ start_uninstall_metadata_refresh() { print updates[path] } } - ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$merged_file" + ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$refresh_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 "$refresh_merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" - rm -f "$updates_file" + rm -f "$updates_file" "$refresh_merged_file" rm -f "$refresh_file" 2> /dev/null || true ) > /dev/null 2>&1 & + disown "$!" 2> /dev/null || true } -# Scan applications and collect information. -scan_applications() { - local temp_file scan_raw_file merged_file refresh_file cache_snapshot_file - temp_file=$(create_temp_file) - scan_raw_file="${temp_file}.scan" - merged_file="${temp_file}.merged" - refresh_file="${temp_file}.refresh" - cache_snapshot_file="${temp_file}.cache" - local scan_status_file="${temp_file}.scan_status" - : > "$scan_raw_file" - : > "$refresh_file" - : > "$cache_snapshot_file" - : > "$scan_status_file" - - ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" - ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" - local cache_source="$MOLE_UNINSTALL_META_CACHE_FILE" - local cache_source_is_temp=false - if [[ ! -r "$cache_source" ]]; then - cache_source=$(create_temp_file) - : > "$cache_source" - cache_source_is_temp=true - fi - - # Fast lookup cache for unchanged apps: path+mtime -> bundle_id/display_name. - local -a cache_paths=() - local -a cache_mtimes=() - local -a cache_bundle_ids=() - local -a cache_display_names=() - local cache_path cache_mtime _cache_size _cache_epoch _cache_updated cache_bundle cache_display - while IFS='|' read -r cache_path cache_mtime _cache_size _cache_epoch _cache_updated cache_bundle cache_display; do - [[ -n "$cache_path" ]] || continue - cache_paths+=("$cache_path") - cache_mtimes+=("${cache_mtime:-0}") - cache_bundle_ids+=("${cache_bundle:-}") - cache_display_names+=("${cache_display:-}") - done < "$cache_source" - - lookup_cached_identity() { - local target_path="$1" - local target_mtime="$2" - local idx - for ((idx = 0; idx < ${#cache_paths[@]}; idx++)); do - if [[ "${cache_paths[idx]}" == "$target_path" ]]; then - if [[ "${cache_mtimes[idx]:-0}" == "${target_mtime:-0}" ]]; then - echo "${cache_bundle_ids[idx]:-}|${cache_display_names[idx]:-}" - else - echo "|" - fi - return 0 - fi - done - echo "|" - } - - # Local spinner_pid for cleanup - local spinner_pid="" - local spinner_shown_file="${temp_file}.spinner_shown" - local previous_int_trap="" - previous_int_trap=$(trap -p INT || true) - - restore_scan_int_trap() { - if [[ -n "$previous_int_trap" ]]; then - eval "$previous_int_trap" - else - trap - INT - fi - } - - # Trap to handle Ctrl+C during scan - # shellcheck disable=SC2329 # Function invoked indirectly via trap - trap_scan_cleanup() { - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - fi - if [[ -f "$spinner_shown_file" ]]; then - printf "\r\033[K" >&2 - fi - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true - exit 130 - } - trap trap_scan_cleanup INT - - update_scan_status() { - local message="$1" - local completed="${2:-0}" - local total="${3:-0}" - printf "%s|%s|%s\n" "$message" "$completed" "$total" > "$scan_status_file" - } - - stop_scan_spinner() { - if [[ -n "$spinner_pid" ]]; then - kill -TERM "$spinner_pid" 2> /dev/null || true - wait "$spinner_pid" 2> /dev/null || true - spinner_pid="" - fi - if [[ -f "$spinner_shown_file" ]]; then - printf "\r\033[K" >&2 - fi - rm -f "$spinner_shown_file" "$scan_status_file" 2> /dev/null || true - } - - # Pass 1: collect app paths and bundle IDs (no mdls). - local -a app_data_tuples=() +uninstall_print_app_search_dirs() { local -a app_dirs=( "/Applications" "$HOME/Applications" "/Library/Input Methods" "$HOME/Library/Input Methods" ) + local vol_app_dir local nullglob_was_set=0 shopt -q nullglob && nullglob_was_set=1 @@ -463,61 +352,280 @@ scan_applications() { shopt -u nullglob fi - for app_dir in "${app_dirs[@]}"; do - if [[ ! -d "$app_dir" ]]; then continue; fi + printf '%s\n' "${app_dirs[@]}" +} - while IFS= read -r -d '' app_path; do - if [[ ! -e "$app_path" ]]; then continue; fi +uninstall_should_skip_app_path() { + local app_path="$1" - local app_name - app_name=$(basename "$app_path" .app) + [[ -e "$app_path" ]] || return 0 - # Skip nested apps inside another .app bundle. - local parent_dir - parent_dir=$(dirname "$app_path") - if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then - continue + # Skip nested apps inside another .app bundle. + local parent_dir="${app_path%/*}" + if [[ "$parent_dir" == *".app" || "$parent_dir" == *".app/"* ]]; then + return 0 + fi + + if [[ -L "$app_path" ]]; then + local link_target + link_target=$(readlink "$app_path" 2> /dev/null) + if [[ -n "$link_target" ]]; then + local resolved_target="$link_target" + if [[ "$link_target" != /* ]]; then + 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/*) + return 0 + ;; + esac + fi + fi - if [[ -L "$app_path" ]]; then - local link_target - link_target=$(readlink "$app_path" 2> /dev/null) - 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 "" - fi - case "$resolved_target" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) - continue - ;; - esac - fi + return 1 +} + +uninstall_resolve_bundle_id() { + local app_path="$1" + local fallback_bundle_id="${2:-}" + local bundle_id="" + local plist="$app_path/Contents/Info.plist" + + fallback_bundle_id="${fallback_bundle_id//|/-}" + fallback_bundle_id="${fallback_bundle_id//[$'\t\r\n']/}" + + if [[ -f "$plist" ]]; then + bundle_id=$(plutil -extract CFBundleIdentifier raw "$plist" 2> /dev/null || echo "") + bundle_id="${bundle_id//|/-}" + bundle_id="${bundle_id//[$'\t\r\n']/}" + fi + + if [[ -n "$bundle_id" && "$bundle_id" != "(null)" ]]; then + printf '%s\n' "$bundle_id" + return 0 + fi + + if [[ -n "$fallback_bundle_id" && "$fallback_bundle_id" != "(null)" ]]; then + printf '%s\n' "$fallback_bundle_id" + return 0 + fi + + printf '%s\n' "unknown" +} + +uninstall_app_is_background_only() { + local app_path="$1" + local plist="$app_path/Contents/Info.plist" + [[ -f "$plist" ]] || return 1 + + local bg_only + bg_only=$(plutil -extract LSBackgroundOnly raw "$plist" 2> /dev/null || echo "") + case "$bg_only" in + 1 | YES | yes | TRUE | true) + return 0 + ;; + esac + + return 1 +} + +uninstall_app_is_top_level_onedrive() { + local app_path="$1" + local bundle_id="${2:-}" + + [[ "$bundle_id" == com.microsoft.OneDrive* ]] || return 1 + + case "$app_path" in + /Applications/OneDrive.app | "$HOME"/Applications/OneDrive.app) + return 0 + ;; + esac + + return 1 +} + +uninstall_app_is_currently_eligible() { + local app_path="$1" + local bundle_id="${2:-}" + + [[ -n "$app_path" && -e "$app_path" ]] || return 1 + + if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] && should_protect_from_uninstall "$bundle_id"; then + return 1 + fi + + if uninstall_app_is_background_only "$app_path" && ! uninstall_app_is_top_level_onedrive "$app_path" "$bundle_id"; then + return 1 + fi + + return 0 +} + +uninstall_resolve_eligible_bundle_id() { + local app_path="$1" + local fallback_bundle_id="${2:-}" + local bundle_id + + bundle_id=$(uninstall_resolve_bundle_id "$app_path" "$fallback_bundle_id") + uninstall_app_is_currently_eligible "$app_path" "$bundle_id" || return 1 + printf '%s\n' "$bundle_id" +} + +uninstall_print_app_paths_with_mtime() { + local app_dir="$1" + local app_path app_mtime + + [[ -d "$app_dir" ]] || return 0 + + while IFS= read -r -d '' app_path; do + [[ -n "$app_path" ]] || continue + app_mtime=$(get_file_mtime "$app_path") + printf '%s\t%s\n' "${app_mtime:-0}" "$app_path" + done < <(command find "$app_dir" -maxdepth 3 -name "*.app" -print0 2> /dev/null) +} + +uninstall_app_inventory_fingerprint() { + local app_dir app_path app_mtime pkg_app_path + + { + while IFS= read -r pkg_app_path; do + [[ -n "$pkg_app_path" && -d "$pkg_app_path" ]] || continue + app_mtime=$(get_file_mtime "$pkg_app_path") + printf '%s|%s\n' "$pkg_app_path" "${app_mtime:-0}" + done < <(pkg_receipt_nonstandard_app_paths) + + while IFS= read -r app_dir; do + [[ -d "$app_dir" ]] || continue + while IFS=$'\t' read -r app_mtime app_path; do + [[ -n "$app_path" ]] || continue + uninstall_should_skip_app_path "$app_path" && continue + printf '%s|%s\n' "$app_path" "${app_mtime:-0}" + done < <(uninstall_print_app_paths_with_mtime "$app_dir") + done < <(uninstall_print_app_search_dirs) + } | sort -u +} + +# Internal helpers for scan_applications. They read and write locals +# declared in the orchestrator's scope via bash dynamic scoping; do not +# call them outside scan_applications. + +# Phase 2 (Pass 1): discover candidate .app paths by combining the +# configured app search directories with pkg-receipt non-standard install +# locations, skipping bundles flagged by uninstall_should_skip_app_path. +# Each row in discovered_file is encoded as ||. +# Writes: discovered_file +_scan_discover_apps() { + local -a app_dirs=() + local app_dir + while IFS= read -r app_dir; do + [[ -n "$app_dir" ]] && app_dirs+=("$app_dir") + done < <(uninstall_print_app_search_dirs) + + # Scan for pkg-installed apps in non-standard locations. + local pkg_app_path + while IFS= read -r pkg_app_path; do + [[ -n "$pkg_app_path" ]] || continue + + local already_scanned=false + for app_dir in "${app_dirs[@]}"; do + if [[ "$pkg_app_path" == "$app_dir"/*.app ]]; then + already_scanned=true + break fi + done + [[ "$already_scanned" == true ]] && continue + + local app_name="${pkg_app_path##*/}" + app_name="${app_name%.app}" - local app_mtime - app_mtime=$(get_file_mtime "$app_path") + local app_mtime + app_mtime=$(get_file_mtime "$pkg_app_path") - local cached_identity cached_bundle_id cached_display_name - cached_identity=$(lookup_cached_identity "$app_path" "$app_mtime") - IFS='|' read -r cached_bundle_id cached_display_name <<< "$cached_identity" + printf "%s|%s|%s\n" "$pkg_app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" + done < <(pkg_receipt_nonstandard_app_paths) - # Store tuple for pass 2 (bundle + display resolution, then cache merge). - app_data_tuples+=("${app_path}|${app_name}|${app_mtime}|${cached_bundle_id}|${cached_display_name}") - done < <(command find "$app_dir" -name "*.app" -maxdepth 3 -print0 2> /dev/null) + for app_dir in "${app_dirs[@]}"; do + if [[ ! -d "$app_dir" ]]; then continue; fi + + while IFS=$'\t' read -r app_mtime app_path; do + if [[ ! -e "$app_path" ]]; then continue; fi + + local app_name="${app_path##*/}" + app_name="${app_name%.app}" + + uninstall_should_skip_app_path "$app_path" && continue + + printf "%s|%s|%s\n" "$app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" + done < <(uninstall_print_app_paths_with_mtime "$app_dir") done +} - if [[ ${#app_data_tuples[@]} -eq 0 ]]; then - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true - [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true - restore_scan_int_trap - printf "\r\033[K" >&2 - echo "No applications found to uninstall." >&2 - return 1 +# Phase 3: partition discovered apps into warm-cache rows (written +# directly to scan_raw_file) and cold rows (queued in app_data_tuples +# for parallel metadata resolution in _scan_resolve_uncached). +# Reads: cache_source, discovered_file +# Writes: cached_rows_file, uncached_rows_file, scan_raw_file (via the +# nested use_cached_scan_metadata helper), app_data_tuples +_scan_partition_cache() { + use_cached_scan_metadata() { + local cached_app_path="$1" + local cached_app_mtime="$2" + local cached_bundle_id="$3" + local cached_display_name="$4" + local cached_size_kb="$5" + + [[ -n "$cached_bundle_id" && -n "$cached_display_name" ]] || return 1 + [[ "$cached_size_kb" =~ ^[0-9]+$ && "$cached_size_kb" -gt 0 ]] || return 1 + + cached_bundle_id=$(uninstall_resolve_eligible_bundle_id "$cached_app_path" "$cached_bundle_id") || return 1 + + printf "%s|%s|%s|%s|%s\n" "$cached_app_path" "$cached_display_name" "$cached_bundle_id" "$cached_app_mtime" "$cached_size_kb" >> "$scan_raw_file" + return 0 + } + + if [[ -s "$discovered_file" ]]; then + awk -F'|' -v cached_out="$cached_rows_file" -v uncached_out="$uncached_rows_file" ' + FILENAME == ARGV[1] { + cache_mtime[$1] = $2 + cache_size[$1] = $3 + cache_bundle[$1] = $6 + cache_display[$1] = $7 + next + } + { + path = $1 + app_mtime = $3 + if (cache_mtime[path] == app_mtime && cache_display[path] != "" && cache_size[path] ~ /^[0-9]+$/ && cache_size[path] > 0) { + cached_bundle = cache_bundle[path] == "" ? "unknown" : cache_bundle[path] + print path "|" app_mtime "|" cached_bundle "|" cache_display[path] "|" cache_size[path] >> cached_out + } else { + print path "|" $2 "|" app_mtime "|" cache_bundle[path] "|" cache_display[path] >> uncached_out + } + } + ' "$cache_source" "$discovered_file" + + local cached_app_path cached_app_mtime cached_bundle_id cached_display_name cached_size_kb + while IFS='|' read -r cached_app_path cached_app_mtime cached_bundle_id cached_display_name cached_size_kb; do + use_cached_scan_metadata "$cached_app_path" "$cached_app_mtime" "$cached_bundle_id" "$cached_display_name" "$cached_size_kb" || true + done < "$cached_rows_file" + + local uncached_app_path uncached_app_name uncached_app_mtime uncached_bundle_id uncached_display_name + while IFS='|' read -r uncached_app_path uncached_app_name uncached_app_mtime uncached_bundle_id uncached_display_name; do + app_data_tuples+=("${uncached_app_path}|${uncached_app_name}|${uncached_app_mtime}|${uncached_bundle_id}|${uncached_display_name}") + done < "$uncached_rows_file" fi - # Pass 2: resolve display names in parallel. +} + +# Phase 5 (Pass 2): resolve display names and bundle IDs in parallel for +# the cold rows queued by _scan_partition_cache. Spawns the progress +# spinner subprocess (assigns spinner_pid), fans out workers up to +# max_parallel, and waits for completion. +# Reads: app_data_tuples +# Writes: scan_raw_file (appended by worker subshells) +_scan_resolve_uncached() { local app_count=0 local total_apps=${#app_data_tuples[@]} local max_parallel @@ -535,17 +643,8 @@ scan_applications() { IFS='|' read -r app_path app_name app_mtime cached_bundle_id cached_display_name <<< "$app_data_tuple" - local bundle_id="${cached_bundle_id:-}" - if [[ -z "$bundle_id" ]]; then - bundle_id="unknown" - if [[ -f "$app_path/Contents/Info.plist" ]]; then - bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "unknown") - fi - fi - - if should_protect_from_uninstall "$bundle_id"; then - return 0 - fi + local bundle_id + bundle_id=$(uninstall_resolve_eligible_bundle_id "$app_path" "${cached_bundle_id:-}") || return 0 local display_name="${cached_display_name:-}" if [[ -z "$display_name" ]]; then @@ -556,64 +655,116 @@ scan_applications() { display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" - echo "${app_path}|${display_name}|${bundle_id}|${app_mtime}" >> "$output_file" + local quick_size_kb + quick_size_kb=$(uninstall_quick_app_size_kb "$app_path") + [[ "$quick_size_kb" =~ ^[0-9]+$ ]] || quick_size_kb=0 + + echo "${app_path}|${display_name}|${bundle_id}|${app_mtime}|${quick_size_kb}" >> "$output_file" } update_scan_status "Scanning applications..." "0" "$total_apps" - ( - # shellcheck disable=SC2329 # Function invoked indirectly via trap - cleanup_spinner() { exit 0; } - trap cleanup_spinner TERM INT EXIT - sleep "$MOLE_UNINSTALL_SCAN_SPINNER_DELAY_SEC" 2> /dev/null || sleep 1 - [[ -f "$scan_status_file" ]] || exit 0 - local spinner_chars="|/-\\" - local i=0 - : > "$spinner_shown_file" - while true; do - local status_line status_message status_completed status_total - status_line=$(cat "$scan_status_file" 2> /dev/null || echo "") - IFS='|' read -r status_message status_completed status_total <<< "$status_line" - [[ -z "$status_message" ]] && status_message="Scanning applications..." - local c="${spinner_chars:$((i % 4)):1}" - if [[ "$status_completed" =~ ^[0-9]+$ && "$status_total" =~ ^[0-9]+$ && $status_total -gt 0 ]]; then - printf "\r\033[K%s %s %d/%d" "$c" "$status_message" "$status_completed" "$status_total" >&2 - else - printf "\r\033[K%s %s" "$c" "$status_message" >&2 + # Skip Pass 2 when the warm cache already wrote every row to $scan_raw_file. + # Also avoids expanding an empty array — macOS bash 3.2 (the /bin/bash that + # this script targets) treats `"${empty[@]}"` as unbound under `set -u`. + if ((total_apps > 0)); then + for app_data_tuple in "${app_data_tuples[@]}"; do + ((app_count++)) + process_app_metadata "$app_data_tuple" "$scan_raw_file" & + pids+=($!) + update_scan_status "Scanning applications..." "$app_count" "$total_apps" + + if ((${#pids[@]} >= max_parallel)); then + wait "${pids[0]}" 2> /dev/null + pids=("${pids[@]:1}") fi - ((i++)) - sleep 0.1 2> /dev/null || sleep 1 done - ) & - spinner_pid=$! - for app_data_tuple in "${app_data_tuples[@]}"; do - ((app_count++)) - process_app_metadata "$app_data_tuple" "$scan_raw_file" & - pids+=($!) - update_scan_status "Scanning applications..." "$app_count" "$total_apps" + for pid in "${pids[@]}"; do + wait "$pid" 2> /dev/null + done + fi +} - if ((${#pids[@]} >= max_parallel)); then - wait "${pids[0]}" 2> /dev/null - pids=("${pids[@]:1}") - fi - done +# Phase 6: collapse duplicate bundle IDs discovered from backup volumes or +# mirrored Applications folders. Keep the live app locations first. +_scan_dedupe_bundle_ids() { + [[ -s "$scan_raw_file" ]] || return 0 - for pid in "${pids[@]}"; do - wait "$pid" 2> /dev/null - done + local deduped_file="${scan_raw_file}.deduped" + if ! awk -F'|' -v home_apps="$HOME/Applications/" ' + function starts_with(value, prefix) { + return prefix != "" && substr(value, 1, length(prefix)) == prefix + } + function direct_app_under(path, prefix, rest) { + if (!starts_with(path, prefix)) { + return 0 + } + rest = substr(path, length(prefix) + 1) + return index(rest, "/") == 0 && rest ~ /[.]app$/ + } + function path_rank(path) { + if (direct_app_under(path, "/Applications/")) { + return 1 + } + if (direct_app_under(path, home_apps)) { + return 2 + } + if (starts_with(path, "/Volumes/")) { + return 4 + } + return 3 + } + { + bundle_id = $3 + if (bundle_id == "" || bundle_id == "unknown") { + key = "__path__" NR + rows[key] = $0 + order[++count] = key + next + } - update_scan_status "Building uninstall index..." "0" "0" + rank = path_rank($1) + if (!(bundle_id in rows)) { + rows[bundle_id] = $0 + ranks[bundle_id] = rank + order[++count] = bundle_id + next + } + if (rank < ranks[bundle_id]) { + rows[bundle_id] = $0 + ranks[bundle_id] = rank + } + } + END { + for (i = 1; i <= count; i++) { + key = order[i] + if (key in rows) { + print rows[key] + } + } + } + ' "$scan_raw_file" > "$deduped_file"; then + rm -f "$deduped_file" 2> /dev/null || true + return 0 + fi - if [[ ! -s "$scan_raw_file" ]]; then - stop_scan_spinner - echo "No applications found to uninstall" >&2 - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true - [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true - restore_scan_int_trap - return 1 + if ! mv "$deduped_file" "$scan_raw_file" 2> /dev/null; then + rm -f "$deduped_file" 2> /dev/null || true fi +} +# Phase 7+8: merge scan_raw_file with the persistent metadata cache, +# compute display size / last-used / refresh-needed flags via the embedded awk +# pipeline, persist the cache snapshot under a lock, sort the result by epoch, +# kick off the deferred background refresh, and echo the sorted index path for +# the caller to capture. +# Reads: scan_raw_file, cache_source +# Writes: merged_file, refresh_file, cache_snapshot_file, temp_file, +# ${temp_file}.sorted, MOLE_UNINSTALL_META_CACHE_FILE +# Returns: 0 on success (sorted path is echoed on stdout), 1 if sort +# fails or the sorted file did not materialize. +_scan_finalize_index() { update_scan_status "Merging cache data..." "0" "0" awk -F'|' ' NR == FNR { @@ -635,104 +786,136 @@ scan_applications() { local current_epoch current_epoch=$(get_epoch_seconds) - local inline_metadata_count=0 local metadata_total=0 metadata_total=$(wc -l < "$merged_file" 2> /dev/null || echo "0") [[ "$metadata_total" =~ ^[0-9]+$ ]] || metadata_total=0 - local metadata_processed=0 update_scan_status "Collecting metadata..." "0" "$metadata_total" - while IFS='|' read -r app_path display_name bundle_id app_mtime cached_mtime cached_size_kb cached_epoch cached_updated_epoch cached_bundle_id cached_display_name; do - ((metadata_processed++)) - if ((metadata_processed % 5 == 0 || metadata_processed == metadata_total)); then - update_scan_status "Collecting metadata..." "$metadata_processed" "$metadata_total" - fi - - [[ -n "$app_path" && -e "$app_path" ]] || continue - - local cache_match=false - if [[ -n "$cached_mtime" && -n "$app_mtime" && "$cached_mtime" == "$app_mtime" ]]; then - cache_match=true - fi - - local final_epoch=0 - if [[ "$cached_epoch" =~ ^[0-9]+$ && $cached_epoch -gt 0 ]]; then - final_epoch="$cached_epoch" - fi - - local final_size_kb=0 - local final_size="N/A" - 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))") - fi - - # Fallback to app mtime to avoid unknown "last used" on first scan. - if [[ ! "$final_epoch" =~ ^[0-9]+$ || $final_epoch -le 0 ]]; then - if [[ "$app_mtime" =~ ^[0-9]+$ && $app_mtime -gt 0 ]]; then - final_epoch="$app_mtime" - fi - fi + awk -F'|' \ + -v now="$current_epoch" \ + -v floor="$MOLE_UNINSTALL_EPOCH_FLOOR" \ + -v ttl="$MOLE_UNINSTALL_META_REFRESH_TTL" \ + -v refresh_out="$refresh_file" \ + -v snapshot_out="$cache_snapshot_file" \ + -v apps_out="$temp_file" ' + function isnum(value) { + return value ~ /^[0-9]+$/ + } + function human_size(kb, bytes, scaled) { + if (!isnum(kb) || kb <= 0) { + return "--" + } + bytes = kb * 1024 + if (bytes >= 1000000000) { + scaled = int((bytes * 100 + 500000000) / 1000000000) + return sprintf("%d.%02dGB", int(scaled / 100), scaled % 100) + } + if (bytes >= 1000000) { + scaled = int((bytes * 10 + 500000) / 1000000) + return sprintf("%d.%01dMB", int(scaled / 10), scaled % 10) + } + if (bytes >= 1000) { + return sprintf("%dKB", int((bytes + 500) / 1000)) + } + return sprintf("%dB", bytes) + } + function relative_time(epoch, now_epoch, days_ago, weeks_ago, months_ago, years_ago) { + if (!isnum(epoch) || epoch <= 0 || epoch < floor) { + return "Unknown" + } + days_ago = int((now_epoch - epoch) / 86400) + if (days_ago < 0) { + days_ago = 0 + } + if (days_ago == 0) { + return "Today" + } + if (days_ago == 1) { + return "Yesterday" + } + if (days_ago < 7) { + return days_ago " days ago" + } + if (days_ago < 30) { + weeks_ago = int(days_ago / 7) + return weeks_ago == 1 ? "1 week ago" : weeks_ago " weeks ago" + } + if (days_ago < 365) { + months_ago = int(days_ago / 30) + return months_ago == 1 ? "1 month ago" : months_ago " months ago" + } + years_ago = int(days_ago / 365) + return years_ago == 1 ? "1 year ago" : years_ago " years ago" + } + { + app_path = $1 + display_name = $2 + bundle_id = $3 + app_mtime = $4 + if (NF >= 11) { + inline_size_kb = $5 + cached_mtime = $6 + cached_size_kb = $7 + cached_epoch = $8 + cached_updated_epoch = $9 + cached_bundle_id = $10 + cached_display_name = $11 + } else { + inline_size_kb = 0 + cached_mtime = $5 + cached_size_kb = $6 + cached_epoch = $7 + cached_updated_epoch = $8 + cached_bundle_id = $9 + cached_display_name = $10 + } - local final_last_used - final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") - - local needs_refresh=false - if [[ $cache_match == false ]]; then - needs_refresh=true - elif [[ ! "$cached_size_kb" =~ ^[0-9]+$ || $cached_size_kb -le 0 ]]; then - needs_refresh=true - elif [[ ! "$cached_epoch" =~ ^[0-9]+$ || $cached_epoch -le 0 ]]; then - needs_refresh=true - elif [[ ! "$cached_updated_epoch" =~ ^[0-9]+$ ]]; then - needs_refresh=true - elif [[ -z "$cached_bundle_id" || -z "$cached_display_name" ]]; then - needs_refresh=true - else - local cache_age=$((current_epoch - cached_updated_epoch)) - if [[ $cache_age -gt $MOLE_UNINSTALL_META_REFRESH_TTL ]]; then - needs_refresh=true - fi - fi + cache_match = (cached_mtime != "" && app_mtime != "" && cached_mtime == app_mtime) - if [[ $needs_refresh == true ]]; then - if [[ $inline_metadata_count -lt $MOLE_UNINSTALL_INLINE_METADATA_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" - ((inline_metadata_count++)) + final_epoch = (isnum(cached_epoch) && cached_epoch > 0) ? cached_epoch : 0 + if (isnum(final_epoch) && final_epoch < floor) { + final_epoch = 0 + } + if ((!isnum(final_epoch) || final_epoch <= 0) && isnum(app_mtime) && app_mtime > floor) { + final_epoch = app_mtime + } - if [[ "$inline_size_kb" =~ ^[0-9]+$ && $inline_size_kb -gt 0 ]]; then - final_size_kb="$inline_size_kb" - final_size=$(bytes_to_human "$((inline_size_kb * 1024))") - fi - if [[ "$inline_epoch" =~ ^[0-9]+$ && $inline_epoch -gt 0 ]]; then - final_epoch="$inline_epoch" - final_last_used=$(uninstall_relative_time_from_epoch "$final_epoch" "$current_epoch") - fi - if [[ "$inline_updated_epoch" =~ ^[0-9]+$ && $inline_updated_epoch -gt 0 ]]; then - cached_updated_epoch="$inline_updated_epoch" - fi - fi - printf "%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$bundle_id" "$display_name" >> "$refresh_file" - fi + final_size_kb = (isnum(cached_size_kb) && cached_size_kb > 0) ? cached_size_kb : 0 + if ((!isnum(final_size_kb) || final_size_kb <= 0) && isnum(inline_size_kb) && inline_size_kb > 0) { + final_size_kb = inline_size_kb + } + final_size = human_size(final_size_kb) + final_last_used = relative_time(final_epoch, now) + + needs_refresh = 0 + if (!cache_match) { + needs_refresh = 1 + } else if (!isnum(cached_size_kb) || cached_size_kb <= 0) { + needs_refresh = 1 + } else if (!isnum(cached_epoch) || cached_epoch <= 0) { + needs_refresh = 1 + } else if (!isnum(cached_updated_epoch)) { + needs_refresh = 1 + } else if (cached_bundle_id == "" || cached_display_name == "") { + needs_refresh = 1 + } else if ((now - cached_updated_epoch) > ttl) { + needs_refresh = 1 + } - local persist_updated_epoch=0 - if [[ "$cached_updated_epoch" =~ ^[0-9]+$ && $cached_updated_epoch -gt 0 ]]; then - persist_updated_epoch="$cached_updated_epoch" - fi - printf "%s|%s|%s|%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "${final_size_kb:-0}" "${final_epoch:-0}" "${persist_updated_epoch:-0}" "$bundle_id" "$display_name" >> "$cache_snapshot_file" + if (needs_refresh) { + print app_path "|" app_mtime "|" bundle_id "|" display_name >> refresh_out + } - echo "${final_epoch}|${app_path}|${display_name}|${bundle_id}|${final_size}|${final_last_used}|${final_size_kb}" >> "$temp_file" - done < "$merged_file" + persist_updated_epoch = (isnum(cached_updated_epoch) && cached_updated_epoch > 0) ? cached_updated_epoch : 0 + print app_path "|" app_mtime "|" final_size_kb "|" final_epoch "|" persist_updated_epoch "|" bundle_id "|" display_name >> snapshot_out + print final_epoch "|" app_path "|" display_name "|" bundle_id "|" final_size "|" final_last_used "|" final_size_kb >> apps_out + } + ' "$merged_file" 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 @@ -740,12 +923,12 @@ scan_applications() { update_scan_status "Sorting application list..." "0" "0" sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { stop_scan_spinner - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap return 1 } - rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$cache_snapshot_file" + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true update_scan_status "Finalizing list..." "0" "0" @@ -753,6 +936,7 @@ scan_applications() { stop_scan_spinner if [[ -f "${temp_file}.sorted" ]]; then + register_temp_file "${temp_file}.sorted" restore_scan_int_trap echo "${temp_file}.sorted" return 0 @@ -762,6 +946,157 @@ scan_applications() { fi } +# Scan applications and collect information. Orchestrates the four +# phases (discover, partition, resolve, finalize) and owns the shared +# temp files, spinner subprocess, INT trap, and metadata cache lock. +scan_applications() { + local temp_file scan_raw_file merged_file refresh_file cache_snapshot_file discovered_file cached_rows_file uncached_rows_file + temp_file=$(create_temp_file) + scan_raw_file="${temp_file}.scan" + merged_file="${temp_file}.merged" + refresh_file="${temp_file}.refresh" + cache_snapshot_file="${temp_file}.cache" + discovered_file="${temp_file}.discovered" + cached_rows_file="${temp_file}.cached_rows" + uncached_rows_file="${temp_file}.uncached_rows" + local scan_status_file="${temp_file}.scan_status" + : > "$scan_raw_file" + : > "$refresh_file" + : > "$cache_snapshot_file" + : > "$discovered_file" + : > "$cached_rows_file" + : > "$uncached_rows_file" + : > "$scan_status_file" + + ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" + ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" + local cache_source="$MOLE_UNINSTALL_META_CACHE_FILE" + local cache_source_is_temp=false + if [[ ! -r "$cache_source" ]]; then + cache_source=$(create_temp_file) + : > "$cache_source" + cache_source_is_temp=true + fi + + # Local spinner_pid for cleanup + local spinner_pid="" + local spinner_shown_file="${temp_file}.spinner_shown" + local previous_int_trap="" + previous_int_trap=$(trap -p INT || true) + + restore_scan_int_trap() { + if [[ -n "$previous_int_trap" ]]; then + # eval: restore previous trap captured by $(trap -p INT) + eval "$previous_int_trap" + else + trap - INT + fi + } + + # Trap to handle Ctrl+C during scan + # shellcheck disable=SC2329 # Function invoked indirectly via trap + trap_scan_cleanup() { + if [[ -n "$spinner_pid" ]]; then + kill -TERM "$spinner_pid" 2> /dev/null || true + wait "$spinner_pid" 2> /dev/null || true + fi + if [[ -f "$spinner_shown_file" ]]; then + printf "\r\033[K" >&2 + fi + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true + exit 130 + } + trap trap_scan_cleanup INT + + update_scan_status() { + local message="$1" + local completed="${2:-0}" + local total="${3:-0}" + printf "%s|%s|%s\n" "$message" "$completed" "$total" > "$scan_status_file" + } + + start_scan_spinner() { + [[ -n "$spinner_pid" ]] && return 0 + [[ -t 2 || "${MOLE_TEST_FORCE_SCAN_SPINNER:-0}" == "1" ]] || return 0 + ( + # shellcheck disable=SC2329 # Function invoked indirectly via trap + cleanup_spinner() { exit 0; } + trap cleanup_spinner TERM INT EXIT + [[ -f "$scan_status_file" ]] || exit 0 + local spinner_chars="|/-\\" + local i=0 + : > "$spinner_shown_file" + while true; do + local status_line status_message status_completed status_total + status_line=$(cat "$scan_status_file" 2> /dev/null || echo "") + IFS='|' read -r status_message status_completed status_total <<< "$status_line" + [[ -z "$status_message" ]] && status_message="Scanning applications..." + local c="${spinner_chars:$((i % 4)):1}" + if [[ "$status_completed" =~ ^[0-9]+$ && "$status_total" =~ ^[0-9]+$ && $status_total -gt 0 ]]; then + printf "\r\033[K%s %s %d/%d" "$c" "$status_message" "$status_completed" "$status_total" >&2 + else + printf "\r\033[K%s %s" "$c" "$status_message" >&2 + fi + ((i++)) + sleep 0.1 2> /dev/null || sleep 1 + done + ) & + spinner_pid=$! + } + + stop_scan_spinner() { + if [[ -n "$spinner_pid" ]]; then + kill -TERM "$spinner_pid" 2> /dev/null || true + wait "$spinner_pid" 2> /dev/null || true + spinner_pid="" + fi + if [[ -f "$spinner_shown_file" ]]; then + printf "\r\033[K" >&2 + fi + rm -f "$spinner_shown_file" "$scan_status_file" 2> /dev/null || true + } + + update_scan_status "Scanning applications..." "0" "0" + start_scan_spinner + + # Phase 2: discover candidate apps. + _scan_discover_apps + + # Phase 3: partition into warm-cache and cold rows. + local -a app_data_tuples=() + _scan_partition_cache + + # Phase 4: bail out if discovery yielded nothing. + if [[ ${#app_data_tuples[@]} -eq 0 && ! -s "$scan_raw_file" ]]; then + stop_scan_spinner + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap + printf "\r\033[K" >&2 + echo "No applications found to uninstall." >&2 + return 1 + fi + # Phase 5: parallel metadata resolution for cold rows. + _scan_resolve_uncached + + # Phase 6: bail out if Pass 2 produced nothing. + update_scan_status "Building uninstall index..." "0" "0" + + if [[ ! -s "$scan_raw_file" ]]; then + stop_scan_spinner + echo "No applications found to uninstall" >&2 + rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true + [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true + restore_scan_int_trap + return 1 + fi + + _scan_dedupe_bundle_ids + + # Phase 7+8: merge cache, persist, sort, return path. + _scan_finalize_index +} + load_applications() { local apps_file="$1" @@ -808,12 +1143,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 +1375,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,31 +1393,111 @@ 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 + local cached_apps_file="" + local cached_inventory_fingerprint="" while true; do unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN if [[ $first_scan == false ]]; then - echo -e "${GRAY}Refreshing application list...${NC}" >&2 + echo -e "${GRAY}Checking application list...${NC}" >&2 fi first_scan=false local apps_file="" - if ! apps_file=$(scan_applications); then - return 1 + local reused_app_cache=false + if [[ -n "$cached_apps_file" && -f "$cached_apps_file" && -n "$cached_inventory_fingerprint" ]]; then + local current_inventory_fingerprint + current_inventory_fingerprint=$(uninstall_app_inventory_fingerprint 2> /dev/null || echo "") + if [[ -n "$current_inventory_fingerprint" && "$current_inventory_fingerprint" == "$cached_inventory_fingerprint" ]]; then + apps_file="$cached_apps_file" + reused_app_cache=true + fi + fi + + if [[ "$reused_app_cache" != "true" ]]; then + if [[ -n "$cached_apps_file" && -f "$cached_apps_file" ]]; then + rm -f "$cached_apps_file" 2> /dev/null || true + fi + + if ! apps_file=$(scan_applications); then + return 1 + fi + + cached_apps_file="$apps_file" + cached_inventory_fingerprint=$(uninstall_app_inventory_fingerprint 2> /dev/null || echo "") fi if [[ ! -f "$apps_file" ]]; then @@ -871,9 +1506,15 @@ main() { if ! load_applications "$apps_file"; then rm -f "$apps_file" + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" 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 0.2 + set +e select_apps_for_uninstall local exit_code=$? @@ -884,6 +1525,7 @@ main() { clear_screen printf '\033[2J\033[H' >&2 rm -f "$apps_file" + [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" return 0 fi @@ -894,7 +1536,6 @@ main() { local selection_count=${#selected_apps[@]} if [[ $selection_count -eq 0 ]]; then echo "No apps selected" - rm -f "$apps_file" continue fi echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" @@ -962,9 +1603,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++)) @@ -972,24 +1623,21 @@ main() { batch_uninstall_applications - 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_filter_test.go b/Resources/mole/cmd/analyze/analyze_filter_test.go new file mode 100644 index 0000000..6546bb6 --- /dev/null +++ b/Resources/mole/cmd/analyze/analyze_filter_test.go @@ -0,0 +1,350 @@ +//go:build darwin + +package main + +import ( + "slices" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func topFilesFixture() model { + files := []fileEntry{ + {Name: "alpha.mp4", Path: "/tmp/p/alpha.mp4", Size: 300}, + {Name: "photo.jpg", Path: "/tmp/p/photo.jpg", Size: 200}, + {Name: "beta.mp4", Path: "/tmp/p/beta.mp4", Size: 100}, + } + cloned := make([]fileEntry, len(files)) + copy(cloned, files) + return model{ + path: "/tmp/p", + showLargeFiles: true, + largeFilesAll: files, + largeFiles: cloned, + largeMultiSelected: map[string]bool{}, + height: 40, + width: 120, + } +} + +func filterKey(t *testing.T, m model, msg tea.KeyMsg) (model, tea.Cmd) { + t.Helper() + updated, cmd := m.updateKey(msg) + got, ok := updated.(model) + if !ok { + t.Fatalf("expected model, got %T", updated) + } + return got, cmd +} + +func filterRune(t *testing.T, m model, r rune) (model, tea.Cmd) { + t.Helper() + return filterKey(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) +} + +func filterType(t *testing.T, m model, s string) model { + t.Helper() + for _, r := range s { + m, _ = filterRune(t, m, r) + } + return m +} + +func TestLargeFilterNarrowsApplyAndClear(t *testing.T) { + m := topFilesFixture() + + m, _ = filterRune(t, m, '/') + if !m.largeFiltering { + t.Fatalf("expected to enter filter input mode") + } + + m = filterType(t, m, "mp4") + if len(m.largeFiles) != 2 { + t.Fatalf("want 2 matches for mp4, got %d", len(m.largeFiles)) + } + + // Enter applies the filter and returns to navigation, keeping the subset. + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.largeFiltering { + t.Fatalf("Enter should exit input mode") + } + if len(m.largeFiles) != 2 { + t.Fatalf("filter should persist after Enter, got %d", len(m.largeFiles)) + } + + // Esc clears the filter and restores the full list. + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.largeFilter != "" { + t.Fatalf("Esc should clear the query, got %q", m.largeFilter) + } + if len(m.largeFiles) != 3 { + t.Fatalf("want full list of 3 after clear, got %d", len(m.largeFiles)) + } +} + +func TestLargeFilterSwallowsNavigationKeys(t *testing.T) { + m := topFilesFixture() + m, _ = filterRune(t, m, '/') + + // 'q' would normally quit; while filtering it must edit the query instead. + m, cmd := filterRune(t, m, 'q') + if cmd != nil { + t.Fatalf("q while filtering must not emit a command (no quit)") + } + if m.largeFilter != "q" { + t.Fatalf("q should append to the query, got %q", m.largeFilter) + } +} + +func TestLargeFilterBackspaceEditsQuery(t *testing.T) { + m := topFilesFixture() + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "mp") + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if m.largeFilter != "m" { + t.Fatalf("backspace should trim the query to 'm', got %q", m.largeFilter) + } +} + +func TestLargeFilterClearsMultiSelectOnQueryChange(t *testing.T) { + m := topFilesFixture() + m.largeMultiSelected = map[string]bool{"/tmp/p/photo.jpg": true} + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "a") + + if len(m.largeMultiSelected) != 0 { + t.Fatalf("changing the query should clear multi-selection, got %d", len(m.largeMultiSelected)) + } +} + +func TestLargeFilterClampsSelection(t *testing.T) { + m := topFilesFixture() + m.largeSelected = 2 // beta.mp4 in the full list + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "photo") // single match, full index 1 + + if len(m.largeFiles) != 1 { + t.Fatalf("want 1 match for photo, got %d", len(m.largeFiles)) + } + if m.largeSelected != 0 { + t.Fatalf("selection should clamp into the visible range, got %d", m.largeSelected) + } +} + +func TestLargeFilterDeleteTargetsVisibleMatch(t *testing.T) { + m := topFilesFixture() + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "beta") // single visible match: beta.mp4 (hidden in full list at index 2) + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + // backspace maps to the delete action once we are out of input mode. + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + if !m.deleteConfirm { + t.Fatalf("expected delete confirmation to open") + } + if m.deleteTarget == nil || m.deleteTarget.Path != "/tmp/p/beta.mp4" { + t.Fatalf("delete must target the visible match, got %+v", m.deleteTarget) + } +} + +func TestLargeFilterIgnoredOutsideTopView(t *testing.T) { + m := topFilesFixture() + m.showLargeFiles = false + m.entries = []dirEntry{{Name: "x", Path: "/tmp/p/x", Size: 1}} + + m, _ = filterRune(t, m, '/') + if m.largeFiltering { + t.Fatalf("'/' should do nothing outside the Top-files view") + } +} + +func treeFixture() model { + entries := []dirEntry{ + {Name: "apps", Path: "/tmp/p/apps", Size: 300, IsDir: true}, + {Name: "logs", Path: "/tmp/p/logs", Size: 200, IsDir: true}, + {Name: "node_modules", Path: "/tmp/p/node_modules", Size: 100, IsDir: true}, + } + var filesScanned, dirsScanned, bytesScanned int64 + return model{ + path: "/tmp/p", + entriesAll: entries, + entries: slices.Clone(entries), + multiSelected: map[string]bool{}, + cache: map[string]historyEntry{}, + filesScanned: &filesScanned, + dirsScanned: &dirsScanned, + bytesScanned: &bytesScanned, + height: 40, + width: 120, + } +} + +func TestEntryFilterNarrowsApplyAndClear(t *testing.T) { + m := treeFixture() + + m, _ = filterRune(t, m, '/') + if !m.entryFiltering { + t.Fatalf("expected to enter directory filter input mode") + } + + m = filterType(t, m, "s") // apps, logs, node_modules all end in 's' + if len(m.entries) != 3 { + t.Fatalf("want 3 matches for s, got %d", len(m.entries)) + } + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyBackspace}) + m = filterType(t, m, "ode") // only node_modules contains "ode" + if len(m.entries) != 1 { + t.Fatalf("want 1 match for ode, got %d", len(m.entries)) + } + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.entryFilter != "" { + t.Fatalf("Esc should clear the query, got %q", m.entryFilter) + } + if len(m.entries) != 3 { + t.Fatalf("want full list of 3 after clear, got %d", len(m.entries)) + } +} + +func TestEntryFilterSwallowsNavigationKeys(t *testing.T) { + m := treeFixture() + m, _ = filterRune(t, m, '/') + + m, cmd := filterRune(t, m, 'q') + if cmd != nil { + t.Fatalf("q while filtering must not emit a command (no quit)") + } + if m.entryFilter != "q" { + t.Fatalf("q should append to the query, got %q", m.entryFilter) + } +} + +func TestEntryFilterClearsMultiSelectOnQueryChange(t *testing.T) { + m := treeFixture() + m.multiSelected = map[string]bool{"/tmp/p/logs": true} + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "a") + + if len(m.multiSelected) != 0 { + t.Fatalf("changing the query should clear multi-selection, got %d", len(m.multiSelected)) + } +} + +func TestEntryFilterIgnoredInOverview(t *testing.T) { + m := treeFixture() + m.isOverview = true + m.path = "/" + + m, _ = filterRune(t, m, '/') + if m.entryFiltering { + t.Fatalf("'/' should do nothing in overview mode") + } +} + +// The load-bearing case: filter the tree, drill into a match, then go back. +// The parent must be restored in full (not the one-row filtered view) with the +// entered directory still highlighted. +func TestEntryFilterDrillInPreservesFullParentOnBack(t *testing.T) { + m := treeFixture() + m.cache["/tmp/p/node_modules"] = historyEntry{ + Path: "/tmp/p/node_modules", + Entries: []dirEntry{{Name: "pkg", Path: "/tmp/p/node_modules/pkg", Size: 50, IsDir: true}}, + TotalSize: 50, + } + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "node") // single match: node_modules (full index 2) + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if len(m.entries) != 1 { + t.Fatalf("want 1 match before drilling in, got %d", len(m.entries)) + } + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // drill into the match + if m.path != "/tmp/p/node_modules" { + t.Fatalf("expected to drill into node_modules, got %s", m.path) + } + if m.entryFilter != "" { + t.Fatalf("filter must be cleared after drilling in, got %q", m.entryFilter) + } + + m, _ = filterKey(t, m, tea.KeyMsg{Type: tea.KeyEsc}) // go back to parent + if m.path != "/tmp/p" { + t.Fatalf("expected to return to /tmp/p, got %s", m.path) + } + if len(m.entries) != 3 { + t.Fatalf("parent must be restored with all 3 entries, got %d", len(m.entries)) + } + if m.selected < 0 || m.selected >= len(m.entries) || m.entries[m.selected].Path != "/tmp/p/node_modules" { + t.Fatalf("entered entry should stay highlighted, selected=%d", m.selected) + } +} + +// Deleting with no active filter must not corrupt the backing lists. Before the +// rebuild-from-backing fix, removing from both a list and its aliased view +// shifted the shared array twice, leaving a duplicated, stale entry behind. +func TestRemovePathPreservesBackingLists(t *testing.T) { + m := treeFixture() // entriesAll aliases entries: [apps, logs, node_modules] + m.totalSize = 600 + + m.removePathFromView("/tmp/p/logs") + + if len(m.entriesAll) != 2 { + t.Fatalf("entriesAll should drop to 2, got %d", len(m.entriesAll)) + } + if len(m.entries) != 2 { + t.Fatalf("entries should drop to 2, got %d", len(m.entries)) + } + seen := map[string]int{} + for _, e := range m.entriesAll { + seen[e.Path]++ + } + if seen["/tmp/p/logs"] != 0 { + t.Fatalf("deleted path still present in entriesAll") + } + for p, c := range seen { + if c != 1 { + t.Fatalf("entry %s duplicated %d times in entriesAll", p, c) + } + } +} + +func TestEntryFilterViewShowsHintAndQuery(t *testing.T) { + m := treeFixture() + if hint := m.View(); !strings.Contains(hint, "/ Filter") { + t.Fatalf("expected '/ Filter' footer hint, got:\n%s", hint) + } + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "node") + view := m.View() + if !strings.Contains(view, "Filter:") { + t.Fatalf("expected active 'Filter:' line, got:\n%s", view) + } + if !strings.Contains(view, "No matches") && !strings.Contains(view, "node_modules") { + t.Fatalf("expected the single match rendered, got:\n%s", view) + } +} + +func TestLargeFilterViewShowsHintAndQuery(t *testing.T) { + m := topFilesFixture() + if hint := m.View(); !strings.Contains(hint, "/ Filter") { + t.Fatalf("expected '/ Filter' footer hint, got:\n%s", hint) + } + + m, _ = filterRune(t, m, '/') + m = filterType(t, m, "mp4") + view := m.View() + if !strings.Contains(view, "Filter:") { + t.Fatalf("expected active 'Filter:' line, got:\n%s", view) + } + if !strings.Contains(view, "matches") { + t.Fatalf("expected match count in filter line, got:\n%s", view) + } +} diff --git a/Resources/mole/cmd/analyze/analyze_test.go b/Resources/mole/cmd/analyze/analyze_test.go index 7a2fecd..ece15a6 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,14 @@ import ( "fmt" "os" "path/filepath" + "slices" + "strconv" "strings" "sync/atomic" "testing" "time" + + tea "github.com/charmbracelet/bubbletea" ) func resetOverviewSnapshotForTest() { @@ -18,6 +24,90 @@ func resetOverviewSnapshotForTest() { overviewSnapshotMu.Unlock() } +func runScanResultCmd(t *testing.T, cmd tea.Cmd) scanResultMsg { + t.Helper() + + msg := cmd() + if scanMsg, ok := scanResultMsgFromMsg(t, msg); ok { + return scanMsg + } + t.Fatalf("expected scanResultMsg or live scan result, got %T", msg) + return scanResultMsg{} +} + +func scanResultMsgFromMsg(t *testing.T, msg tea.Msg) (scanResultMsg, bool) { + t.Helper() + + switch typed := msg.(type) { + case scanResultMsg: + return typed, true + case liveScanStartMsg: + return drainLiveScanToResultMsg(t, typed), true + case tea.BatchMsg: + for _, batchCmd := range typed { + if batchCmd == nil { + continue + } + if scanMsg, ok := scanResultMsgFromMsg(t, batchCmd()); ok { + return scanMsg, true + } + } + return scanResultMsg{}, false + default: + return scanResultMsg{}, false + } +} + +func drainLiveScanToResultMsg(t *testing.T, start liveScanStartMsg) scanResultMsg { + t.Helper() + if start.err != nil { + return scanResultMsg{path: start.path, err: start.err} + } + deadline := time.After(5 * time.Second) + for { + select { + case event, ok := <-start.events: + if !ok { + t.Fatalf("live scan event channel closed without completion") + } + switch event.kind { + case liveScanComplete: + return scanResultMsg{path: start.path, result: event.result} + case liveScanFailed: + return scanResultMsg{path: start.path, err: event.err} + case liveScanCanceled: + return scanResultMsg{path: start.path, err: event.err} + } + case <-deadline: + if start.cancel != nil { + start.cancel() + } + t.Fatalf("timed out waiting for live scan completion") + } + } +} + +func cancelAndDrainLiveScan(start liveScanStartMsg) { + if start.cancel != nil { + start.cancel() + } + for range start.events { + } +} + +func rowContaining(view, needle string) string { + for line := range strings.SplitSeq(view, "\n") { + if strings.Contains(line, needle) { + return line + } + } + return "" +} + +func progressFillCount(row string) int { + return strings.Count(row, "█") + strings.Count(row, "▓") + strings.Count(row, "▒") +} + func TestScanPathConcurrentBasic(t *testing.T) { root := t.TempDir() @@ -91,6 +181,66 @@ func TestScanPathConcurrentBasic(t *testing.T) { } } +// TestScanPathConcurrentDedupsHardlinks guards #906: a file with multiple +// hardlinks (e.g. Final Cut Pro managed media) must be counted once, the way +// `du` does, instead of once per link. +func TestScanPathConcurrentDedupsHardlinks(t *testing.T) { + root := t.TempDir() + + nested := filepath.Join(root, "nested") + other := filepath.Join(root, "other") + for _, d := range []string{nested, other} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + + original := filepath.Join(nested, "media.bin") + if err := os.WriteFile(original, []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write original: %v", err) + } + // Two more hardlinks to the same inode, one in this dir and one in a + // sibling dir, so the shared scan-wide dedup set is exercised. + for _, link := range []string{ + filepath.Join(nested, "media-copy.bin"), + filepath.Join(other, "media-link.bin"), + } { + if err := os.Link(original, link); err != nil { + t.Fatalf("hardlink %s: %v", link, err) + } + } + // An unrelated plain file that must still be counted in full. + plain := filepath.Join(other, "plain.bin") + if err := os.WriteFile(plain, []byte("plaindata"), 0o644); err != nil { + t.Fatalf("write plain: %v", err) + } + + var filesScanned, dirsScanned, bytesScanned int64 + current := &atomic.Value{} + current.Store("") + + result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, current) + if err != nil { + t.Fatalf("scanPathConcurrent returned error: %v", err) + } + + mediaInfo, err := os.Lstat(original) + if err != nil { + t.Fatalf("stat original: %v", err) + } + plainInfo, err := os.Lstat(plain) + if err != nil { + t.Fatalf("stat plain: %v", err) + } + want := getActualFileSize(original, mediaInfo) + getActualFileSize(plain, plainInfo) + if result.TotalSize != want { + t.Fatalf("expected hardlinked media counted once (total %d), got %d", want, result.TotalSize) + } + if !result.dedupedHardlink { + t.Fatalf("expected dedupedHardlink flag to be set when a hardlink is deduped") + } +} + func TestPerformScanForJSONCountsTopLevelFiles(t *testing.T) { root := t.TempDir() @@ -109,7 +259,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 +330,141 @@ 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 TestViewKeepsCachedEntriesWhileRefreshing(t *testing.T) { + m := model{ + path: "/tmp/project/child", + history: []historyEntry{{Path: "/tmp/project"}}, + entries: []dirEntry{{Name: "warmed-child", Path: "/tmp/project/child/warmed-child", Size: 100, IsDir: true}}, + totalSize: 100, + scanning: true, + viewNeedsRefresh: true, + } + + view := m.View() + if !strings.Contains(view, "warmed-child") { + t.Fatalf("expected cached entry to render during refresh, got:\n%s", view) + } + if !strings.Contains(view, "Showing cached results while refreshing") { + t.Fatalf("expected refreshing hint during cached refresh, got:\n%s", view) + } +} + +func TestViewBlanksToScanOnlyWithoutWarmCache(t *testing.T) { + // Right after entering an uncached child, m.entries still holds the parent's + // stale entries while viewNeedsRefresh is false. The view must not paint + // those stale rows under the new path; it stays scan-only until results land. + m := model{ + path: "/tmp/project/child", + history: []historyEntry{{Path: "/tmp/project"}}, + entries: []dirEntry{{Name: "stale-parent-row", Path: "/tmp/project/stale-parent-row", Size: 100, IsDir: true}}, + totalSize: 100, + scanning: true, + viewNeedsRefresh: false, + } + + view := m.View() + if strings.Contains(view, "stale-parent-row") { + t.Fatalf("expected scan-only view to hide stale entries, got:\n%s", view) + } + if strings.Contains(view, "Showing cached results while refreshing") { + t.Fatalf("did not expect cached-refresh hint without a warm cache, got:\n%s", view) + } + if !strings.Contains(view, "Scanning") { + t.Fatalf("expected scan-only view to show scanning progress, 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 +503,1072 @@ func TestCacheSaveLoadRoundTrip(t *testing.T) { } } +func TestPruneAnalyzerCacheDirRemovesOnlyExpiredCacheFiles(t *testing.T) { + cacheDir := t.TempDir() + now := time.Now() + oldTime := now.Add(-analyzerCacheTTL - time.Hour) + freshTime := now.Add(-time.Hour) + + oldCache := filepath.Join(cacheDir, "old.cache") + freshCache := filepath.Join(cacheDir, "fresh.cache") + namedState := filepath.Join(cacheDir, overviewCacheFile) + cacheDirEntry := filepath.Join(cacheDir, "directory.cache") + symlinkTarget := filepath.Join(cacheDir, "target") + symlinkCache := filepath.Join(cacheDir, "link.cache") + + for _, path := range []string{oldCache, freshCache, namedState, symlinkTarget} { + if err := os.WriteFile(path, []byte("cache"), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } + } + if err := os.Mkdir(cacheDirEntry, 0o755); err != nil { + t.Fatalf("mkdir cache dir entry: %v", err) + } + if err := os.Symlink(symlinkTarget, symlinkCache); err != nil { + t.Fatalf("symlink cache entry: %v", err) + } + + for _, path := range []string{oldCache, namedState, cacheDirEntry, symlinkCache} { + if err := os.Chtimes(path, oldTime, oldTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } + if err := os.Chtimes(freshCache, freshTime, freshTime); err != nil { + t.Fatalf("chtimes fresh cache: %v", err) + } + + if err := pruneAnalyzerCacheDir(cacheDir, now); err != nil { + t.Fatalf("pruneAnalyzerCacheDir: %v", err) + } + + if _, err := os.Stat(oldCache); !os.IsNotExist(err) { + t.Fatalf("expected expired cache file to be removed, stat err: %v", err) + } + for _, path := range []string{freshCache, namedState, cacheDirEntry, symlinkCache} { + if _, err := os.Lstat(path); err != nil { + t.Fatalf("expected %s to be preserved: %v", path, err) + } + } +} + +func TestPruneAnalyzerCacheDirMissingDirectory(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + if err := pruneAnalyzerCacheDir(missing, time.Now()); err != nil { + t.Fatalf("expected missing cache dir to be ignored, got: %v", err) + } +} + +func TestPruneAnalyzerCacheDirIgnoresRemoveFailures(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can remove files from read-only directories") + } + + cacheDir := t.TempDir() + oldCache := filepath.Join(cacheDir, "old.cache") + if err := os.WriteFile(oldCache, []byte("cache"), 0o644); err != nil { + t.Fatalf("write old cache: %v", err) + } + oldTime := time.Now().Add(-analyzerCacheTTL - time.Hour) + if err := os.Chtimes(oldCache, oldTime, oldTime); err != nil { + t.Fatalf("chtimes old cache: %v", err) + } + + if err := os.Chmod(cacheDir, 0o555); err != nil { + t.Fatalf("chmod cache dir read-only: %v", err) + } + defer func() { + _ = os.Chmod(cacheDir, 0o755) + }() + + if err := pruneAnalyzerCacheDir(cacheDir, time.Now()); err != nil { + t.Fatalf("expected remove failure to be ignored, got: %v", err) + } + if _, err := os.Stat(oldCache); err != nil { + t.Fatalf("expected failed removal to leave cache file in place: %v", err) + } +} + +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 TestLiveScanUXConfigFromEnv(t *testing.T) { + t.Setenv(liveSortModeEnv, "freeze-on-move") + t.Setenv(liveCursorModeEnv, "index") + + m := newModel(t.TempDir(), false) + if m.liveSortMode != liveSortFreezeOnMove { + t.Fatalf("expected freeze-on-move sort mode, got %v", m.liveSortMode) + } + if m.liveCursorMode != liveCursorByIndex { + t.Fatalf("expected index cursor mode, got %v", m.liveCursorMode) + } +} + +func TestLiveScanDefaultsToPathCursor(t *testing.T) { + m := newModel(t.TempDir(), false) + if m.liveCursorMode != liveCursorByPath { + t.Fatalf("expected default path cursor mode, got %v", m.liveCursorMode) + } +} + +func TestLiveScanInitialListingShowsImmediateChildren(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) + } + filePath := filepath.Join(root, "root.txt") + if err := os.WriteFile(filePath, []byte("root-data"), 0o644); err != nil { + t.Fatalf("write root file: %v", err) + } + + m := newModel(root, false) + msg := m.scanFreshCmd(root)() + start, ok := msg.(liveScanStartMsg) + if !ok { + t.Fatalf("expected liveScanStartMsg, got %T", msg) + } + defer cancelAndDrainLiveScan(start) + + foundFile := false + foundDir := false + for _, entry := range start.entries { + switch entry.Path { + case filePath: + foundFile = true + if entry.Size <= 0 { + t.Fatalf("expected file size to be known immediately, got %d", entry.Size) + } + case child: + foundDir = true + if entry.Size != -1 { + t.Fatalf("expected child directory to start pending, got %d", entry.Size) + } + } + } + if !foundFile || !foundDir { + t.Fatalf("expected immediate file and directory entries, got %+v", start.entries) + } +} + +func TestLiveScanStartDoesNotAddSecondSpinnerTick(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + m := newModel(root, false) + start := m.scanFreshCmd(root)().(liveScanStartMsg) + defer cancelAndDrainLiveScan(start) + + _, cmd := m.Update(start) + if cmd == nil { + t.Fatalf("expected live scan start to wait for scan events") + } + if _, ok := cmd().(tickMsg); ok { + t.Fatalf("live scan start must not schedule an extra spinner tick") + } +} + +func TestOverviewHomeNavigationRendersImmediateRows(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + downloads := filepath.Join(home, "Downloads") + desktop := filepath.Join(home, "Desktop") + for _, dir := range []string{downloads, desktop} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("create %s: %v", dir, err) + } + } + if err := os.WriteFile(filepath.Join(home, "note.txt"), []byte("home-note"), 0o644); err != nil { + t.Fatalf("write home file: %v", err) + } + + m := newModel("/", true) + for i, entry := range m.entries { + if entry.Path == home { + m.selected = i + break + } + } + + updated, cmd := m.enterSelectedDir() + if cmd == nil { + t.Fatalf("expected Home navigation to start a scan") + } + got := updated.(model) + if got.path != home { + t.Fatalf("expected path %s, got %s", home, got.path) + } + + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("expected navigation command batch, got %T", msg) + } + var start liveScanStartMsg + for _, batchCmd := range batch { + if batchCmd == nil { + continue + } + if candidate, ok := batchCmd().(liveScanStartMsg); ok { + start = candidate + break + } + } + if start.events == nil { + t.Fatalf("expected batch to include live scan start") + } + defer cancelAndDrainLiveScan(start) + + updated, _ = got.Update(start) + got = updated.(model) + view := got.View() + for _, want := range []string{"Downloads", "Desktop", "note.txt"} { + if !strings.Contains(view, want) { + t.Fatalf("expected Home view to contain %q, got:\n%s", want, view) + } + } +} + +func TestLiveScanChildUpdateUpdatesRowTotalAndCache(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(child, "data.bin"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil { + t.Fatalf("write child file: %v", err) + } + + m := newModel(root, false) + start := m.scanFreshCmd(root)().(liveScanStartMsg) + defer cancelAndDrainLiveScan(start) + + updated, _ := m.Update(start) + liveModel := updated.(model) + + deadline := time.After(5 * time.Second) + for { + select { + case event := <-start.events: + if event.kind != liveScanChildDone { + continue + } + updated, _ = liveModel.Update(event) + liveModel = updated.(model) + + var found dirEntry + for _, entry := range liveModel.entries { + if entry.Path == child { + found = entry + break + } + } + if found.Path == "" { + t.Fatalf("expected child row to remain visible") + } + if found.Size <= 0 { + t.Fatalf("expected child row size to update, got %d", found.Size) + } + if liveModel.totalSize != found.Size { + t.Fatalf("expected total size %d, got %d", found.Size, liveModel.totalSize) + } + cached, ok := liveModel.cache[child] + if !ok { + t.Fatalf("expected child result to warm in-memory cache") + } + if cached.TotalSize != found.Size { + t.Fatalf("cached child size mismatch: want %d, got %d", found.Size, cached.TotalSize) + } + return + case <-deadline: + t.Fatalf("timed out waiting for child update") + } + } +} + +func TestLiveScanStartPreservesEntryFilterBackingList(t *testing.T) { + root := t.TempDir() + apps := filepath.Join(root, "apps") + logs := filepath.Join(root, "logs") + + m := newModel(root, false) + m.entryFilter = "app" + start := liveScanStartMsg{ + id: 1, + path: root, + entries: []dirEntry{ + {Name: "apps", Path: apps, Size: -1, IsDir: true}, + {Name: "logs", Path: logs, Size: -1, IsDir: true}, + }, + events: make(chan liveScanEventMsg), + cancel: func() {}, + } + + updated, _ := m.Update(start) + got := updated.(model) + if len(got.entriesAll) != 2 { + t.Fatalf("expected backing list to keep both live entries, got %+v", got.entriesAll) + } + if len(got.entries) != 1 || got.entries[0].Path != apps { + t.Fatalf("expected active filter to render only apps, got %+v", got.entries) + } +} + +func TestLiveScanIgnoresStaleEventsAfterNavigation(t *testing.T) { + root := t.TempDir() + other := t.TempDir() + + m := newModel(other, false) + m.liveScanID = 2 + m.liveScanEvents = make(chan liveScanEventMsg) + m.entries = []dirEntry{{Name: "current", Path: filepath.Join(other, "current"), Size: 1}} + m.totalSize = 1 + + stale := liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{ + Name: "stale", + Path: filepath.Join(root, "stale"), + Size: 99, + IsDir: true, + }, + result: scanResult{TotalSize: 99}, + } + + updated, _ := m.Update(stale) + got := updated.(model) + if got.totalSize != 1 || len(got.entries) != 1 || got.entries[0].Name != "current" { + t.Fatalf("stale event changed model: %+v", got) + } +} + +func TestLiveScanDefaultCursorByPathKeepsSelectedPathAcrossReorder(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortContinuous + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + m.entriesAll = slices.Clone(m.entries) + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + if got := []string{m.entries[0].Path, m.entries[1].Path}; !slices.Equal(got, []string{b, a}) { + t.Fatalf("expected live sort to reorder by size, got %v", got) + } + if m.entries[m.selected].Path != a { + t.Fatalf("expected default cursor to stay on %s, selected=%d entries=%+v", a, m.selected, m.entries) + } + + updated, _ = m.enterSelectedDir() + got := updated.(model) + if got.path != a { + t.Fatalf("expected Enter to drill into selected path %s, got %s", a, got.path) + } +} + +func TestLiveScanProgressUpdatesRowBarAndPercent(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "child") + sibling := filepath.Join(root, "sibling.bin") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = false + m.liveScanningPaths = map[string]bool{child: true} + m.entries = []dirEntry{ + {Name: "child", Path: child, Size: -1, IsDir: true}, + {Name: "sibling.bin", Path: sibling, Size: 100}, + } + m.totalSize = 100 + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildProgress, + entry: dirEntry{Name: "child", Path: child, Size: 10, IsDir: true}, + }) + m = updated.(model) + firstRow := rowContaining(m.View(), "child") + firstFill := progressFillCount(firstRow) + if !strings.Contains(firstRow, "9.1%") { + t.Fatalf("expected first progress row to show 9.1%%, got:\n%s", firstRow) + } + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildProgress, + entry: dirEntry{Name: "child", Path: child, Size: 50, IsDir: true}, + }) + m = updated.(model) + secondRow := rowContaining(m.View(), "child") + secondFill := progressFillCount(secondRow) + if !strings.Contains(secondRow, "33.3%") { + t.Fatalf("expected second progress row to show 33.3%%, got:\n%s", secondRow) + } + if secondFill <= firstFill { + t.Fatalf("expected child progress bar fill to increase, first=%d second=%d\nfirst: %s\nsecond: %s", firstFill, secondFill, firstRow, secondRow) + } + if m.totalSize != 150 { + t.Fatalf("expected total known size to grow to 150, got %d", m.totalSize) + } + if _, ok := m.cache[child]; ok { + t.Fatalf("progress event must not warm child cache before completion") + } + if !m.liveScanningPaths[child] { + t.Fatalf("progress event must keep child marked as scanning") + } +} + +func TestLiveScanContinuousSortKeepsCursorByIndex(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortContinuous + m.liveCursorMode = liveCursorByIndex + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + if m.entries[0].Path != b { + t.Fatalf("expected live auto-sort to move larger row first, got %+v", m.entries) + } + + updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if !m.autoSortLiveEntries { + t.Fatalf("expected navigation key to keep live sort enabled") + } + if m.entries[m.selected].Path != a { + t.Fatalf("expected selection to move to a before reorder, got selected=%d entries=%+v", m.selected, m.entries) + } + selectedIndex := m.selected + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "a", Path: a, Size: 100, IsDir: true}, + result: scanResult{TotalSize: 100}, + }) + m = updated.(model) + if got := []string{m.entries[0].Path, m.entries[1].Path}; !slices.Equal(got, []string{a, b}) { + t.Fatalf("expected live sort to continue after navigation, got %v", got) + } + if m.selected != selectedIndex { + t.Fatalf("expected selection index to stay %d, got %d", selectedIndex, m.selected) + } + if m.entries[m.selected].Path != b { + t.Fatalf("expected cursor-by-index to now point at row %d (%s), selected entries=%+v", selectedIndex, b, m.entries) + } +} + +func TestLiveScanContinuousSortCanKeepCursorByPath(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortContinuous + m.liveCursorMode = liveCursorByPath + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if m.entries[m.selected].Path != a { + t.Fatalf("expected selection to move to a before reorder, got selected=%d entries=%+v", m.selected, m.entries) + } + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "a", Path: a, Size: 100, IsDir: true}, + result: scanResult{TotalSize: 100}, + }) + m = updated.(model) + if got := []string{m.entries[0].Path, m.entries[1].Path}; !slices.Equal(got, []string{a, b}) { + t.Fatalf("expected live sort to continue after navigation, got %v", got) + } + if m.entries[m.selected].Path != a { + t.Fatalf("expected cursor-by-path to stay on %s after reorder, selected=%d entries=%+v", a, m.selected, m.entries) + } +} + +func TestLiveScanSortCanFreezeAfterNavigationKey(t *testing.T) { + root := t.TempDir() + a := filepath.Join(root, "a") + b := filepath.Join(root, "b") + + m := newModel(root, false) + m.liveScanID = 1 + m.liveScanEvents = make(chan liveScanEventMsg) + m.scanning = true + m.autoSortLiveEntries = true + m.liveSortMode = liveSortFreezeOnMove + m.liveCursorMode = liveCursorByIndex + m.liveScanningPaths = map[string]bool{a: true, b: true} + m.entries = []dirEntry{ + {Name: "a", Path: a, Size: -1, IsDir: true}, + {Name: "b", Path: b, Size: -1, IsDir: true}, + } + + updated, _ := m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "b", Path: b, Size: 10, IsDir: true}, + result: scanResult{TotalSize: 10}, + }) + m = updated.(model) + updated, _ = m.updateKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + if m.autoSortLiveEntries { + t.Fatalf("expected freeze-on-move to disable live sort") + } + before := []string{m.entries[0].Path, m.entries[1].Path} + + updated, _ = m.Update(liveScanEventMsg{ + id: 1, + path: root, + kind: liveScanChildDone, + entry: dirEntry{Name: "a", Path: a, Size: 100, IsDir: true}, + result: scanResult{TotalSize: 100}, + }) + m = updated.(model) + after := []string{m.entries[0].Path, m.entries[1].Path} + if !slices.Equal(before, after) { + t.Fatalf("expected freeze-on-move to keep row order %v, got %v", before, after) + } +} + +func TestScanningViewRendersRowsWithSpinner(t *testing.T) { + m := model{ + path: "/tmp/project", + scanning: true, + spinner: 1, + totalSize: 8, + entries: []dirEntry{ + {Name: "child", Path: "/tmp/project/child", Size: -1, IsDir: true}, + {Name: "file.txt", Path: "/tmp/project/file.txt", Size: 8}, + }, + liveScanningPaths: map[string]bool{"/tmp/project/child": true}, + } + + view := m.View() + if !strings.Contains(view, "child") || !strings.Contains(view, "file.txt") { + t.Fatalf("expected scanning view to render rows, got:\n%s", view) + } + if !strings.Contains(view, spinnerFrames[m.spinner]+" scanning") { + t.Fatalf("expected pending directory spinner in row, got:\n%s", view) + } +} + +func TestScanningViewShowsSpinnerDividerForPartiallySizedFolders(t *testing.T) { + m := model{ + path: "/tmp/project", + scanning: true, + spinner: 1, + totalSize: 150, + entries: []dirEntry{ + {Name: "child", Path: "/tmp/project/child", Size: 50, IsDir: true}, + {Name: "file.txt", Path: "/tmp/project/file.txt", Size: 100}, + }, + liveScanningPaths: map[string]bool{"/tmp/project/child": true}, + } + + view := m.View() + childRow := rowContaining(view, "child") + fileRow := rowContaining(view, "file.txt") + if !strings.Contains(childRow, spinnerFrames[m.spinner]) { + t.Fatalf("expected active child row divider to show spinner, got:\n%s", childRow) + } + if strings.Contains(fileRow, spinnerFrames[m.spinner]) { + t.Fatalf("expected non-scanning file row to keep static divider, got:\n%s", fileRow) + } +} + +func TestEnterSelectedDirMarksScanningParentForRefresh(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("create child: %v", err) + } + + cancelled := false + m := newModel(root, false) + m.entries = []dirEntry{{Name: "child", Path: child, Size: -1, IsDir: true}} + m.scanning = true + m.liveScanID = 1 + m.liveScanCancel = func() { cancelled = true } + + updated, cmd := m.enterSelectedDir() + if cmd == nil { + t.Fatalf("expected child navigation to start a scan") + } + got := updated.(model) + if !cancelled { + t.Fatalf("expected active parent scan to be cancelled") + } + if len(got.history) != 1 || !got.history[0].NeedsRefresh { + t.Fatalf("expected scanning parent history to be marked for refresh, got %+v", got.history) + } +} + +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..9032470 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 ( @@ -14,6 +16,11 @@ import ( "github.com/cespare/xxhash/v2" ) +// cacheSchemaVersion is bumped whenever directory-size semantics change so +// stale on-disk cache entries are rejected instead of silently reused. +// v2: analyze deduplicates hardlinked files to match `du`. +const cacheSchemaVersion = 2 + type overviewSizeSnapshot struct { Size int64 `json:"size"` Updated time.Time `json:"updated"` @@ -36,13 +43,35 @@ func snapshotFromModel(m model) historyEntry { EntryOffset: m.offset, LargeSelected: m.largeSelected, LargeOffset: m.largeOffset, + NeedsRefresh: m.viewNeedsRefresh || m.scanning, 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, + IsOverview: previous.IsOverview, + } return entry } @@ -182,6 +211,45 @@ func getCachePath(path string) (string, error) { return filepath.Join(cacheDir, filename), nil } +func pruneAnalyzerCache() { + cacheDir, err := getCacheDir() + if err != nil { + return + } + // Pruning is best-effort; errors are intentionally ignored to avoid blocking startup. + _ = pruneAnalyzerCacheDir(cacheDir, time.Now()) +} + +func pruneAnalyzerCacheDir(cacheDir string, now time.Time) error { + if cacheDir == "" || analyzerCacheTTL <= 0 { + return nil + } + + entries, err := os.ReadDir(cacheDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + cutoff := now.Add(-analyzerCacheTTL) + for _, entry := range entries { + if entry.Type()&os.ModeSymlink != 0 || filepath.Ext(entry.Name()) != ".cache" { + continue + } + + info, err := entry.Info() + if err != nil || !info.Mode().IsRegular() || info.ModTime().After(cutoff) { + continue + } + + _ = os.Remove(filepath.Join(cacheDir, entry.Name())) + } + + return nil +} + func loadRawCacheFromDisk(path string) (*cacheEntry, error) { cachePath, err := getCachePath(path) if err != nil { @@ -200,6 +268,10 @@ func loadRawCacheFromDisk(path string) (*cacheEntry, error) { return nil, err } + if entry.SchemaVersion != cacheSchemaVersion { + return nil, fmt.Errorf("cache schema mismatch: got %d, want %d", entry.SchemaVersion, cacheSchemaVersion) + } + return &entry, nil } @@ -215,7 +287,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) { } scanAge := time.Since(entry.ScanTime) - if scanAge > 7*24*time.Hour { + if scanAge > analyzerCacheTTL { return nil, fmt.Errorf("cache expired: too old") } @@ -253,6 +325,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 +340,14 @@ 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, + SchemaVersion: cacheSchemaVersion, } file, err := os.Create(cachePath) @@ -313,6 +391,22 @@ func invalidateCache(path string) { removeOverviewSnapshot(path) } +// invalidateCacheTree invalidates the cache for path and all its direct +// child directories so that a rescan does not reuse stale subdirectory +// sizes. See #812. +func invalidateCacheTree(path string) { + invalidateCache(path) + children, err := os.ReadDir(path) + if err != nil { + return + } + for _, child := range children { + if child.IsDir() { + invalidateCache(filepath.Join(path, child.Name())) + } + } +} + func removeOverviewSnapshot(path string) { if path == "" { return @@ -347,16 +441,29 @@ func prefetchOverviewCache(ctx context.Context) { return } + sem := make(chan struct{}, maxConcurrentOverview) + var wg sync.WaitGroup for _, path := range needScan { select { case <-ctx.Done(): + wg.Wait() return default: } - size, err := measureOverviewSize(path) - if err == nil && size > 0 { - _ = storeOverviewSize(path, size) - } + wg.Go(func() { + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + + size, err := measureOverviewSize(path) + if err == nil && size > 0 { + _ = storeOverviewSize(path, size) + } + }) } + wg.Wait() } diff --git a/Resources/mole/cmd/analyze/cleanable.go b/Resources/mole/cmd/analyze/cleanable.go index 4c80879..f69d83e 100644 --- a/Resources/mole/cmd/analyze/cleanable.go +++ b/Resources/mole/cmd/analyze/cleanable.go @@ -1,10 +1,19 @@ +//go:build darwin + package main import ( + "io" + "os" "path/filepath" "strings" ) +const ( + cacheDirTagFileName = "CACHEDIR.TAG" + cacheDirTagSignature = "Signature: 8a477f597d28d172789f06886806bc55" +) + // isCleanableDir marks paths safe to delete manually (not handled by mo clean). func isCleanableDir(path string) bool { if path == "" { @@ -18,6 +27,11 @@ func isCleanableDir(path string) bool { baseName := filepath.Base(path) + // CACHEDIR.TAG marks the whole directory tree as regenerable cache. + if hasValidCacheDirTag(path) { + return true + } + // Project dependencies and build outputs are safe. if projectDependencyDirs[baseName] { return true @@ -26,18 +40,33 @@ func isCleanableDir(path string) bool { return false } -// isHandledByMoClean checks if a path is cleaned by mo clean. -func isHandledByMoClean(path string) bool { - cleanPaths := []string{ - "/Library/Caches/", - "/Library/Logs/", - "/Library/Saved Application State/", - "/.Trash/", - "/Library/DiagnosticReports/", +func hasValidCacheDirTag(path string) bool { + tagPath := filepath.Join(path, cacheDirTagFileName) + info, err := os.Lstat(tagPath) + if err != nil || !info.Mode().IsRegular() { + return false } - for _, p := range cleanPaths { - if strings.Contains(path, p) { + file, err := os.Open(tagPath) + if err != nil { + return false + } + defer func() { + _ = file.Close() + }() + + buf := make([]byte, len(cacheDirTagSignature)) + if _, err := io.ReadFull(file, buf); err != nil { + return false + } + + return string(buf) == cacheDirTagSignature +} + +// isHandledByMoClean checks if a path is cleaned by mo clean. +func isHandledByMoClean(path string) bool { + for _, fragment := range moCleanHandledPathFragments { + if strings.Contains(path, fragment) { return true } } @@ -45,6 +74,14 @@ func isHandledByMoClean(path string) bool { return false } +var moCleanHandledPathFragments = []string{ + "/Library/Caches/", + "/Library/Logs/", + "/Library/Saved Application State/", + "/.Trash/", + "/Library/DiagnosticReports/", +} + // Project dependency and build directories. var projectDependencyDirs = map[string]bool{ // JavaScript/Node. diff --git a/Resources/mole/cmd/analyze/cleanable_test.go b/Resources/mole/cmd/analyze/cleanable_test.go new file mode 100644 index 0000000..41cab95 --- /dev/null +++ b/Resources/mole/cmd/analyze/cleanable_test.go @@ -0,0 +1,58 @@ +//go:build darwin + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeCacheDirTag(t testing.TB, dir string, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, cacheDirTagFileName), []byte(content), 0o644); err != nil { + t.Fatalf("write cache dir tag: %v", err) + } +} + +func TestIsCleanableDirAcceptsValidCacheDirTag(t *testing.T) { + dir := t.TempDir() + writeCacheDirTag(t, dir, cacheDirTagSignature+"\n# https://bford.info/cachedir/") + + if !isCleanableDir(dir) { + t.Fatalf("expected valid CACHEDIR.TAG directory to be cleanable") + } +} + +func TestIsCleanableDirRejectsInvalidCacheDirTag(t *testing.T) { + tests := map[string]string{ + "wrong signature": "Signature: invalid", + "short file": cacheDirTagSignature[:len(cacheDirTagSignature)-1], + } + + for name, content := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeCacheDirTag(t, dir, content) + + if isCleanableDir(dir) { + t.Fatalf("expected invalid CACHEDIR.TAG directory to stay non-cleanable") + } + }) + } +} + +func TestIsCleanableDirRejectsSymlinkCacheDirTag(t *testing.T) { + dir := t.TempDir() + realTag := filepath.Join(dir, "real-tag") + if err := os.WriteFile(realTag, []byte(cacheDirTagSignature), 0o644); err != nil { + t.Fatalf("write real tag: %v", err) + } + if err := os.Symlink(realTag, filepath.Join(dir, cacheDirTagFileName)); err != nil { + t.Fatalf("symlink cache dir tag: %v", err) + } + + if isCleanableDir(dir) { + t.Fatalf("expected symlink CACHEDIR.TAG directory to stay non-cleanable") + } +} diff --git a/Resources/mole/cmd/analyze/constants.go b/Resources/mole/cmd/analyze/constants.go index d400035..8d682ec 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" @@ -9,6 +11,7 @@ const ( spotlightMinFileSize = 100 << 20 largeFileWarmupMinSize = 1 << 20 defaultViewport = 12 + analyzerCacheTTL = 7 * 24 * time.Hour overviewCacheTTL = 7 * 24 * time.Hour overviewCacheFile = "overview_sizes.json" duTimeout = 30 * time.Second @@ -19,14 +22,29 @@ 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 User 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. + // Further reduced after #765: System Library (184GB, 261k files) with + // deep permission checks can still exhaust threads at previous limits. + minWorkers = 2 + maxWorkers = 12 + cpuMultiplier = 1 + maxDirWorkers = 6 openCommandTimeout = 10 * time.Second + scanSendTimeout = 100 * time.Millisecond + uiTickInterval = 100 * time.Millisecond ) +var overviewDuIgnoreNames = map[string]bool{ + // iCloud Drive's FileProvider tree can block `du` for tens of seconds even + // when most entries are cloud placeholders. Keep the overview responsive; + // users can still drill into the folder explicitly when they need it. + "Mobile Documents": true, +} + var foldDirs = map[string]bool{ // VCS. ".git": true, diff --git a/Resources/mole/cmd/analyze/delete.go b/Resources/mole/cmd/analyze/delete.go index 11feaee..c6607bd 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 := validateTrashTarget(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 := validateTrashTarget(absPath); err != nil { + return err + } + // Escape path for AppleScript (handle quotes and backslashes). escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\") escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"") @@ -144,3 +157,57 @@ func moveToTrash(path string) error { return nil } + +func validateTrashTarget(path string) error { + if err := validatePath(path); err != nil { + return err + } + if isProtectedAnalyzeDeletePath(path) { + return fmt.Errorf("protected path cannot be deleted: %s", path) + } + return nil +} + +func isProtectedAnalyzeDeletePath(path string) bool { + home := os.Getenv("HOME") + if home == "" || path == "" { + return false + } + + cleanPath := filepath.Clean(path) + orbstackState := filepath.Join(home, ".orbstack") + if cleanPath == orbstackState || strings.HasPrefix(cleanPath, orbstackState+string(filepath.Separator)) { + return true + } + + groupContainers := filepath.Join(home, "Library", "Group Containers") + rel, err := filepath.Rel(groupContainers, cleanPath) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + + containerName := rel + if idx := strings.Index(containerName, string(filepath.Separator)); idx >= 0 { + containerName = containerName[:idx] + } + return strings.HasSuffix(containerName, "dev.orbstack") +} + +// 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_fuzz_test.go b/Resources/mole/cmd/analyze/delete_fuzz_test.go new file mode 100644 index 0000000..c107b35 --- /dev/null +++ b/Resources/mole/cmd/analyze/delete_fuzz_test.go @@ -0,0 +1,61 @@ +//go:build darwin + +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +// FuzzValidatePath asserts the invariant: anything validatePath accepts +// (returns nil) must be absolute, free of null bytes, and free of ".." +// path components. The fuzzer also catches panics on adversarial input. +func FuzzValidatePath(f *testing.F) { + seeds := []string{ + "/Users/alice", + "/", + "", + "../etc/passwd", + "/../etc/passwd", + "/Users/../etc", + "relative/path", + "/path\x00with/null", + "/with spaces/file", + "/中文路径/测试", + "/very/deeply/nested/path/that/is/quite/long", + "/.", + "/..", + "/.../legitimate", + "/name..files/ok", + "//double/slash", + "/trailing/slash/", + "\x00", + string(make([]byte, 4096)), + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, path string) { + err := validatePath(path) + if err != nil { + return + } + + if path == "" { + t.Errorf("accepted empty path") + } + if !filepath.IsAbs(path) { + t.Errorf("accepted non-absolute path: %q", path) + } + if strings.Contains(path, "\x00") { + t.Errorf("accepted path with null bytes: %q", path) + } + for p := range strings.SplitSeq(path, string(filepath.Separator)) { + if p == ".." { + t.Errorf("accepted path with .. component: %q", path) + } + } + }) +} diff --git a/Resources/mole/cmd/analyze/delete_test.go b/Resources/mole/cmd/analyze/delete_test.go index 8d89ae1..061df38 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,135 @@ 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 TestValidateTrashTargetRejectsOrbStackLiveData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack"), + filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data"), + filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data", "data.img.raw"), + filepath.Join(home, ".orbstack"), + filepath.Join(home, ".orbstack", "state.db"), + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err == nil || !strings.Contains(err.Error(), "protected path") { + t.Fatalf("validateTrashTarget(%q) error = %v, want protected path error", path, err) + } + }) + } +} + +func TestValidateTrashTargetAllowsRegularUserPaths(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []string{ + filepath.Join(home, "Downloads", "old.zip"), + filepath.Join(home, "Library", "Caches", "example.cache"), + filepath.Join(home, "Library", "Group Containers", "group.com.example.tool", "Library", "Caches", "item"), + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + if err := validateTrashTarget(path); err != nil { + t.Fatalf("validateTrashTarget(%q) error = %v, want nil", path, 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..7a25d00 100644 --- a/Resources/mole/cmd/analyze/format.go +++ b/Resources/mole/cmd/analyze/format.go @@ -1,10 +1,15 @@ +//go:build darwin + package main import ( "fmt" "os" + "slices" "strings" "time" + + "github.com/tw93/mole/internal/units" ) func displayPath(path string) string { @@ -54,8 +59,8 @@ func truncateMiddle(s string, maxWidth int) string { tailWidth := 0 tailIdx := len(runes) - for i := len(runes) - 1; i >= 0; i-- { - w := runeWidth(runes[i]) + for i, r := range slices.Backward(runes) { + w := runeWidth(r) if tailWidth+w > targetTailWidth { break } @@ -77,20 +82,7 @@ func formatNumber(n int64) string { } func humanizeBytes(size int64) string { - if size < 0 { - return "0 B" - } - const unit = 1000 - if size < unit { - return fmt.Sprintf("%d B", size) - } - div, exp := int64(unit), 0 - for n := size / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - value := float64(size) / float64(div) - return fmt.Sprintf("%.1f %cB", value, "kMGTPE"[exp]) + return units.BytesSI(size) } func coloredProgressBar(value, maxValue int64, percent float64) string { diff --git a/Resources/mole/cmd/analyze/format_test.go b/Resources/mole/cmd/analyze/format_test.go index 65a8333..d0466cc 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 { @@ -55,30 +57,14 @@ func TestDisplayWidth(t *testing.T) { } } +// Core byte-format coverage lives in internal/units; this is a wiring sanity +// check to ensure humanizeBytes still delegates to BytesSI. func TestHumanizeBytes(t *testing.T) { - tests := []struct { - input int64 - want string - }{ - {-100, "0 B"}, - {0, "0 B"}, - {512, "512 B"}, - {999, "999 B"}, - {1000, "1.0 kB"}, - {1500, "1.5 kB"}, - {10000, "10.0 kB"}, - {1000000, "1.0 MB"}, - {1500000, "1.5 MB"}, - {1000000000, "1.0 GB"}, - {1000000000000, "1.0 TB"}, - {1000000000000000, "1.0 PB"}, + if got := humanizeBytes(1500); got != "1.5 kB" { + t.Errorf("humanizeBytes(1500) = %q, want %q", got, "1.5 kB") } - - for _, tt := range tests { - got := humanizeBytes(tt.input) - if got != tt.want { - t.Errorf("humanizeBytes(%d) = %q, want %q", tt.input, got, tt.want) - } + if got := humanizeBytes(-1); got != "0 B" { + t.Errorf("humanizeBytes(-1) = %q, want %q", got, "0 B") } } 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..8427d2c --- /dev/null +++ b/Resources/mole/cmd/analyze/insights.go @@ -0,0 +1,189 @@ +//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")}, + } + if matches, err := filepath.Glob(filepath.Join(home, "Library", "Group Containers", "*dev.orbstack", "data")); err == nil { + for _, match := range matches { + if info, statErr := os.Stat(match); statErr == nil && info.IsDir() { + cleanablePaths = append(cleanablePaths, struct { + name string + path string + }{"OrbStack Data", match}) + break + } + } + } + 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 the icon for an overview entry. +// +// Two-icon scheme so the column stays visually clean: +// - 📁 for top-level directories (Home, User Library, Applications, System +// Library) where the user is browsing structure. +// - 👀 for "hidden space insights": paths that silently accumulate disk +// usage and deserve a peek. Eyes signal attention without promising the +// contents are safe to delete, which matters for iOS Backups, Xcode +// Archives, and Old Downloads (valuable user data, not just cache). +// +// History: an earlier per-name icon zoo (📋 💾 🔨 📲 🐳 📱 📥) varied in +// render width and added no information. A broom (🧹) followed but +// mis-signalled "all of these are cleanable", which Xcode Archives and iOS +// Backups are not. +func insightIcon(entry dirEntry) string { + switch entry.Name { + case "Home", "User Library", "App Library", "Applications", "System Library": + 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..cc34d45 --- /dev/null +++ b/Resources/mole/cmd/analyze/insights_test.go @@ -0,0 +1,140 @@ +//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 TestCreateInsightEntriesIncludesOrbStackData(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + orbstackData := filepath.Join(home, "Library", "Group Containers", "HUAQ24HBR6.dev.orbstack", "data") + if err := os.MkdirAll(orbstackData, 0755); err != nil { + t.Fatal(err) + } + + entries := createInsightEntries() + for _, entry := range entries { + if entry.Name == "OrbStack Data" { + if entry.Path != orbstackData { + t.Fatalf("OrbStack path = %q, want %q", entry.Path, orbstackData) + } + return + } + } + t.Fatal("OrbStack Data insight not found") +} + +func TestInsightIcon(t *testing.T) { + // Two-icon scheme: top-level directories use 📁, every insight row + // uses 👀 (eyes; signals "peek here" without promising deletability). + tests := []struct { + name string + want string + }{ + // Top-level dirs. + {"Home", "📁"}, + {"User Library", "📁"}, + {"App Library", "📁"}, // Legacy name retained for backwards compatibility. + {"Applications", "📁"}, + {"System Library", "📁"}, + // Insights collapsed to a single eyes glyph. + {"iOS Backups", "👀"}, + {"Old Downloads (90d+)", "👀"}, + {"Homebrew Cache", "👀"}, + {"System Logs", "👀"}, + {"Xcode DerivedData", "👀"}, + {"Xcode Simulators", "👀"}, + {"Xcode Archives", "👀"}, + {"Docker Data", "👀"}, + {"OrbStack Data", "👀"}, + } + 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..19e79cf 100644 --- a/Resources/mole/cmd/analyze/json.go +++ b/Resources/mole/cmd/analyze/json.go @@ -6,25 +6,39 @@ import ( "encoding/json" "fmt" "os" + "sort" + "sync" "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 +48,142 @@ 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 + return jsonOutput{ + Path: path, + Overview: false, + Entries: jsonEntriesFromDirEntries(result.Entries, false, nil), + LargeFiles: jsonFileEntriesFromFileEntries(result.LargeFiles), + TotalSize: result.TotalSize, + TotalFiles: result.TotalFiles, + } +} + +func performOverviewScanForJSON(path string) jsonOutput { + insightEntries := createInsightEntries() + overviewEntries := createOverviewEntriesWithInsights(insightEntries) + insightPaths := make(map[string]bool, len(insightEntries)) + for _, insight := range insightEntries { + insightPaths[insight.Path] = true + } + var totalSize int64 + entries := make([]dirEntry, 0, len(overviewEntries)) + for _, entry := range measureOverviewEntriesForJSON(overviewEntries, insightPaths) { + // 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, + Overview: true, + Entries: jsonEntriesFromDirEntries(entries, true, insightPaths), + TotalSize: totalSize, + } +} + +func measureOverviewEntriesForJSON(overviewEntries []dirEntry, insightPaths map[string]bool) []dirEntry { + if len(overviewEntries) == 0 { + return nil + } - for _, item := range items { - fullPath := path + "/" + item.Name() - var size int64 + type measurement struct { + index int + entry dirEntry + } + + measured := make([]dirEntry, len(overviewEntries)) + sem := make(chan struct{}, maxConcurrentOverview) + results := make(chan measurement, len(overviewEntries)) + + var wg sync.WaitGroup + for index, item := range overviewEntries { + wg.Go(func() { + sem <- struct{}{} + defer func() { <-sem }() + + var ( + size int64 + err error + ) + + if cached, cacheErr := loadOverviewCachedSize(item.Path); cacheErr == nil && cached > 0 { + size = cached + } else if insightPaths[item.Path] { + size, err = measureInsightSize(item.Path) + } else { + size, err = measureOverviewSize(item.Path) + } - if item.IsDir() { - size = calculateDirSizeFast(fullPath, &filesScanned, &dirsScanned, &bytesScanned, currentPath) - } else { - info, err := item.Info() if err == nil { - size = info.Size() - atomic.AddInt64(&filesScanned, 1) - atomic.AddInt64(&bytesScanned, size) + item.Size = size } + results <- measurement{index: index, entry: item} + }) + } + + wg.Wait() + close(results) + + for result := range results { + measured[result.index] = result.entry + } + return measured +} + +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), } - totalSize += size - entries = append(entries, jsonEntry{ - Name: item.Name(), - Path: fullPath, - Size: size, - IsDir: item.IsDir(), - }) + 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 +} - return jsonOutput{ - Path: path, - Entries: entries, - TotalSize: totalSize, - TotalFiles: atomic.LoadInt64(&filesScanned), +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/live_config.go b/Resources/mole/cmd/analyze/live_config.go new file mode 100644 index 0000000..df28345 --- /dev/null +++ b/Resources/mole/cmd/analyze/live_config.go @@ -0,0 +1,45 @@ +//go:build darwin + +package main + +import ( + "os" + "strings" +) + +const ( + liveSortModeEnv = "MOLE_ANALYZE_LIVE_SORT" + liveCursorModeEnv = "MOLE_ANALYZE_LIVE_CURSOR" +) + +func liveScanSortModeFromEnv() liveSortMode { + switch strings.ToLower(strings.TrimSpace(os.Getenv(liveSortModeEnv))) { + case "freeze", "freeze-on-move", "freeze_on_move", "stop-on-move", "stop_on_move": + return liveSortFreezeOnMove + default: + return liveSortContinuous + } +} + +func liveScanCursorModeFromEnv() liveCursorMode { + switch strings.ToLower(strings.TrimSpace(os.Getenv(liveCursorModeEnv))) { + case "index": + return liveCursorByIndex + default: + return liveCursorByPath + } +} + +func nextLiveSortMode(mode liveSortMode) liveSortMode { + if mode == liveSortContinuous { + return liveSortFreezeOnMove + } + return liveSortContinuous +} + +func liveSortModeLabel(mode liveSortMode) string { + if mode == liveSortFreezeOnMove { + return "freeze-on-move" + } + return "continuous" +} diff --git a/Resources/mole/cmd/analyze/live_scan.go b/Resources/mole/cmd/analyze/live_scan.go new file mode 100644 index 0000000..22a6adc --- /dev/null +++ b/Resources/mole/cmd/analyze/live_scan.go @@ -0,0 +1,469 @@ +//go:build darwin + +package main + +import ( + "container/heap" + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "sync" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +var nextLiveScanID atomic.Int64 + +type liveScanTargetKind int + +const ( + liveScanTargetDirectory liveScanTargetKind = iota + 1 + liveScanTargetFoldedDirectory + liveScanTargetHomeLibrary +) + +type liveScanTarget struct { + name string + path string + kind liveScanTargetKind +} + +func startLiveScanCmd(path string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) tea.Cmd { + return func() tea.Msg { + id := nextLiveScanID.Add(1) + ctx, cancel := context.WithCancel(context.Background()) + + limiter := newScanLimiter(0) + entries, targets, totalSize, totalFiles, largeFiles, err := readLiveScanInitialEntries(path, limiter) + if err != nil { + cancel() + return liveScanStartMsg{id: id, path: path, err: err} + } + + if totalFiles > 0 { + atomic.AddInt64(filesScanned, totalFiles) + } + if totalSize > 0 { + atomic.AddInt64(bytesScanned, totalSize) + } + + events := make(chan liveScanEventMsg, max(len(targets)*4, 1)) + go runLiveScan(ctx, id, path, entries, targets, totalSize, totalFiles, largeFiles, limiter, filesScanned, dirsScanned, bytesScanned, currentPath, events) + + scanningPaths := make([]string, 0, len(targets)) + for _, target := range targets { + scanningPaths = append(scanningPaths, target.path) + } + + return liveScanStartMsg{ + id: id, + path: path, + entries: entries, + totalSize: totalSize, + totalFiles: totalFiles, + largeFiles: largeFiles, + scanningPaths: scanningPaths, + events: events, + cancel: cancel, + } + } +} + +func readLiveScanInitialEntries(root string, limiter *scanLimiter) ([]dirEntry, []liveScanTarget, int64, int64, []fileEntry, error) { + children, err := os.ReadDir(root) + if err != nil { + return nil, nil, 0, 0, nil, err + } + if limiter == nil { + limiter = newScanLimiter(len(children)) + } + + isRootDir := root == "/" + home := os.Getenv("HOME") + isHomeDir := home != "" && root == home + + entries := make([]dirEntry, 0, len(children)) + targets := make([]liveScanTarget, 0, len(children)) + largeFiles := make([]fileEntry, 0) + var totalSize int64 + var totalFiles int64 + + for _, child := range children { + fullPath := filepath.Join(root, child.Name()) + + if child.Type()&fs.ModeSymlink != 0 { + targetInfo, err := os.Stat(fullPath) + isDir := false + if err == nil && targetInfo.IsDir() { + isDir = true + } + info, err := child.Info() + if err != nil { + continue + } + size := getActualFileSize(fullPath, info) + totalSize += size + entries = append(entries, dirEntry{ + Name: child.Name() + " →", + Path: fullPath, + Size: size, + IsDir: isDir, + LastAccess: getLastAccessTimeFromInfo(info), + }) + continue + } + + if child.IsDir() { + if defaultSkipDirs[child.Name()] { + continue + } + if isRootDir && skipSystemDirs[child.Name()] { + continue + } + + targetKind := liveScanTargetDirectory + if isHomeDir && child.Name() == "Library" { + targetKind = liveScanTargetHomeLibrary + } else if shouldFoldDirWithPath(child.Name(), fullPath) { + targetKind = liveScanTargetFoldedDirectory + } + + entries = append(entries, dirEntry{ + Name: child.Name(), + Path: fullPath, + Size: -1, + IsDir: true, + }) + targets = append(targets, liveScanTarget{ + name: child.Name(), + path: fullPath, + kind: targetKind, + }) + continue + } + + info, err := child.Info() + if err != nil { + continue + } + size, _ := countableFileSize(info, &limiter.seen) + totalSize += size + totalFiles++ + entries = append(entries, dirEntry{ + Name: child.Name(), + Path: fullPath, + Size: size, + IsDir: false, + LastAccess: getLastAccessTimeFromInfo(info), + }) + if !shouldSkipFileForLargeTracking(fullPath) && size >= largeFileWarmupMinSize { + largeFiles = append(largeFiles, fileEntry{Name: child.Name(), Path: fullPath, Size: size}) + } + } + + sortDirEntriesBySize(entries) + largeFiles = topLargeFiles(largeFiles) + return entries, targets, totalSize, totalFiles, largeFiles, nil +} + +func runLiveScan( + ctx context.Context, + id int64, + root string, + initialEntries []dirEntry, + targets []liveScanTarget, + initialTotalSize int64, + initialTotalFiles int64, + initialLargeFiles []fileEntry, + limiter *scanLimiter, + filesScanned, dirsScanned, bytesScanned *int64, + currentPath *atomic.Value, + events chan<- liveScanEventMsg, +) { + defer close(events) + + entriesByPath := make(map[string]dirEntry, len(initialEntries)) + for _, entry := range initialEntries { + entriesByPath[entry.Path] = entry + } + + var totalSize atomic.Int64 + var totalFiles atomic.Int64 + totalSize.Store(initialTotalSize) + totalFiles.Store(initialTotalFiles) + + largeFileChan := make(chan fileEntry, maxLargeFiles*2) + largeFileMinSize := int64(largeFileWarmupMinSize) + largeFilesDone := make(chan []fileEntry, 1) + go collectLiveLargeFiles(initialLargeFiles, largeFileChan, &largeFileMinSize, largeFilesDone) + + var dedupedHardlink atomic.Bool + var mu sync.Mutex + var wg sync.WaitGroup + + for _, target := range targets { + if ctx.Err() != nil { + break + } + target := target + scanTarget := func() { + defer wg.Done() + result, err := scanLiveTargetWithProgress(ctx, id, root, target, largeFileChan, &largeFileMinSize, limiter, currentPath, events) + if err != nil && !errors.Is(err, context.Canceled) { + sendLiveScanEvent(ctx, events, liveScanEventMsg{id: id, path: root, kind: liveScanFailed, entry: dirEntry{Name: target.name, Path: target.path, IsDir: true}, err: err}) + return + } + if ctx.Err() != nil { + return + } + + entry := dirEntry{ + Name: target.name, + Path: target.path, + Size: result.TotalSize, + IsDir: true, + } + mu.Lock() + entriesByPath[target.path] = entry + mu.Unlock() + + totalSize.Add(result.TotalSize) + if result.TotalFiles > 0 { + totalFiles.Add(result.TotalFiles) + } + if result.dedupedHardlink { + dedupedHardlink.Store(true) + } + atomic.AddInt64(dirsScanned, 1) + if result.TotalFiles > 0 { + atomic.AddInt64(filesScanned, result.TotalFiles) + } + if result.TotalSize > 0 { + atomic.AddInt64(bytesScanned, result.TotalSize) + } + + sendLiveScanEvent(ctx, events, liveScanEventMsg{ + id: id, + path: root, + kind: liveScanChildDone, + entry: entry, + result: result, + }) + } + + wg.Add(1) + if limiter.tryAcquireEntry() { + go func() { + defer limiter.releaseEntry() + scanTarget() + }() + } else { + scanTarget() + } + } + + wg.Wait() + close(largeFileChan) + largeFiles := <-largeFilesDone + + if ctx.Err() != nil { + sendLiveScanEvent(context.Background(), events, liveScanEventMsg{id: id, path: root, kind: liveScanCanceled, err: ctx.Err()}) + return + } + + mu.Lock() + finalEntries := make([]dirEntry, 0, len(entriesByPath)) + for _, entry := range entriesByPath { + finalEntries = append(finalEntries, entry) + } + mu.Unlock() + sortDirEntriesBySize(finalEntries) + if len(finalEntries) > maxEntries { + finalEntries = finalEntries[:maxEntries] + } + + result := scanResult{ + Entries: finalEntries, + LargeFiles: largeFiles, + TotalSize: totalSize.Load(), + TotalFiles: totalFiles.Load(), + dedupedHardlink: dedupedHardlink.Load(), + } + + sendLiveScanEvent(ctx, events, liveScanEventMsg{id: id, path: root, kind: liveScanComplete, result: result}) +} + +func scanLiveTargetWithProgress(ctx context.Context, id int64, root string, target liveScanTarget, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, currentPath *atomic.Value, events chan<- liveScanEventMsg) (scanResult, error) { + var filesScanned int64 + var dirsScanned int64 + var bytesScanned int64 + localCurrentPath := &atomic.Value{} + localCurrentPath.Store("") + done := make(chan struct{}) + progressDone := make(chan struct{}) + + go func() { + defer close(progressDone) + ticker := time.NewTicker(uiTickInterval * 2) + defer ticker.Stop() + + var lastSize int64 + for { + select { + case <-ctx.Done(): + return + case <-done: + return + case <-ticker.C: + size := atomic.LoadInt64(&bytesScanned) + if size <= 0 || size == lastSize { + continue + } + lastSize = size + if currentPath != nil { + if path, _ := localCurrentPath.Load().(string); path != "" { + currentPath.Store(path) + } + } + sendLiveScanProgress(ctx, events, liveScanEventMsg{ + id: id, + path: root, + kind: liveScanChildProgress, + entry: dirEntry{ + Name: target.name, + Path: target.path, + Size: size, + IsDir: true, + }, + }) + } + } + }() + + result, err := scanLiveTarget(ctx, target, largeFileChan, largeFileMinSize, limiter, &filesScanned, &dirsScanned, &bytesScanned, localCurrentPath) + close(done) + <-progressDone + if result.TotalFiles == 0 { + result.TotalFiles = atomic.LoadInt64(&filesScanned) + } + if result.TotalSize == 0 { + result.TotalSize = atomic.LoadInt64(&bytesScanned) + } + return result, err +} + +func scanLiveTarget(ctx context.Context, target liveScanTarget, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) (scanResult, error) { + if err := ctx.Err(); err != nil { + return scanResult{}, err + } + + switch target.kind { + case liveScanTargetHomeLibrary: + if cached, err := loadStoredOverviewSize(target.path); err == nil && cached > 0 { + return scanResult{TotalSize: cached}, nil + } + case liveScanTargetFoldedDirectory: + size, err := getDirectorySizeFromDu(target.path) + if err != nil || size <= 0 { + size = calculateDirSizeFastWithLimiter(target.path, limiter, filesScanned, dirsScanned, bytesScanned, currentPath) + } else { + atomic.AddInt64(bytesScanned, size) + } + return scanResult{TotalSize: size}, nil + } + + if err := ctx.Err(); err != nil { + return scanResult{}, err + } + + result := scanSubdirWithCache(target.path, largeFileChan, largeFileMinSize, limiter, limiter.dirSem, limiter.duSem, limiter.duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + return result, ctx.Err() +} + +func collectLiveLargeFiles(initial []fileEntry, largeFileChan <-chan fileEntry, largeFileMinSize *int64, done chan<- []fileEntry) { + h := &largeFileHeap{} + heap.Init(h) + for _, file := range initial { + pushLiveLargeFile(h, file, largeFileMinSize) + } + for file := range largeFileChan { + pushLiveLargeFile(h, file, largeFileMinSize) + } + files := make([]fileEntry, h.Len()) + for i := range slices.Backward(files) { + files[i] = heap.Pop(h).(fileEntry) + } + done <- files +} + +func pushLiveLargeFile(h *largeFileHeap, file fileEntry, largeFileMinSize *int64) { + if h.Len() < maxLargeFiles { + heap.Push(h, file) + if h.Len() == maxLargeFiles { + atomic.StoreInt64(largeFileMinSize, (*h)[0].Size) + } + return + } + if file.Size > (*h)[0].Size { + heap.Pop(h) + heap.Push(h, file) + atomic.StoreInt64(largeFileMinSize, (*h)[0].Size) + } +} + +func sendLiveScanEvent(ctx context.Context, events chan<- liveScanEventMsg, msg liveScanEventMsg) { + select { + case <-ctx.Done(): + case events <- msg: + } +} + +func sendLiveScanProgress(ctx context.Context, events chan<- liveScanEventMsg, msg liveScanEventMsg) { + select { + case <-ctx.Done(): + case events <- msg: + default: + } +} + +func waitLiveScanEventCmd(events <-chan liveScanEventMsg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-events + if !ok { + return nil + } + return msg + } +} + +func sortDirEntriesBySize(entries []dirEntry) { + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].Size > entries[j].Size + }) +} + +func topLargeFiles(files []fileEntry) []fileEntry { + if len(files) <= maxLargeFiles { + sort.SliceStable(files, func(i, j int) bool { + return files[i].Size > files[j].Size + }) + return files + } + h := &largeFileHeap{} + heap.Init(h) + var minSize int64 = largeFileWarmupMinSize + for _, file := range files { + pushLiveLargeFile(h, file, &minSize) + } + top := make([]fileEntry, h.Len()) + for i := range slices.Backward(top) { + top[i] = heap.Pop(h).(fileEntry) + } + return top +} diff --git a/Resources/mole/cmd/analyze/main.go b/Resources/mole/cmd/analyze/main.go index c8ed030..57b2156 100644 --- a/Resources/mole/cmd/analyze/main.go +++ b/Resources/mole/cmd/analyze/main.go @@ -9,9 +9,8 @@ import ( "os" "os/exec" "path/filepath" - "slices" - "sort" "sync/atomic" + "syscall" "time" tea "github.com/charmbracelet/bubbletea" @@ -21,116 +20,6 @@ var ( jsonMode = flag.Bool("json", false, "output analysis as JSON instead of TUI") ) -type dirEntry struct { - Name string - Path string - Size int64 - IsDir bool - LastAccess time.Time -} - -type fileEntry struct { - Name string - Path string - Size int64 -} - -type scanResult struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 -} - -type cacheEntry struct { - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - ModTime time.Time - ScanTime time.Time -} - -type historyEntry struct { - Path string - Entries []dirEntry - LargeFiles []fileEntry - TotalSize int64 - TotalFiles int64 - Selected int - EntryOffset int - LargeSelected int - LargeOffset int - Dirty bool - IsOverview bool -} - -type scanResultMsg struct { - path string - result scanResult - err error - stale bool -} - -type overviewSizeMsg struct { - Path string - Index int - Size int64 - Err error -} - -type tickMsg time.Time - -type deleteProgressMsg struct { - done bool - err error - count int64 - path string -} - -type model struct { - path string - history []historyEntry - entries []dirEntry - largeFiles []fileEntry - selected int - offset int - status string - totalSize int64 - scanning bool - spinner int - filesScanned *int64 - dirsScanned *int64 - bytesScanned *int64 - currentPath *atomic.Value - showLargeFiles bool - isOverview bool - deleteConfirm bool - deleteTarget *dirEntry - deleting bool - deleteCount *int64 - cache map[string]historyEntry - largeSelected int - largeOffset int - overviewSizeCache map[string]int64 - overviewFilesScanned *int64 - overviewDirsScanned *int64 - overviewBytesScanned *int64 - overviewCurrentPath *string - overviewScanning bool - overviewScanningSet map[string]bool // Track which paths are currently being scanned - width int // Terminal width - height int // Terminal height - multiSelected map[string]bool // Track multi-selected items by path (safer than index) - largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) - totalFiles int64 // Total files found in current/last scan - lastTotalFiles int64 // Total files from previous scan (for progress bar) -} - -func (m model) inOverviewMode() bool { - return m.isOverview && m.path == "/" -} - func main() { flag.Parse() @@ -155,6 +44,7 @@ func main() { isOverview = false } + go pruneAnalyzerCache() if *jsonMode { runJSONMode(abs, isOverview) } else { @@ -163,10 +53,14 @@ func main() { } func runTUIMode(path string, isOverview bool) { - // Warm overview cache in background. - prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer prefetchCancel() - go prefetchOverviewCache(prefetchCtx) + // Warm overview cache only when the user opens a specific directory. + // Overview mode already schedules the same measurements for the foreground UI; + // running the prefetcher there doubles the du/io workload on cold start. + if !isOverview { + prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer prefetchCancel() + go prefetchOverviewCache(prefetchCtx) + } p := tea.NewProgram(newModel(path, isOverview), tea.WithAltScreen()) if _, err := p.Run(); err != nil { @@ -179,29 +73,31 @@ func newModel(path string, isOverview bool) model { var filesScanned, dirsScanned, bytesScanned int64 currentPath := &atomic.Value{} 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...", - scanning: !isOverview, - filesScanned: &filesScanned, - dirsScanned: &dirsScanned, - bytesScanned: &bytesScanned, - currentPath: currentPath, - showLargeFiles: false, - isOverview: isOverview, - cache: make(map[string]historyEntry), - overviewFilesScanned: &overviewFilesScanned, - overviewDirsScanned: &overviewDirsScanned, - overviewBytesScanned: &overviewBytesScanned, - overviewCurrentPath: &overviewCurrentPath, - overviewSizeCache: make(map[string]int64), - overviewScanningSet: make(map[string]bool), - multiSelected: make(map[string]bool), - largeMultiSelected: make(map[string]bool), + path: path, + selected: 0, + status: "Preparing scan...", + diskFree: diskFreeBytes, + scanning: !isOverview, + filesScanned: &filesScanned, + dirsScanned: &dirsScanned, + bytesScanned: &bytesScanned, + currentPath: currentPath, + showLargeFiles: false, + isOverview: isOverview, + cache: make(map[string]historyEntry), + overviewSizeCache: make(map[string]int64), + overviewScanningSet: make(map[string]bool), + multiSelected: make(map[string]bool), + largeMultiSelected: make(map[string]bool), + liveSortMode: liveScanSortModeFromEnv(), + liveCursorMode: liveScanCursorModeFromEnv(), } if isOverview { @@ -228,6 +124,10 @@ func newModel(path string, isOverview bool) model { } func createOverviewEntries() []dirEntry { + return createOverviewEntriesWithInsights(createInsightEntries()) +} + +func createOverviewEntriesWithInsights(insightEntries []dirEntry) []dirEntry { home := os.Getenv("HOME") entries := []dirEntry{} @@ -237,7 +137,10 @@ func createOverviewEntries() []dirEntry { userLibrary := filepath.Join(home, "Library") if _, err := os.Stat(userLibrary); err == nil { - entries = append(entries, dirEntry{Name: "App Library", Path: userLibrary, IsDir: true, Size: -1}) + // Renamed from "App Library" to "User Library" so it parallels + // "System Library" (`/Library`) and is not confused with + // `/Applications`. Path unchanged. + entries = append(entries, dirEntry{Name: "User Library", Path: userLibrary, IsDir: true, Size: -1}) } } @@ -246,857 +149,10 @@ func createOverviewEntries() []dirEntry { dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1}, ) - return entries -} - -func (m *model) hydrateOverviewEntries() { - m.entries = createOverviewEntries() - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - for i := range m.entries { - if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok { - m.entries[i].Size = size - continue - } - if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil { - m.entries[i].Size = size - m.overviewSizeCache[m.entries[i].Path] = size - } - } - m.totalSize = sumKnownEntrySizes(m.entries) -} - -func (m *model) sortOverviewEntriesBySize() { - // Stable sort by size. - sort.SliceStable(m.entries, func(i, j int) bool { - return m.entries[i].Size > m.entries[j].Size - }) -} - -func (m *model) scheduleOverviewScans() tea.Cmd { - if !m.inOverviewMode() { - return nil - } - - var pendingIndices []int - for i, entry := range m.entries { - if entry.Size < 0 && !m.overviewScanningSet[entry.Path] { - pendingIndices = append(pendingIndices, i) - if len(pendingIndices) >= maxConcurrentOverview { - break - } - } - } - - if len(pendingIndices) == 0 { - m.overviewScanning = false - if !hasPendingOverviewEntries(m.entries) { - m.sortOverviewEntriesBySize() - m.status = "Ready" - } - return nil - } - - var cmds []tea.Cmd - for _, idx := range pendingIndices { - entry := m.entries[idx] - m.overviewScanningSet[entry.Path] = true - cmd := scanOverviewPathCmd(entry.Path, idx) - cmds = append(cmds, cmd) - } - - m.overviewScanning = true - remaining := 0 - for _, e := range m.entries { - if e.Size < 0 { - remaining++ - } - } - if len(pendingIndices) > 0 { - firstEntry := m.entries[pendingIndices[0]] - if len(pendingIndices) == 1 { - m.status = fmt.Sprintf("Scanning %s..., %d left", firstEntry.Name, remaining) - } else { - m.status = fmt.Sprintf("Scanning %d directories..., %d left", len(pendingIndices), remaining) - } - } - - cmds = append(cmds, tickCmd()) - return tea.Batch(cmds...) -} - -func (m *model) getScanProgress() (files, dirs, bytes int64) { - if m.filesScanned != nil { - files = atomic.LoadInt64(m.filesScanned) - } - if m.dirsScanned != nil { - dirs = atomic.LoadInt64(m.dirsScanned) - } - if m.bytesScanned != nil { - bytes = atomic.LoadInt64(m.bytesScanned) - } - return -} - -func (m model) Init() tea.Cmd { - if m.inOverviewMode() { - return m.scheduleOverviewScans() - } - return tea.Batch(m.scanCmd(m.path), tickCmd()) -} - -func (m model) scanCmd(path string) tea.Cmd { - return func() tea.Msg { - if cached, err := loadCacheFromDisk(path); err == nil { - result := scanResult{ - Entries: cached.Entries, - LargeFiles: cached.LargeFiles, - TotalSize: cached.TotalSize, - TotalFiles: cached.TotalFiles, - } - return scanResultMsg{path: path, result: result, err: nil} - } - - if stale, err := loadStaleCacheFromDisk(path); err == nil { - result := scanResult{ - Entries: stale.Entries, - LargeFiles: stale.LargeFiles, - TotalSize: stale.TotalSize, - TotalFiles: stale.TotalFiles, - } - return scanResultMsg{path: path, result: result, err: nil, stale: true} - } - - v, err, _ := scanGroup.Do(path, func() (any, error) { - return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) - }) - - if err != nil { - return scanResultMsg{path: path, err: err} - } - - result := v.(scanResult) - - go func(p string, r scanResult) { - if err := saveCacheToDisk(p, r); err != nil { - _ = err // Cache save failure is not critical - } - }(path, result) - - return scanResultMsg{path: path, result: result, err: nil} - } -} - -func (m model) scanFreshCmd(path string) tea.Cmd { - return func() tea.Msg { - v, err, _ := scanGroup.Do(path, func() (any, error) { - return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath) - }) - - if err != nil { - return scanResultMsg{path: path, err: err} - } - - result := v.(scanResult) - go func(p string, r scanResult) { - if err := saveCacheToDisk(p, r); err != nil { - _ = err - } - }(path, result) - - return scanResultMsg{path: path, result: result} - } -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m.updateKey(msg) - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case deleteProgressMsg: - if msg.done { - m.deleting = false - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - if msg.err != nil { - m.status = fmt.Sprintf("Failed to delete: %v", msg.err) - } else { - if msg.path != "" { - m.removePathFromView(msg.path) - invalidateCache(msg.path) - } - invalidateCache(m.path) - m.status = fmt.Sprintf("Deleted %d items", msg.count) - for i := range m.history { - m.history[i].Dirty = true - } - for path := range m.cache { - entry := m.cache[path] - entry.Dirty = true - m.cache[path] = entry - } - m.scanning = true - 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.scanCmd(m.path), tickCmd()) - } - } - return m, nil - case scanResultMsg: - if msg.path != "" && msg.path != m.path { - return m, nil - } - m.scanning = false - if msg.err != nil { - 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) - } - } - m.entries = filteredEntries - m.largeFiles = msg.result.LargeFiles - m.totalSize = msg.result.TotalSize - m.totalFiles = msg.result.TotalFiles - m.clampEntrySelection() - m.clampLargeSelection() - m.cache[m.path] = cacheSnapshot(m) - if m.totalSize > 0 { - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - m.overviewSizeCache[m.path] = m.totalSize - go func(path string, size int64) { - _ = storeOverviewSize(path, size) - }(m.path, m.totalSize) - } - - if msg.stale { - 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)) - return m, nil - case overviewSizeMsg: - delete(m.overviewScanningSet, msg.Path) - - if msg.Err == nil { - if m.overviewSizeCache == nil { - m.overviewSizeCache = make(map[string]int64) - } - m.overviewSizeCache[msg.Path] = msg.Size - } - - if m.inOverviewMode() { - for i := range m.entries { - if m.entries[i].Path == msg.Path { - if msg.Err == nil { - m.entries[i].Size = msg.Size - } else { - m.entries[i].Size = 0 - } - break - } - } - m.totalSize = sumKnownEntrySizes(m.entries) - - if msg.Err != nil { - m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err) - } - - cmd := m.scheduleOverviewScans() - return m, cmd - } - return m, nil - case tickMsg: - hasPending := false - if m.inOverviewMode() { - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - } - if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) { - m.spinner = (m.spinner + 1) % len(spinnerFrames) - if m.deleting && m.deleteCount != nil { - count := atomic.LoadInt64(m.deleteCount) - if count > 0 { - m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count)) - } - } - return m, tickCmd() - } - return m, nil - default: - return m, nil - } -} - -func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Delete confirm flow. - if m.deleteConfirm { - switch msg.String() { - case "enter": - m.deleteConfirm = false - m.deleting = true - var deleteCount int64 - m.deleteCount = &deleteCount - - // Collect paths (safer than indices). - var pathsToDelete []string - if m.showLargeFiles { - if len(m.largeMultiSelected) > 0 { - for path := range m.largeMultiSelected { - pathsToDelete = append(pathsToDelete, path) - } - } else if m.deleteTarget != nil { - pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) - } - } else { - if len(m.multiSelected) > 0 { - for path := range m.multiSelected { - pathsToDelete = append(pathsToDelete, path) - } - } else if m.deleteTarget != nil { - pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) - } - } - - m.deleteTarget = nil - if len(pathsToDelete) == 0 { - m.deleting = false - m.status = "Nothing to delete" - return m, nil - } - - if len(pathsToDelete) == 1 { - targetPath := pathsToDelete[0] - m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath)) - return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) - } - - m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) - return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) - case "esc", "q": - m.status = "Cancelled" - m.deleteConfirm = false - m.deleteTarget = nil - return m, nil - default: - return m, nil - } - } - - switch msg.String() { - case "q", "ctrl+c", "Q": - return m, tea.Quit - case "esc": - if m.showLargeFiles { - m.showLargeFiles = false - return m, nil - } - return m, tea.Quit - case "up", "k", "K": - if m.showLargeFiles { - if m.largeSelected > 0 { - m.largeSelected-- - if m.largeSelected < m.largeOffset { - m.largeOffset = m.largeSelected - } - } - } else if len(m.entries) > 0 && m.selected > 0 { - m.selected-- - if m.selected < m.offset { - m.offset = m.selected - } - } - case "down", "j", "J": - if m.showLargeFiles { - if m.largeSelected < len(m.largeFiles)-1 { - m.largeSelected++ - viewport := calculateViewport(m.height, true) - if m.largeSelected >= m.largeOffset+viewport { - m.largeOffset = m.largeSelected - viewport + 1 - } - } - } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { - m.selected++ - viewport := calculateViewport(m.height, false) - if m.selected >= m.offset+viewport { - m.offset = m.selected - viewport + 1 - } - } - case "enter", "right", "l", "L": - if m.showLargeFiles { - return m, nil - } - return m.enterSelectedDir() - case "b", "left", "h", "B", "H": - if m.showLargeFiles { - 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 - case "r", "R": - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - - if m.inOverviewMode() { - // Explicitly invalidate cache for all overview entries to force re-scan - for _, entry := range m.entries { - invalidateCache(entry.Path) - } - - m.overviewSizeCache = make(map[string]int64) - m.overviewScanningSet = make(map[string]bool) - m.hydrateOverviewEntries() // Reset sizes to pending - - for i := range m.entries { - m.entries[i].Size = -1 - } - m.totalSize = 0 - - m.status = "Refreshing..." - m.overviewScanning = true - return m, tea.Batch(m.scheduleOverviewScans(), tickCmd()) - } - - invalidateCache(m.path) - m.status = "Refreshing..." - 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.scanCmd(m.path), tickCmd()) - case "t", "T": - if !m.inOverviewMode() { - m.showLargeFiles = !m.showLargeFiles - if m.showLargeFiles { - m.largeSelected = 0 - m.largeOffset = 0 - m.largeMultiSelected = make(map[string]bool) - } else { - m.multiSelected = make(map[string]bool) - } - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - case "o", "O": - // Open selected entries (multi-select aware). - const maxBatchOpen = 20 - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - count := len(m.largeMultiSelected) - if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) - return m, nil - } - for path := range m.largeMultiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } - m.status = fmt.Sprintf("Opening %d items...", count) - } else { - selected := m.largeFiles[m.largeSelected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) - } - } - } else if len(m.entries) > 0 { - if len(m.multiSelected) > 0 { - count := len(m.multiSelected) - if count > maxBatchOpen { - m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) - return m, nil - } - for path := range m.multiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", p).Run() - }(path) - } - m.status = fmt.Sprintf("Opening %d items...", count) - } else { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Opening %s...", selected.Name) - } - } - case "f", "F": - // Reveal in Finder (multi-select aware). - const maxBatchReveal = 20 - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - count := len(m.largeMultiSelected) - if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) - return m, nil - } - for path := range m.largeMultiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } - m.status = fmt.Sprintf("Showing %d items in Finder...", count) - } else { - selected := m.largeFiles[m.largeSelected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) - } - } - } else if len(m.entries) > 0 { - if len(m.multiSelected) > 0 { - count := len(m.multiSelected) - if count > maxBatchReveal { - m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) - return m, nil - } - for path := range m.multiSelected { - go func(p string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", p).Run() - }(path) - } - m.status = fmt.Sprintf("Showing %d items in Finder...", count) - } else { - selected := m.entries[m.selected] - go func(path string) { - ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) - defer cancel() - _ = exec.CommandContext(ctx, "open", "-R", path).Run() - }(selected.Path) - m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) - } - } - case " ": - // Toggle multi-select (paths as keys). - if m.showLargeFiles { - if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) { - if m.largeMultiSelected == nil { - m.largeMultiSelected = make(map[string]bool) - } - selectedPath := m.largeFiles[m.largeSelected].Path - if m.largeMultiSelected[selectedPath] { - delete(m.largeMultiSelected, selectedPath) - } else { - m.largeMultiSelected[selectedPath] = true - } - count := len(m.largeMultiSelected) - if count > 0 { - var totalSize int64 - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - totalSize += file.Size - break - } - } - } - m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) - } else { - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - } - } else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) { - if m.multiSelected == nil { - m.multiSelected = make(map[string]bool) - } - selectedPath := m.entries[m.selected].Path - if m.multiSelected[selectedPath] { - delete(m.multiSelected, selectedPath) - } else { - m.multiSelected[selectedPath] = true - } - count := len(m.multiSelected) - if count > 0 { - var totalSize int64 - for path := range m.multiSelected { - for _, entry := range m.entries { - if entry.Path == path { - totalSize += entry.Size - break - } - } - } - m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) - } else { - m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) - } - } - case "delete", "backspace": - if m.showLargeFiles { - if len(m.largeFiles) > 0 { - if len(m.largeMultiSelected) > 0 { - m.deleteConfirm = true - for path := range m.largeMultiSelected { - for _, file := range m.largeFiles { - if file.Path == path { - m.deleteTarget = &dirEntry{ - Name: file.Name, - Path: file.Path, - Size: file.Size, - IsDir: false, - } - break - } - } - break // Only need first one for display - } - } else if m.largeSelected < len(m.largeFiles) { - selected := m.largeFiles[m.largeSelected] - m.deleteConfirm = true - m.deleteTarget = &dirEntry{ - Name: selected.Name, - Path: selected.Path, - Size: selected.Size, - IsDir: false, - } - } - } - } else if len(m.entries) > 0 && !m.inOverviewMode() { - if len(m.multiSelected) > 0 { - m.deleteConfirm = true - for path := range m.multiSelected { - // Resolve entry by path. - for i := range m.entries { - if m.entries[i].Path == path { - m.deleteTarget = &m.entries[i] - break - } - } - break // Only need first one for display - } - } else if m.selected < len(m.entries) { - selected := m.entries[m.selected] - m.deleteConfirm = true - m.deleteTarget = &selected - } - } - } - return m, nil -} - -func (m *model) switchToOverviewMode() tea.Cmd { - m.isOverview = true - m.path = "/" - m.scanning = false - m.showLargeFiles = false - m.largeFiles = nil - m.largeSelected = 0 - m.largeOffset = 0 - m.deleteConfirm = false - m.deleteTarget = nil - m.selected = 0 - m.offset = 0 - m.hydrateOverviewEntries() - cmd := m.scheduleOverviewScans() - if cmd == nil { - m.status = "Ready" - return nil - } - return tea.Batch(cmd, tickCmd()) -} - -func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { - if len(m.entries) == 0 { - return m, nil - } - selected := m.entries[m.selected] - if selected.IsDir { - if len(m.history) == 0 || m.history[len(m.history)-1].Path != m.path { - m.history = append(m.history, snapshotFromModel(m)) - } - m.path = selected.Path - m.selected = 0 - m.offset = 0 - m.status = "Scanning..." - m.scanning = true - m.isOverview = false - m.multiSelected = make(map[string]bool) - m.largeMultiSelected = make(map[string]bool) - - atomic.StoreInt64(m.filesScanned, 0) - atomic.StoreInt64(m.dirsScanned, 0) - atomic.StoreInt64(m.bytesScanned, 0) - if m.currentPath != nil { - m.currentPath.Store("") - } - - if cached, ok := m.cache[m.path]; ok && !cached.Dirty { - m.entries = slices.Clone(cached.Entries) - m.largeFiles = slices.Clone(cached.LargeFiles) - m.totalSize = cached.TotalSize - m.totalFiles = cached.TotalFiles - m.selected = cached.Selected - m.offset = cached.EntryOffset - m.largeSelected = cached.LargeSelected - m.largeOffset = cached.LargeOffset - m.clampEntrySelection() - m.clampLargeSelection() - m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path)) - m.scanning = false - return m, nil - } - m.lastTotalFiles = 0 - if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 { - m.lastTotalFiles = total - } - return m, tea.Batch(m.scanCmd(m.path), tickCmd()) - } - m.status = fmt.Sprintf("File: %s, %s", selected.Name, humanizeBytes(selected.Size)) - return m, nil -} - -func (m *model) clampEntrySelection() { - if len(m.entries) == 0 { - m.selected = 0 - m.offset = 0 - return - } - if m.selected >= len(m.entries) { - m.selected = len(m.entries) - 1 - } - if m.selected < 0 { - m.selected = 0 - } - viewport := calculateViewport(m.height, false) - maxOffset := max(len(m.entries)-viewport, 0) - if m.offset > maxOffset { - m.offset = maxOffset - } - if m.selected < m.offset { - m.offset = m.selected - } - if m.selected >= m.offset+viewport { - m.offset = m.selected - viewport + 1 - } -} + // Hidden space insights — paths that silently accumulate disk usage. + entries = append(entries, insightEntries...) -func (m *model) clampLargeSelection() { - if len(m.largeFiles) == 0 { - m.largeSelected = 0 - m.largeOffset = 0 - return - } - if m.largeSelected >= len(m.largeFiles) { - m.largeSelected = len(m.largeFiles) - 1 - } - if m.largeSelected < 0 { - m.largeSelected = 0 - } - viewport := calculateViewport(m.height, true) - maxOffset := max(len(m.largeFiles)-viewport, 0) - if m.largeOffset > maxOffset { - m.largeOffset = maxOffset - } - if m.largeSelected < m.largeOffset { - m.largeOffset = m.largeSelected - } - if m.largeSelected >= m.largeOffset+viewport { - m.largeOffset = m.largeSelected - viewport + 1 - } + return entries } func sumKnownEntrySizes(entries []dirEntry) int64 { @@ -1127,48 +183,25 @@ func hasPendingOverviewEntries(entries []dirEntry) bool { return false } -func (m *model) removePathFromView(path string) { - if path == "" { - return - } - - var removedSize int64 - for i, entry := range m.entries { - if entry.Path == path { - if entry.Size > 0 { - removedSize = entry.Size - } - m.entries = append(m.entries[:i], m.entries[i+1:]...) - break - } +func safeOpen(path string, reveal bool) error { + if err := validatePath(path); err != nil { + return err } - - for i := 0; i < len(m.largeFiles); i++ { - if m.largeFiles[i].Path == path { - m.largeFiles = append(m.largeFiles[:i], m.largeFiles[i+1:]...) - break - } + ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout) + defer cancel() + args := []string{path} + if reveal { + args = []string{"-R", path} } - - if removedSize > 0 { - if removedSize > m.totalSize { - m.totalSize = 0 - } else { - m.totalSize -= removedSize - } - m.clampEntrySelection() - } - m.clampLargeSelection() + return exec.CommandContext(ctx, "open", args...).Run() } -func scanOverviewPathCmd(path string, index int) tea.Cmd { - return func() tea.Msg { - size, err := measureOverviewSize(path) - return overviewSizeMsg{ - Path: path, - Index: index, - Size: size, - Err: err, - } +// 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/model.go b/Resources/mole/cmd/analyze/model.go new file mode 100644 index 0000000..72a1eba --- /dev/null +++ b/Resources/mole/cmd/analyze/model.go @@ -0,0 +1,392 @@ +//go:build darwin + +package main + +import ( + "context" + "sort" + "strings" + "sync/atomic" + "time" +) + +type dirEntry struct { + Name string + Path string + Size int64 + IsDir bool + LastAccess time.Time +} + +type fileEntry struct { + Name string + Path string + Size int64 +} + +type scanResult struct { + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + // dedupedHardlink is true when a hardlinked file in this subtree was + // counted as zero because another link was seen earlier in the same + // scan. Such a result is scan-order dependent and must not be written + // to the on-disk cache. In-memory only; never serialized to cacheEntry. + dedupedHardlink bool +} + +type cacheEntry struct { + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + ModTime time.Time + ScanTime time.Time + NeedsRefresh bool + // SchemaVersion guards against reusing cache written by an older binary + // with different sizing semantics. Entries not at cacheSchemaVersion are + // rejected on load. Old caches decode this as 0. + SchemaVersion int +} + +type historyEntry struct { + Path string + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + TotalFiles int64 + Selected int + EntryOffset int + LargeSelected int + LargeOffset int + NeedsRefresh bool + IsOverview bool +} + +type scanResultMsg struct { + path string + result scanResult + err error + stale bool +} + +type liveScanStartMsg struct { + id int64 + path string + entries []dirEntry + totalSize int64 + totalFiles int64 + largeFiles []fileEntry + scanningPaths []string + events <-chan liveScanEventMsg + cancel context.CancelFunc + err error +} + +type liveScanEventKind int + +const ( + liveScanChildProgress liveScanEventKind = iota + 1 + liveScanChildDone + liveScanComplete + liveScanFailed + liveScanCanceled +) + +type liveScanEventMsg struct { + id int64 + path string + kind liveScanEventKind + entry dirEntry + result scanResult + err error +} + +type liveSortMode int + +const ( + liveSortContinuous liveSortMode = iota + liveSortFreezeOnMove +) + +type liveCursorMode int + +const ( + liveCursorByIndex liveCursorMode = iota + liveCursorByPath +) + +type overviewSizeMsg struct { + Path string + Index int + Size int64 + Err error +} + +type tickMsg time.Time + +type deleteProgressMsg struct { + done bool + err error + count int64 + path string +} + +type model struct { + path string + history []historyEntry + entries []dirEntry + largeFiles []fileEntry + selected int + offset int + status string + totalSize int64 + scanning bool + spinner int + filesScanned *int64 + dirsScanned *int64 + bytesScanned *int64 + currentPath *atomic.Value + showLargeFiles bool + isOverview bool + deleteConfirm bool + deleteTarget *dirEntry + deleting bool + deleteCount *int64 + cache map[string]historyEntry + largeSelected int + largeOffset int + overviewSizeCache map[string]int64 + overviewScanning bool + overviewScanningSet map[string]bool // Track which paths are currently being scanned + width int // Terminal width + height int // Terminal height + multiSelected map[string]bool // Track multi-selected items by path (safer than index) + largeMultiSelected map[string]bool // Track multi-selected large files by path (safer than index) + 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 + // Top-files (T) view incremental filter. largeFilesAll is the full, + // size-ranked list; largeFiles is the view actually rendered and acted on, + // which equals largeFilesAll when no filter is set and the matching subset + // otherwise. largeFiltering is true only while the user is typing a query. + largeFilesAll []fileEntry + largeFilter string + largeFiltering bool + // Directory (drill-down) view incremental filter, mirroring the Top-files + // one. entriesAll is the full non-empty entry list; entries is the rendered, + // possibly filtered view. Disabled in overview mode. + entriesAll []dirEntry + entryFilter string + entryFiltering bool + liveScanID int64 + liveScanCancel context.CancelFunc + liveScanEvents <-chan liveScanEventMsg + liveScanningPaths map[string]bool + autoSortLiveEntries bool + liveSortMode liveSortMode + liveCursorMode liveCursorMode +} + +func (m model) inOverviewMode() bool { + return m.isOverview && m.path == "/" +} + +func (m *model) hydrateOverviewEntries() { + m.entries = createOverviewEntries() + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + for i := range m.entries { + if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok { + m.entries[i].Size = size + continue + } + if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil { + m.entries[i].Size = size + m.overviewSizeCache[m.entries[i].Path] = size + } + } + m.totalSize = sumKnownEntrySizes(m.entries) +} + +func (m *model) sortOverviewEntriesBySize() { + // Stable sort by size. + sort.SliceStable(m.entries, func(i, j int) bool { + return m.entries[i].Size > m.entries[j].Size + }) +} + +func (m *model) getScanProgress() (files, dirs, bytes int64) { + if m.filesScanned != nil { + files = atomic.LoadInt64(m.filesScanned) + } + if m.dirsScanned != nil { + dirs = atomic.LoadInt64(m.dirsScanned) + } + if m.bytesScanned != nil { + bytes = atomic.LoadInt64(m.bytesScanned) + } + return +} + +func (m *model) clampEntrySelection() { + if len(m.entries) == 0 { + m.selected = 0 + m.offset = 0 + return + } + if m.selected >= len(m.entries) { + m.selected = len(m.entries) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + viewport := calculateViewport(m.height, false) + maxOffset := max(len(m.entries)-viewport, 0) + if m.offset > maxOffset { + m.offset = maxOffset + } + if m.selected < m.offset { + m.offset = m.selected + } + if m.selected >= m.offset+viewport { + m.offset = m.selected - viewport + 1 + } +} + +func (m *model) clampLargeSelection() { + if len(m.largeFiles) == 0 { + m.largeSelected = 0 + m.largeOffset = 0 + return + } + if m.largeSelected >= len(m.largeFiles) { + m.largeSelected = len(m.largeFiles) - 1 + } + if m.largeSelected < 0 { + m.largeSelected = 0 + } + viewport := calculateViewport(m.height, true) + maxOffset := max(len(m.largeFiles)-viewport, 0) + if m.largeOffset > maxOffset { + m.largeOffset = maxOffset + } + if m.largeSelected < m.largeOffset { + m.largeOffset = m.largeSelected + } + if m.largeSelected >= m.largeOffset+viewport { + m.largeOffset = m.largeSelected - viewport + 1 + } +} + +func (m *model) removePathFromView(path string) { + if path == "" { + return + } + + var removedSize int64 + for _, entry := range m.entriesAll { + if entry.Path == path { + if entry.Size > 0 { + removedSize = entry.Size + } + break + } + } + + // Trim the backing lists once, then rebuild each view from them. Removing + // directly from both a backing list and its (possibly aliased) view would + // shift the shared array twice and corrupt it; rebuilding via the filters + // keeps the view, the query, and the selection consistent. + m.entriesAll = removeByPath(m.entriesAll, path, dirEntryPath) + m.largeFilesAll = removeByPath(m.largeFilesAll, path, fileEntryPath) + + if removedSize > 0 { + if removedSize > m.totalSize { + m.totalSize = 0 + } else { + m.totalSize -= removedSize + } + } + + m.applyEntryFilter() + m.applyLargeFilter() +} + +func fileEntryName(f fileEntry) string { return f.Name } +func fileEntryPath(f fileEntry) string { return f.Path } +func dirEntryName(e dirEntry) string { return e.Name } +func dirEntryPath(e dirEntry) string { return e.Path } + +// filterMatches reports whether an item with the given name and path matches a +// case-insensitive substring query. Single source of truth for both the +// Top-files and directory filters so their match semantics cannot drift. +func filterMatches(name, path, query string) bool { + needle := strings.ToLower(query) + return strings.Contains(strings.ToLower(name), needle) || + strings.Contains(strings.ToLower(displayPath(path)), needle) +} + +// filterByQuery returns the items matching query, or the original slice +// unchanged when the query is empty. nameOf/pathOf project the fields matched. +func filterByQuery[T any](all []T, query string, nameOf, pathOf func(T) string) []T { + if query == "" { + return all + } + out := make([]T, 0, len(all)) + for _, item := range all { + if filterMatches(nameOf(item), pathOf(item), query) { + out = append(out, item) + } + } + return out +} + +// removeByPath drops the first item whose projected path equals path. +func removeByPath[T any](items []T, path string, pathOf func(T) string) []T { + for i := range items { + if pathOf(items[i]) == path { + return append(items[:i], items[i+1:]...) + } + } + return items +} + +// applyLargeFilter rebuilds the rendered Top-files view from largeFilesAll +// using the current query. An empty query restores the full list. +func (m *model) applyLargeFilter() { + m.largeFiles = filterByQuery(m.largeFilesAll, m.largeFilter, fileEntryName, fileEntryPath) + m.clampLargeSelection() +} + +// resetLargeFilter clears any active Top-files filter and restores the full +// list. Callers that leave the Top-files view use this so the next visit and +// the per-path navigation state start clean. +func (m *model) resetLargeFilter() { + m.largeFilter = "" + m.largeFiltering = false + if m.largeFilesAll != nil { + m.largeFiles = m.largeFilesAll + } +} + +// applyEntryFilter rebuilds the rendered directory view from entriesAll using +// the current query. The directory view is the drill-down list (m.entries) in +// non-overview mode. +func (m *model) applyEntryFilter() { + m.entries = filterByQuery(m.entriesAll, m.entryFilter, dirEntryName, dirEntryPath) + m.clampEntrySelection() +} + +// resetEntryFilter clears any active directory filter and restores the full +// entry list. +func (m *model) resetEntryFilter() { + m.entryFilter = "" + m.entryFiltering = false + if m.entriesAll != nil { + m.entries = m.entriesAll + } +} diff --git a/Resources/mole/cmd/analyze/scanner.go b/Resources/mole/cmd/analyze/scanner.go index f22387b..736d0f5 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,17 +12,86 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" + "sort" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" - - "golang.org/x/sync/singleflight" ) -var scanGroup singleflight.Group +// scanLimiter bundles the concurrency budgets used by a single scan pass. +// +// There are five separate semaphores on purpose: each protects a different +// scarce resource. Collapsing two of them changes scaling behavior in ways +// that are easy to get wrong; see the per-field notes before adjusting. +type scanLimiter struct { + // entrySem caps the number of in-flight top-level entry workers (one per + // child of the root being scanned). Acquired with tryAcquireEntry so the + // caller can fall back to inline scanning when the budget is saturated. + entrySem chan struct{} + + // dirSem caps the number of concurrent recursive directory walkers + // inside calculateDirSizeConcurrent. Independent of entrySem because a + // single entry can fan out into many directory walkers. + dirSem chan struct{} + + // duSem caps how many `du` subprocesses execute concurrently. Tuned + // low (NumCPU capped at 4) because each du process is itself heavily + // I/O parallel and saturating the disk hurts wall-clock latency. + duSem chan struct{} + + // duQueueSem caps how many goroutines are *queued* to run du. + // Distinct from duSem so we don't spawn one goroutine per pending + // directory and grow memory linearly with the input set; without + // this bound, large home dirs allocate thousands of stacks waiting + // on duSem. Sized at 2x duSem to keep the worker side warm without + // unbounded queueing. + duQueueSem chan struct{} + + // fastSem caps the workers used by the fallback fast-sizing path + // when du is unavailable or rejected. Same scale as entrySem because + // the fast path replaces a single du subprocess with one walker. + fastSem chan struct{} + + // seen tracks (dev, ino) of hardlinked files counted so far in this + // scan so a file with multiple links is counted once, matching `du`. + seen sync.Map +} + +func newScanLimiter(childCount int) *scanLimiter { + if childCount <= 0 { + childCount = maxWorkers + } + numWorkers := max(min(max(runtime.NumCPU()*cpuMultiplier, minWorkers), maxWorkers, childCount), 1) + return &scanLimiter{ + entrySem: make(chan struct{}, numWorkers), + dirSem: make(chan struct{}, min(runtime.NumCPU()*2, maxDirWorkers)), + duSem: make(chan struct{}, min(4, runtime.NumCPU())), + duQueueSem: make(chan struct{}, min(4, runtime.NumCPU())*2), + fastSem: make(chan struct{}, min(runtime.NumCPU()*cpuMultiplier, maxWorkers)), + } +} + +func (l *scanLimiter) tryAcquireEntry() bool { + if l == nil || l.entrySem == nil { + return false + } + select { + case l.entrySem <- struct{}{}: + return true + default: + return false + } +} + +func (l *scanLimiter) releaseEntry() { + if l != nil && l.entrySem != nil { + <-l.entrySem + } +} // trySend attempts to send an item to a channel with a timeout. // Returns true if the item was sent, false if the timeout was reached. @@ -59,29 +130,48 @@ 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) { + return scanPathConcurrentWithLimiter(root, filesScanned, dirsScanned, bytesScanned, currentPath, useSpotlight, entryLimit, nil) +} + +func scanPathConcurrentWithLimiter(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value, useSpotlight bool, entryLimit int, limiter *scanLimiter) (scanResult, error) { children, err := os.ReadDir(root) if err != nil { return scanResult{}, err } + if limiter == nil { + limiter = newScanLimiter(len(children)) + } var total int64 var localFilesScanned int64 var localBytesScanned int64 + var subtreeFilesScanned atomic.Int64 + var dedupedHardlink atomic.Bool + + 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) largeFileMinSize := int64(largeFileWarmupMinSize) - // Worker pool sized for I/O-bound scanning. - numWorkers := max(min(max(runtime.NumCPU()*cpuMultiplier, minWorkers), maxWorkers, len(children)), 1) - sem := make(chan struct{}, numWorkers) - dirSem := make(chan struct{}, min(runtime.NumCPU()*2, maxDirWorkers)) - duSem := make(chan struct{}, min(4, runtime.NumCPU())) // limits concurrent du processes - duQueueSem := make(chan struct{}, min(4, runtime.NumCPU())*2) // limits how many goroutines may be waiting to run du + dirSem := limiter.dirSem + duSem := limiter.duSem + duQueueSem := limiter.duQueueSem var wg sync.WaitGroup // Collect results via channels. @@ -91,20 +181,22 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in largeFileChan := make(chan fileEntry, maxLargeFiles*2) var collectorWg sync.WaitGroup - collectorWg.Add(2) - go func() { - defer collectorWg.Done() + collectorWg.Go(func() { 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) heap.Push(entriesHeap, entry) } } - }() - go func() { - defer collectorWg.Done() + }) + collectorWg.Go(func() { for file := range largeFileChan { if largeFilesHeap.Len() < maxLargeFiles { heap.Push(largeFilesHeap, file) @@ -117,7 +209,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in atomic.StoreInt64(&largeFileMinSize, (*largeFilesHeap)[0].Size) } } - }() + }) isRootDir := root == "/" home := os.Getenv("HOME") @@ -148,7 +240,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in Size: size, IsDir: isDir, LastAccess: getLastAccessTimeFromInfo(info), - }, 100*time.Millisecond) + }, scanSendTimeout) continue } @@ -165,82 +257,96 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in // ~/Library is scanned separately; reuse cache when possible. if isHomeDir && child.Name() == "Library" { - sem <- struct{}{} - wg.Add(1) - go func(name, path string) { - defer wg.Done() - defer func() { <-sem }() - - var size int64 + processDir := func(name, path string) { + 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, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + } + atomic.AddInt64(&total, result.TotalSize) + if result.TotalFiles > 0 { + subtreeFilesScanned.Add(result.TotalFiles) + } + if result.dedupedHardlink { + dedupedHardlink.Store(true) } - 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) - }(child.Name(), fullPath) + }, scanSendTimeout) + } + if limiter.tryAcquireEntry() { + wg.Go(func() { + defer limiter.releaseEntry() + processDir(child.Name(), fullPath) + }) + } else { + processDir(child.Name(), fullPath) + } continue } // Folded dirs: fast size without expanding. if shouldFoldDirWithPath(child.Name(), fullPath) { duQueueSem <- struct{}{} - wg.Add(1) - go func(name, path string) { - defer wg.Done() + wg.Go(func() { defer func() { <-duQueueSem }() size, err := func() (int64, error) { duSem <- struct{}{} defer func() { <-duSem }() - return getDirectorySizeFromDu(path) + return getDirectorySizeFromDu(fullPath) }() if err != nil || size <= 0 { - size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeFastWithLimiter(fullPath, limiter, filesScanned, dirsScanned, bytesScanned, currentPath) } atomic.AddInt64(&total, size) atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ - Name: name, - Path: path, + Name: child.Name(), + Path: fullPath, Size: size, IsDir: true, LastAccess: time.Time{}, - }, 100*time.Millisecond) - }(child.Name(), fullPath) + }, scanSendTimeout) + }) continue } - sem <- struct{}{} - wg.Add(1) - go func(name, path string) { - defer wg.Done() - defer func() { <-sem }() - - size := calculateDirSizeConcurrent(path, largeFileChan, &largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) + processDir := func(name, path string) { + result := scanSubdirWithCache(path, largeFileChan, &largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + atomic.AddInt64(&total, result.TotalSize) + if result.TotalFiles > 0 { + subtreeFilesScanned.Add(result.TotalFiles) + } + if result.dedupedHardlink { + dedupedHardlink.Store(true) + } atomic.AddInt64(dirsScanned, 1) trySend(entryChan, dirEntry{ Name: name, Path: path, - Size: size, + Size: result.TotalSize, IsDir: true, LastAccess: time.Time{}, - }, 100*time.Millisecond) - }(child.Name(), fullPath) + }, scanSendTimeout) + } + if limiter.tryAcquireEntry() { + wg.Go(func() { + defer limiter.releaseEntry() + processDir(child.Name(), fullPath) + }) + } else { + processDir(child.Name(), fullPath) + } continue } @@ -248,8 +354,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in if err != nil { continue } - // Actual disk usage for sparse/cloud files. - size := getActualFileSize(fullPath, info) + // Actual disk usage for sparse/cloud files, deduping hardlinks. + size, deduped := countableFileSize(info, &limiter.seen) + if deduped { + dedupedHardlink.Store(true) + } atomic.AddInt64(&total, size) localFilesScanned++ localBytesScanned += size @@ -260,13 +369,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in Size: size, IsDir: false, LastAccess: getLastAccessTimeFromInfo(info), - }, 100*time.Millisecond) + }, scanSendTimeout) // Track large files only. if !shouldSkipFileForLargeTracking(fullPath) { minSize := atomic.LoadInt64(&largeFileMinSize) if size >= minSize { - trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond) + trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, scanSendTimeout) } } } @@ -286,29 +395,87 @@ 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 := range slices.Backward(entries) { + entries[i] = heap.Pop(entriesHeap).(dirEntry) + } } largeFiles := make([]fileEntry, largeFilesHeap.Len()) - for i := len(largeFiles) - 1; i >= 0; i-- { + for i := range slices.Backward(largeFiles) { largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry) } // 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), + Entries: entries, + LargeFiles: largeFiles, + TotalSize: total, + TotalFiles: localFilesScanned + subtreeFilesScanned.Load(), + dedupedHardlink: dedupedHardlink.Load(), }, nil } +func publishLargeFiles(files []fileEntry, largeFileChan chan<- fileEntry) { + for _, file := range files { + trySend(largeFileChan, file, scanSendTimeout) + } +} + +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, limiter *scanLimiter, 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 := scanPathConcurrentWithLimiter(root, filesScanned, dirsScanned, bytesScanned, currentPath, false, maxEntries, limiter) + if err == nil { + publishLargeFiles(result.LargeFiles, largeFileChan) + // A subtree whose size depended on hardlink dedup is scan-order + // dependent; caching it would poison standalone re-scans. + if !result.dedupedHardlink { + _ = saveCacheToDiskWithOptions(root, result, true) + } + return result + } + + return scanResult{TotalSize: calculateDirSizeConcurrent(root, largeFileChan, largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath)} +} + func shouldFoldDirWithPath(name, path string) bool { if foldDirs[name] { return true @@ -335,14 +502,21 @@ func shouldSkipFileForLargeTracking(path string) bool { // calculateDirSizeFast performs concurrent dir sizing using os.ReadDir. func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { - var total int64 + return calculateDirSizeFastWithLimiter(root, newScanLimiter(0), filesScanned, dirsScanned, bytesScanned, currentPath) +} + +func calculateDirSizeFastWithLimiter(root string, limiter *scanLimiter, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { + var total atomic.Int64 var wg sync.WaitGroup 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) + if limiter != nil && limiter.fastSem != nil { + sem = limiter.fastSem + } var walk func(string) walk = func(dirPath string) { @@ -370,12 +544,10 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * select { case sem <- struct{}{}: - wg.Add(1) - go func(p string) { - defer wg.Done() + wg.Go(func() { defer func() { <-sem }() - walk(p) - }(subDir) + walk(subDir) + }) default: // Fallback to synchronous traversal to avoid semaphore deadlock under high fan-out. walk(subDir) @@ -391,7 +563,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * } if localBytes > 0 { - atomic.AddInt64(&total, localBytes) + total.Add(localBytes) atomic.AddInt64(bytesScanned, localBytes) } if localFiles > 0 { @@ -402,11 +574,21 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned * walk(root) wg.Wait() - return total + return total.Load() } // 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) @@ -462,7 +644,7 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry { } files := make([]fileEntry, h.Len()) - for i := len(files) - 1; i >= 0; i-- { + for i := range slices.Backward(files) { files[i] = heap.Pop(h).(fileEntry) } @@ -480,13 +662,14 @@ func isInFoldedDir(path string) bool { return false } -func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { +func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, largeFileMinSize *int64, limiter *scanLimiter, dirSem, duSem, duQueueSem chan struct{}, filesScanned, dirsScanned, bytesScanned *int64, currentPath *atomic.Value) int64 { children, err := os.ReadDir(root) if err != nil { return 0 } - var total int64 + var total atomic.Int64 + var localTotal int64 var localFilesScanned int64 var localDirsScanned int64 var localBytesScanned int64 @@ -501,7 +684,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar continue } size := getActualFileSize(fullPath, info) - total += size + localTotal += size localFilesScanned++ localBytesScanned += size continue @@ -512,39 +695,35 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar if shouldFoldDirWithPath(child.Name(), fullPath) { duQueueSem <- struct{}{} - wg.Add(1) - go func(path string) { - defer wg.Done() + wg.Go(func() { defer func() { <-duQueueSem }() size, err := func() (int64, error) { duSem <- struct{}{} defer func() { <-duSem }() - return getDirectorySizeFromDu(path) + return getDirectorySizeFromDu(fullPath) }() if err != nil || size <= 0 { - size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath) + size = calculateDirSizeFastWithLimiter(fullPath, limiter, filesScanned, dirsScanned, bytesScanned, currentPath) } else { atomic.AddInt64(bytesScanned, size) } - atomic.AddInt64(&total, size) - }(fullPath) + total.Add(size) + }) continue } select { case dirSem <- struct{}{}: - wg.Add(1) - go func(path string) { - defer wg.Done() + wg.Go(func() { defer func() { <-dirSem }() - size := calculateDirSizeConcurrent(path, largeFileChan, largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) - }(fullPath) + size := calculateDirSizeConcurrent(fullPath, largeFileChan, largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + total.Add(size) + }) default: - size := calculateDirSizeConcurrent(fullPath, largeFileChan, largeFileMinSize, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) - atomic.AddInt64(&total, size) + size := calculateDirSizeConcurrent(fullPath, largeFileChan, largeFileMinSize, limiter, dirSem, duSem, duQueueSem, filesScanned, dirsScanned, bytesScanned, currentPath) + localTotal += size } continue } @@ -555,14 +734,14 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar } size := getActualFileSize(fullPath, info) - total += size + localTotal += size localFilesScanned++ localBytesScanned += size if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil { minSize := atomic.LoadInt64(largeFileMinSize) if size >= minSize { - trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, 100*time.Millisecond) + trySend(largeFileChan, fileEntry{Name: child.Name(), Path: fullPath, Size: size}, scanSendTimeout) } } @@ -572,6 +751,10 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar } } + if localTotal > 0 { + total.Add(localTotal) + } + wg.Wait() if localFilesScanned > 0 { @@ -584,7 +767,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar atomic.AddInt64(dirsScanned, localDirsScanned) } - return total + return total.Load() } // measureOverviewSize calculates the size of a directory using multiple strategies. @@ -610,12 +793,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 := getDirectorySizeFromDuWithExcludeAndIgnores(path, excludePath, overviewIgnoreNamesForPath(path)); 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 +816,25 @@ func getDirectorySizeFromDu(path string) (int64, error) { } func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) { + return getDirectorySizeFromDuWithExcludeAndIgnores(path, excludePath, nil) +} + +func getDirectorySizeFromDuWithExcludeAndIgnores(path string, excludePath string, ignoreNames []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 + } + } + for _, ignoreName := range ignoreNames { + if err := validateDuIgnoreName(ignoreName); err != nil { + return 0, err + } + } + runDuSize := func(target string) (int64, error) { if _, err := os.Stat(target); err != nil { return 0, err @@ -641,29 +843,43 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, ctx, cancel := context.WithTimeout(context.Background(), duTimeout) defer cancel() - cmd := exec.CommandContext(ctx, "du", "-skP", target) + args := []string{"-skPx"} + for _, ignoreName := range ignoreNames { + args = append(args, "-I", ignoreName) + } + args = append(args, target) + cmd := exec.CommandContext(ctx, "du", args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { + runErr := cmd.Run() + fields := strings.Fields(stdout.String()) + if runErr != nil { if ctx.Err() == context.DeadlineExceeded { return 0, fmt.Errorf("du timeout after %v", duTimeout) } - if stderr.Len() > 0 { - return 0, fmt.Errorf("du failed: %v, %s", err, stderr.String()) + // BSD du may return non-zero for unreadable descendants while still + // printing a useful aggregate for the requested root. Use that best + // effort total instead of falling back to a much slower recursive walk. + if len(fields) == 0 { + if stderr.Len() > 0 { + return 0, fmt.Errorf("du failed: %v, %s", runErr, stderr.String()) + } + return 0, fmt.Errorf("du failed: %v", runErr) } - return 0, fmt.Errorf("du failed: %v", err) } - fields := strings.Fields(stdout.String()) if len(fields) == 0 { return 0, fmt.Errorf("du output empty") } - kb, err := strconv.ParseInt(fields[0], 10, 64) - if err != nil { - return 0, fmt.Errorf("failed to parse du output: %v", err) + kb, parseErr := strconv.ParseInt(fields[0], 10, 64) + if parseErr != nil { + return 0, fmt.Errorf("failed to parse du output: %v", parseErr) } if kb <= 0 { + if runErr != nil { + return 0, fmt.Errorf("du failed: %v", runErr) + } return 0, fmt.Errorf("du size invalid: %d", kb) } return kb * 1024, nil @@ -671,6 +887,10 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, // When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library" if excludePath != "" { + if size, err := getDirectorySizeFromDuSkippingImmediateChild(path, excludePath, runDuSize); err == nil { + return size, nil + } + totalSize, err := runDuSize(path) if err != nil { return 0, err @@ -691,6 +911,113 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, return runDuSize(path) } +func validateDuIgnoreName(name string) error { + if name == "" { + return fmt.Errorf("empty du ignore name") + } + if strings.Contains(name, "\x00") { + return fmt.Errorf("du ignore name contains null bytes") + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("du ignore name must be a basename: %s", name) + } + return nil +} + +func overviewIgnoreNamesForPath(path string) []string { + entries, err := os.ReadDir(path) + if err != nil { + return nil + } + + ignoreNames := make([]string, 0, len(overviewDuIgnoreNames)) + for _, entry := range entries { + name := entry.Name() + if overviewDuIgnoreNames[name] && entry.IsDir() { + ignoreNames = append(ignoreNames, name) + } + } + return ignoreNames +} + +func getDirectorySizeFromDuSkippingImmediateChild(path string, excludePath string, runDuSize func(string) (int64, error)) (int64, error) { + path = filepath.Clean(path) + excludePath = filepath.Clean(excludePath) + + rel, err := filepath.Rel(path, excludePath) + if err != nil { + return 0, err + } + if rel == "." || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return 0, fmt.Errorf("exclude path is outside base: %s", excludePath) + } + if strings.Contains(rel, string(os.PathSeparator)) { + return 0, fmt.Errorf("exclude path is not an immediate child: %s", excludePath) + } + + entries, err := os.ReadDir(path) + if err != nil { + return 0, err + } + + var total int64 + if info, err := os.Lstat(path); err == nil { + atomic.AddInt64(&total, getActualFileSize(path, info)) + } + + var wg sync.WaitGroup + var firstErr error + var errMu sync.Mutex + workerCount := min(max(runtime.NumCPU()*2, 2), 8) + sem := make(chan struct{}, workerCount) + + recordErr := func(err error) { + if err == nil { + return + } + errMu.Lock() + defer errMu.Unlock() + if firstErr == nil { + firstErr = err + } + } + + for _, entry := range entries { + fullPath := filepath.Join(path, entry.Name()) + if filepath.Clean(fullPath) == excludePath { + continue + } + + if entry.Type()&fs.ModeSymlink != 0 || !entry.IsDir() { + info, err := entry.Info() + if err != nil { + continue + } + atomic.AddInt64(&total, getActualFileSize(fullPath, info)) + continue + } + + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + + size, err := runDuSize(fullPath) + if err != nil { + recordErr(err) + return + } + atomic.AddInt64(&total, size) + }) + } + + wg.Wait() + + if firstErr != nil { + return 0, firstErr + } + return total, nil +} + func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) { var total int64 err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { @@ -720,6 +1047,27 @@ func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, return total, nil } +// countableFileSize returns the on-disk size to attribute to a regular file. +// Hardlinked files are deduplicated the way `du` does: the first link counts +// its full size and subsequent links seen in the same scan count zero. The +// bool reports whether this call was a deduplicated (zero-counted) hardlink. +// A nil seen map disables deduplication. +func countableFileSize(info fs.FileInfo, seen *sync.Map) (int64, bool) { + size := getActualFileSize("", info) + if seen == nil { + return size, false + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Nlink <= 1 { + return size, false + } + key := [2]uint64{uint64(uint32(stat.Dev)), stat.Ino} + if _, loaded := seen.LoadOrStore(key, struct{}{}); loaded { + return 0, true + } + return size, false +} + func getActualFileSize(_ string, info fs.FileInfo) int64 { stat, ok := info.Sys().(*syscall.Stat_t) if !ok { diff --git a/Resources/mole/cmd/analyze/scanner_test.go b/Resources/mole/cmd/analyze/scanner_test.go index 718d276..8ebe372 100644 --- a/Resources/mole/cmd/analyze/scanner_test.go +++ b/Resources/mole/cmd/analyze/scanner_test.go @@ -1,12 +1,15 @@ +//go:build darwin + package main import ( + "fmt" "os" "path/filepath" "testing" ) -func writeFileWithSize(t *testing.T, path string, size int) { +func writeFileWithSize(t testing.TB, path string, size int) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", path, err) @@ -43,3 +46,91 @@ func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) { t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding) } } + +func TestGetDirectorySizeFromDuSkippingImmediateChildDoesNotMeasureExcludedPath(t *testing.T) { + base := t.TempDir() + excluded := filepath.Join(base, "Library") + included := filepath.Join(base, "Documents") + if err := os.MkdirAll(excluded, 0o755); err != nil { + t.Fatalf("mkdir excluded: %v", err) + } + if err := os.MkdirAll(included, 0o755); err != nil { + t.Fatalf("mkdir included: %v", err) + } + + var measured []string + size, err := getDirectorySizeFromDuSkippingImmediateChild(base, excluded, func(path string) (int64, error) { + measured = append(measured, path) + return 100, nil + }) + if err != nil { + t.Fatalf("getDirectorySizeFromDuSkippingImmediateChild: %v", err) + } + if size < 100 { + t.Fatalf("expected included directory size in total, got %d", size) + } + if len(measured) != 1 || measured[0] != included { + t.Fatalf("expected to measure only %s, measured %#v", included, measured) + } +} + +func TestGetDirectorySizeFromDuWithIgnoresSkipsCloudPlaceholderTree(t *testing.T) { + base := t.TempDir() + writeFileWithSize(t, filepath.Join(base, "Application Support", "state.dat"), 4096) + writeFileWithSize(t, filepath.Join(base, "Mobile Documents", "cloud.dat"), 1024*1024) + + withoutIgnore, err := getDirectorySizeFromDuWithExcludeAndIgnores(base, "", nil) + if err != nil { + t.Fatalf("getDirectorySizeFromDuWithExcludeAndIgnores without ignore: %v", err) + } + withIgnore, err := getDirectorySizeFromDuWithExcludeAndIgnores(base, "", []string{"Mobile Documents"}) + if err != nil { + t.Fatalf("getDirectorySizeFromDuWithExcludeAndIgnores with ignore: %v", err) + } + if withIgnore >= withoutIgnore { + t.Fatalf("expected ignored Mobile Documents to reduce size, got ignored=%d without=%d", withIgnore, withoutIgnore) + } + if withIgnore <= 0 { + t.Fatalf("expected non-zero size for included files, got %d", withIgnore) + } +} + +func TestValidateDuIgnoreNameRejectsPathPatterns(t *testing.T) { + for _, name := range []string{"", "../Library", "Library/Developer", "bad\x00name"} { + if err := validateDuIgnoreName(name); err == nil { + t.Fatalf("expected %q to be rejected", name) + } + } + if err := validateDuIgnoreName("Mobile Documents"); err != nil { + t.Fatalf("expected basename ignore to be accepted: %v", err) + } +} + +func BenchmarkGetDirectorySizeFromDuWithExcludeHomeLibrary(b *testing.B) { + base := b.TempDir() + libraryDir := filepath.Join(base, "Library") + for dirIdx := range 250 { + for fileIdx := range 20 { + writeFileWithSize( + b, + filepath.Join(libraryDir, "bulk", fmt.Sprintf("dir-%03d", dirIdx), "bucket", fmt.Sprintf("file-%03d.dat", fileIdx)), + 16, + ) + } + } + writeFileWithSize(b, filepath.Join(base, "Documents", "keep.dat"), 4096) + + excludePath := filepath.Join(base, "Library") + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + size, err := getDirectorySizeFromDuWithExclude(base, excludePath) + if err != nil { + b.Fatalf("getDirectorySizeFromDuWithExclude: %v", err) + } + if size <= 0 { + b.Fatalf("expected non-zero size, got %d", size) + } + } +} 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/update.go b/Resources/mole/cmd/analyze/update.go new file mode 100644 index 0000000..75fe281 --- /dev/null +++ b/Resources/mole/cmd/analyze/update.go @@ -0,0 +1,1207 @@ +//go:build darwin + +package main + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m *model) scheduleOverviewScans() tea.Cmd { + if !m.inOverviewMode() { + return nil + } + + var pendingIndices []int + for i, entry := range m.entries { + if entry.Size < 0 && !m.overviewScanningSet[entry.Path] { + pendingIndices = append(pendingIndices, i) + if len(pendingIndices) >= maxConcurrentOverview { + break + } + } + } + + if len(pendingIndices) == 0 { + m.overviewScanning = false + if !hasPendingOverviewEntries(m.entries) { + m.sortOverviewEntriesBySize() + m.status = "Ready" + } + return nil + } + + var cmds []tea.Cmd + for _, idx := range pendingIndices { + entry := m.entries[idx] + m.overviewScanningSet[entry.Path] = true + cmd := scanOverviewPathCmd(entry.Path, idx) + cmds = append(cmds, cmd) + } + + m.overviewScanning = true + remaining := 0 + for _, e := range m.entries { + if e.Size < 0 { + remaining++ + } + } + if len(pendingIndices) > 0 { + firstEntry := m.entries[pendingIndices[0]] + if len(pendingIndices) == 1 { + m.status = fmt.Sprintf("Scanning %s..., %d left", firstEntry.Name, remaining) + } else { + m.status = fmt.Sprintf("Scanning %d directories..., %d left", len(pendingIndices), remaining) + } + } + + cmds = append(cmds, tickCmd()) + return tea.Batch(cmds...) +} + +func (m model) Init() tea.Cmd { + if m.inOverviewMode() { + return m.scheduleOverviewScans() + } + return tea.Batch(m.scanCmd(m.path), tickCmd()) +} + +func (m model) scanCmd(path string) tea.Cmd { + return func() tea.Msg { + if cached, err := loadCacheFromDisk(path); err == nil { + result := scanResult{ + Entries: cached.Entries, + LargeFiles: cached.LargeFiles, + 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} + } + + if stale, err := loadStaleCacheFromDisk(path); err == nil { + result := scanResult{ + Entries: stale.Entries, + LargeFiles: stale.LargeFiles, + TotalSize: stale.TotalSize, + TotalFiles: stale.TotalFiles, + } + return scanResultMsg{path: path, result: result, err: nil, stale: true} + } + + return startLiveScanCmd(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)() + } +} + +func (m model) scanFreshCmd(path string) tea.Cmd { + return func() tea.Msg { + return startLiveScanCmd(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)() + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(uiTickInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m *model) cancelLiveScan() { + if m.liveScanCancel != nil { + m.liveScanCancel() + } + m.liveScanID = 0 + m.liveScanCancel = nil + m.liveScanEvents = nil + m.liveScanningPaths = nil + m.autoSortLiveEntries = false +} + +func (m model) isCurrentLiveScan(id int64, path string) bool { + return id != 0 && id == m.liveScanID && path == m.path && m.liveScanEvents != nil +} + +func (m *model) noteLiveCursorMove() { + if m.scanning && !m.showLargeFiles && m.liveSortMode == liveSortFreezeOnMove { + m.autoSortLiveEntries = false + } +} + +func (m *model) applyLiveChildProgress(entry dirEntry) { + m.applyLiveChildSize(entry, false, scanResult{}) +} + +func (m *model) applyLiveChildUpdate(entry dirEntry, result scanResult) { + m.applyLiveChildSize(entry, true, result) +} + +func (m *model) ensureLiveEntryBacking() { + if m.entriesAll == nil { + m.entriesAll = slices.Clone(m.entries) + } +} + +func (m *model) applyLiveChildSize(entry dirEntry, complete bool, result scanResult) { + if complete && m.liveScanningPaths != nil { + delete(m.liveScanningPaths, entry.Path) + } + + m.ensureLiveEntryBacking() + previousSize := int64(-1) + found := false + for i := range m.entriesAll { + if m.entriesAll[i].Path == entry.Path { + previousSize = m.entriesAll[i].Size + m.entriesAll[i] = entry + found = true + break + } + } + if !found { + m.entriesAll = append(m.entriesAll, entry) + } + + if entry.Size > 0 { + if previousSize > 0 { + m.totalSize += entry.Size - previousSize + } else { + m.totalSize += entry.Size + } + } + if complete && result.TotalFiles > 0 { + m.totalFiles += result.TotalFiles + } + if complete && (len(result.Entries) > 0 || len(result.LargeFiles) > 0 || result.TotalSize > 0 || result.TotalFiles > 0) { + childResult := result + childResult.Entries = filterNonEmptyEntries(result.Entries) + m.cache[entry.Path] = historyEntryFromScanResult(entry.Path, childResult, m.cache[entry.Path], true) + } + if m.autoSortLiveEntries { + m.sortLiveEntriesForCurrentCursorMode() + } else { + m.applyEntryFilter() + } + m.clampEntrySelection() + m.status = fmt.Sprintf("Scanning %s...", displayPath(m.path)) +} + +func (m *model) finishLiveScan(result scanResult) { + m.scanning = false + m.liveScanID = 0 + m.liveScanCancel = nil + m.liveScanEvents = nil + m.liveScanningPaths = nil + m.autoSortLiveEntries = false + + filteredEntries := filterNonEmptyEntries(result.Entries) + result.Entries = filteredEntries + selectedPath := m.selectedEntryPath() + m.entriesAll = filteredEntries + m.largeFilesAll = result.LargeFiles + m.totalSize = result.TotalSize + m.totalFiles = result.TotalFiles + m.viewNeedsRefresh = false + m.applyEntryFilter() + m.applyLargeFilter() + if selectedPath != "" { + m.selectEntryPath(selectedPath) + } + m.cache[m.path] = historyEntryFromScanResult(m.path, result, m.cache[m.path], false) + if m.totalSize > 0 { + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + m.overviewSizeCache[m.path] = m.totalSize + go func(path string, size int64) { + _ = storeOverviewSize(path, size) + }(m.path, m.totalSize) + } + go func(path string, scan scanResult) { + _ = saveCacheToDisk(path, scan) + }(m.path, result) + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) +} + +func (m *model) finishCanceledLiveScan() { + m.scanning = false + m.liveScanID = 0 + m.liveScanCancel = nil + m.liveScanEvents = nil + m.liveScanningPaths = nil + m.autoSortLiveEntries = false + m.status = "Scan cancelled" +} + +func (m *model) sortLiveEntriesForCurrentCursorMode() { + m.ensureLiveEntryBacking() + selectedPath := "" + if m.liveCursorMode == liveCursorByPath { + selectedPath = m.selectedEntryPath() + } + + if m.liveCursorMode == liveCursorByIndex { + sortDirEntriesBySize(m.entriesAll) + m.applyEntryFilter() + m.clampEntrySelection() + return + } + sortDirEntriesBySize(m.entriesAll) + m.applyEntryFilter() + if selectedPath == "" { + return + } + m.selectEntryPath(selectedPath) +} + +func (m model) selectedEntryPath() string { + if len(m.entries) == 0 || m.selected < 0 || m.selected >= len(m.entries) { + return "" + } + return m.entries[m.selected].Path +} + +func (m *model) selectEntryPath(path string) { + for i, entry := range m.entries { + if entry.Path == path { + m.selected = i + return + } + } + m.clampEntrySelection() +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.updateKey(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case deleteProgressMsg: + if msg.done { + m.deleting = false + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) + if msg.err != nil { + m.status = fmt.Sprintf("Failed to delete: %v", msg.err) + } else { + if msg.path != "" { + m.removePathFromView(msg.path) + invalidateCache(msg.path) + } + invalidateCache(m.path) + m.status = fmt.Sprintf("Deleted %d items", msg.count) + + // Selective invalidation: only mark current path and ancestors as needing refresh + currentPath := m.path + for currentPath != "/" && currentPath != "" { + if entry, exists := m.cache[currentPath]; exists { + entry.NeedsRefresh = true + m.cache[currentPath] = entry + } + currentPath = filepath.Dir(currentPath) + } + + // Mark history entries for current path and ancestors as needing refresh + for i := range m.history { + histPath := m.history[i].Path + if histPath == m.path || strings.HasPrefix(m.path, histPath+"/") { + m.history[i].NeedsRefresh = true + } + } + + m.cancelLiveScan() + m.scanning = true + 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.scanCmd(m.path), tickCmd()) + } + } + 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 + if msg.err != nil { + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, nil + } + filteredEntries := filterNonEmptyEntries(msg.result.Entries) + result := msg.result + result.Entries = filteredEntries + m.entriesAll = filteredEntries + m.entries = filteredEntries + m.largeFilesAll = msg.result.LargeFiles + m.largeFiles = msg.result.LargeFiles + m.totalSize = msg.result.TotalSize + m.totalFiles = msg.result.TotalFiles + m.viewNeedsRefresh = msg.stale + // Re-narrow to the active query if a background refresh landed while a + // filter is showing; each is a no-op (restores the full list) when its + // query is empty. + m.applyEntryFilter() + m.applyLargeFilter() + 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) + } + m.overviewSizeCache[m.path] = m.totalSize + go func(path string, size int64) { + _ = storeOverviewSize(path, size) + }(m.path, m.totalSize) + } + + if msg.stale { + 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)) + return m, nil + case liveScanStartMsg: + if msg.path != m.path { + if msg.cancel != nil { + msg.cancel() + } + return m, nil + } + m.cancelLiveScan() + if msg.err != nil { + m.scanning = false + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, nil + } + m.liveScanID = msg.id + m.liveScanCancel = msg.cancel + m.liveScanEvents = msg.events + m.liveScanningPaths = make(map[string]bool, len(msg.scanningPaths)) + for _, path := range msg.scanningPaths { + m.liveScanningPaths[path] = true + } + m.autoSortLiveEntries = true + selectedPath := m.selectedEntryPath() + m.entriesAll = slices.Clone(msg.entries) + m.largeFilesAll = slices.Clone(msg.largeFiles) + m.totalSize = msg.totalSize + m.totalFiles = msg.totalFiles + m.viewNeedsRefresh = false + m.scanning = true + m.status = fmt.Sprintf("Scanning %s...", displayPath(m.path)) + m.sortLiveEntriesForCurrentCursorMode() + m.applyLargeFilter() + if selectedPath != "" { + m.selectEntryPath(selectedPath) + } + m.clampEntrySelection() + return m, waitLiveScanEventCmd(msg.events) + case liveScanEventMsg: + if !m.isCurrentLiveScan(msg.id, msg.path) { + return m, nil + } + switch msg.kind { + case liveScanChildProgress: + m.applyLiveChildProgress(msg.entry) + return m, waitLiveScanEventCmd(m.liveScanEvents) + case liveScanChildDone: + m.applyLiveChildUpdate(msg.entry, msg.result) + return m, waitLiveScanEventCmd(m.liveScanEvents) + case liveScanComplete: + m.finishLiveScan(msg.result) + return m, nil + case liveScanFailed: + m.status = fmt.Sprintf("Scan failed: %v", msg.err) + return m, waitLiveScanEventCmd(m.liveScanEvents) + case liveScanCanceled: + m.finishCanceledLiveScan() + return m, nil + default: + return m, waitLiveScanEventCmd(m.liveScanEvents) + } + case overviewSizeMsg: + delete(m.overviewScanningSet, msg.Path) + + if msg.Err == nil { + if m.overviewSizeCache == nil { + m.overviewSizeCache = make(map[string]int64) + } + m.overviewSizeCache[msg.Path] = msg.Size + } + + if m.inOverviewMode() { + for i := range m.entries { + if m.entries[i].Path == msg.Path { + if msg.Err == nil { + m.entries[i].Size = msg.Size + } else { + m.entries[i].Size = 0 + } + break + } + } + m.totalSize = sumKnownEntrySizes(m.entries) + + if msg.Err != nil { + m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err) + } + + cmd := m.scheduleOverviewScans() + return m, cmd + } + return m, nil + case tickMsg: + hasPending := false + if m.inOverviewMode() { + for _, entry := range m.entries { + if entry.Size < 0 { + hasPending = true + break + } + } + } + if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) { + m.spinner = (m.spinner + 1) % len(spinnerFrames) + if m.deleting && m.deleteCount != nil { + count := atomic.LoadInt64(m.deleteCount) + if count > 0 { + m.status = fmt.Sprintf("Moving to Trash... %s items", formatNumber(count)) + } + } + return m, tickCmd() + } + return m, nil + default: + return m, nil + } +} + +func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Delete confirm flow. + if m.deleteConfirm { + switch msg.String() { + case "enter": + m.deleteConfirm = false + m.deleting = true + var deleteCount int64 + m.deleteCount = &deleteCount + + // Collect paths (safer than indices). + var pathsToDelete []string + if m.showLargeFiles { + if len(m.largeMultiSelected) > 0 { + for path := range m.largeMultiSelected { + pathsToDelete = append(pathsToDelete, path) + } + } else if m.deleteTarget != nil { + pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) + } + } else { + if len(m.multiSelected) > 0 { + for path := range m.multiSelected { + pathsToDelete = append(pathsToDelete, path) + } + } else if m.deleteTarget != nil { + pathsToDelete = append(pathsToDelete, m.deleteTarget.Path) + } + } + + m.deleteTarget = nil + if len(pathsToDelete) == 0 { + m.deleting = false + m.status = "Nothing to delete" + return m, nil + } + + if len(pathsToDelete) == 1 { + targetPath := pathsToDelete[0] + m.status = fmt.Sprintf("Deleting %s...", filepath.Base(targetPath)) + return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd()) + } + + m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete)) + return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd()) + case "esc", "q": + m.status = "Cancelled" + m.deleteConfirm = false + m.deleteTarget = nil + return m, nil + case "ctrl+c": + return m, tea.Quit + default: + return m, nil + } + } + + // Filter prompts swallow all keys while the user types a query. + if m.largeFiltering { + return m.updateLargeFilterInput(msg) + } + if m.entryFiltering { + return m.updateEntryFilterInput(msg) + } + + switch msg.String() { + case "q", "Q", "ctrl+c": + return m, tea.Quit + case "esc": + if m.showLargeFiles { + if m.largeFilter != "" { + m.resetLargeFilter() + m.clampLargeSelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + } + m.showLargeFiles = false + return m, nil + } + if m.entryFilter != "" { + m.resetEntryFilter() + m.clampEntrySelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + } + return m.goBack() + case "up", "k", "K": + m.noteLiveCursorMove() + if m.showLargeFiles { + if m.largeSelected > 0 { + m.largeSelected-- + if m.largeSelected < m.largeOffset { + m.largeOffset = m.largeSelected + } + } + } else if len(m.entries) > 0 && m.selected > 0 { + 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 + } + } + case "down", "j", "J": + m.noteLiveCursorMove() + if m.showLargeFiles { + if m.largeSelected < len(m.largeFiles)-1 { + m.largeSelected++ + viewport := calculateViewport(m.height, true) + if m.largeSelected >= m.largeOffset+viewport { + m.largeOffset = m.largeSelected - viewport + 1 + } + } + } else if len(m.entries) > 0 && m.selected < len(m.entries)-1 { + 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 + } + } + case "enter", "right", "l", "L": + if m.showLargeFiles { + return m, nil + } + return m.enterSelectedDir() + case "b", "left", "h", "B", "H": + if m.showLargeFiles { + m.showLargeFiles = false + m.resetLargeFilter() + return m, nil + } + return m.goBack() + case "r", "R": + m.cancelLiveScan() + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) + + if m.inOverviewMode() { + // Explicitly invalidate cache for all overview entries to force re-scan + for _, entry := range m.entries { + invalidateCache(entry.Path) + } + + m.overviewSizeCache = make(map[string]int64) + m.overviewScanningSet = make(map[string]bool) + m.hydrateOverviewEntries() // Reset sizes to pending + + for i := range m.entries { + m.entries[i].Size = -1 + } + m.totalSize = 0 + + m.status = "Refreshing..." + m.overviewScanning = true + return m, tea.Batch(m.scheduleOverviewScans(), tickCmd()) + } + + invalidateCacheTree(m.path) + m.status = "Refreshing..." + 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()) + case "t", "T": + if m.scanning { + m.status = "Top files are available after the scan finishes" + return m, nil + } + if !m.inOverviewMode() { + m.showLargeFiles = !m.showLargeFiles + m.resetLargeFilter() + m.resetEntryFilter() + if m.showLargeFiles { + m.largeSelected = 0 + m.largeOffset = 0 + m.largeMultiSelected = make(map[string]bool) + } else { + m.multiSelected = make(map[string]bool) + } + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + case "/": + if m.inOverviewMode() { + break + } + if m.showLargeFiles { + if len(m.largeFilesAll) > 0 { + m.largeFiltering = true + m.status = "Filter: type to match, Enter to apply, Esc to clear" + } + } else if len(m.entriesAll) > 0 { + m.entryFiltering = true + m.status = "Filter: type to match, Enter to apply, Esc to clear" + } + case "s", "S": + if m.scanning && !m.inOverviewMode() { + m.liveSortMode = nextLiveSortMode(m.liveSortMode) + m.autoSortLiveEntries = m.liveSortMode == liveSortContinuous + if m.autoSortLiveEntries { + m.sortLiveEntriesForCurrentCursorMode() + } + m.status = fmt.Sprintf("Live sort: %s", liveSortModeLabel(m.liveSortMode)) + } + case "o", "O": + // Open selected entries (multi-select aware). + const maxBatchOpen = 20 + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + if len(m.largeMultiSelected) > 0 { + count := len(m.largeMultiSelected) + if count > maxBatchOpen { + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) + return m, nil + } + for path := range m.largeMultiSelected { + go func(p string) { + _ = safeOpen(p, false) + }(path) + } + m.status = fmt.Sprintf("Opening %d items...", count) + } else { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = safeOpen(path, false) + }(selected.Path) + m.status = fmt.Sprintf("Opening %s...", selected.Name) + } + } + } else if len(m.entries) > 0 { + if len(m.multiSelected) > 0 { + count := len(m.multiSelected) + if count > maxBatchOpen { + m.status = fmt.Sprintf("Too many items to open, max %d, selected %d", maxBatchOpen, count) + return m, nil + } + for path := range m.multiSelected { + go func(p string) { + _ = safeOpen(p, false) + }(path) + } + m.status = fmt.Sprintf("Opening %d items...", count) + } else { + selected := m.entries[m.selected] + go func(path string) { + _ = safeOpen(path, false) + }(selected.Path) + m.status = fmt.Sprintf("Opening %s...", selected.Name) + } + } + case "f", "F": + // Reveal in Finder (multi-select aware). + const maxBatchReveal = 20 + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + if len(m.largeMultiSelected) > 0 { + count := len(m.largeMultiSelected) + if count > maxBatchReveal { + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) + return m, nil + } + for path := range m.largeMultiSelected { + go func(p string) { + _ = safeOpen(p, true) + }(path) + } + m.status = fmt.Sprintf("Showing %d items in Finder...", count) + } else { + selected := m.largeFiles[m.largeSelected] + go func(path string) { + _ = safeOpen(path, true) + }(selected.Path) + m.status = fmt.Sprintf("Showing %s in Finder...", selected.Name) + } + } + } else if len(m.entries) > 0 { + if len(m.multiSelected) > 0 { + count := len(m.multiSelected) + if count > maxBatchReveal { + m.status = fmt.Sprintf("Too many items to reveal, max %d, selected %d", maxBatchReveal, count) + return m, nil + } + for path := range m.multiSelected { + go func(p string) { + _ = safeOpen(p, true) + }(path) + } + m.status = fmt.Sprintf("Showing %d items in Finder...", count) + } else { + selected := m.entries[m.selected] + go func(path string) { + _ = 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 " ": + if m.scanning { + m.status = "Selection is available after the scan finishes" + return m, nil + } + // Toggle multi-select (paths as keys). + if m.showLargeFiles { + if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) { + if m.largeMultiSelected == nil { + m.largeMultiSelected = make(map[string]bool) + } + selectedPath := m.largeFiles[m.largeSelected].Path + if m.largeMultiSelected[selectedPath] { + delete(m.largeMultiSelected, selectedPath) + } else { + m.largeMultiSelected[selectedPath] = true + } + count := len(m.largeMultiSelected) + if count > 0 { + var totalSize int64 + for path := range m.largeMultiSelected { + for _, file := range m.largeFiles { + if file.Path == path { + totalSize += file.Size + break + } + } + } + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) + } else { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + } + } else if len(m.entries) > 0 && !m.inOverviewMode() && m.selected < len(m.entries) { + if m.multiSelected == nil { + m.multiSelected = make(map[string]bool) + } + selectedPath := m.entries[m.selected].Path + if m.multiSelected[selectedPath] { + delete(m.multiSelected, selectedPath) + } else { + m.multiSelected[selectedPath] = true + } + count := len(m.multiSelected) + if count > 0 { + var totalSize int64 + for path := range m.multiSelected { + for _, entry := range m.entries { + if entry.Path == path { + totalSize += entry.Size + break + } + } + } + m.status = fmt.Sprintf("%d selected, %s", count, humanizeBytes(totalSize)) + } else { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } + } + case "delete", "backspace": + if m.scanning { + m.status = "Delete is available after the scan finishes" + return m, nil + } + if m.showLargeFiles { + if len(m.largeFiles) > 0 { + if len(m.largeMultiSelected) > 0 { + m.deleteConfirm = true + for path := range m.largeMultiSelected { + for _, file := range m.largeFiles { + if file.Path == path { + m.deleteTarget = &dirEntry{ + Name: file.Name, + Path: file.Path, + Size: file.Size, + IsDir: false, + } + break + } + } + break // Only need first one for display + } + } else if m.largeSelected < len(m.largeFiles) { + selected := m.largeFiles[m.largeSelected] + m.deleteConfirm = true + m.deleteTarget = &dirEntry{ + Name: selected.Name, + Path: selected.Path, + Size: selected.Size, + IsDir: false, + } + } + } + } else if len(m.entries) > 0 && !m.inOverviewMode() { + if len(m.multiSelected) > 0 { + m.deleteConfirm = true + for path := range m.multiSelected { + // Resolve entry by path. + for i := range m.entries { + if m.entries[i].Path == path { + m.deleteTarget = &m.entries[i] + break + } + } + break // Only need first one for display + } + } else if m.selected < len(m.entries) { + selected := m.entries[m.selected] + m.deleteConfirm = true + m.deleteTarget = &selected + } + } + } + return m, nil +} + +// updateLargeFilterInput handles keystrokes while the Top-files filter prompt +// is active. Typing edits the query and re-filters live; Enter applies and +// hands control back to navigation; Esc clears the filter entirely. Navigation +// and action keys are intentionally swallowed so they edit the query instead of +// moving the cursor or deleting files. Changing the query clears any +// multi-selection so an action can never touch a row hidden by the filter. +func (m model) updateLargeFilterInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + m.resetLargeFilter() + m.clampLargeSelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + case tea.KeyEnter: + m.largeFiltering = false + if m.largeFilter == "" { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } else { + m.status = fmt.Sprintf("Filter %q, %d matches", m.largeFilter, len(m.largeFiles)) + } + return m, nil + case tea.KeyBackspace, tea.KeyDelete: + if r := []rune(m.largeFilter); len(r) > 0 { + m.largeFilter = string(r[:len(r)-1]) + m.largeMultiSelected = make(map[string]bool) + m.applyLargeFilter() + } + return m, nil + case tea.KeySpace: + m.largeFilter += " " + m.largeMultiSelected = make(map[string]bool) + m.applyLargeFilter() + return m, nil + case tea.KeyRunes: + m.largeFilter += string(msg.Runes) + m.largeMultiSelected = make(map[string]bool) + m.applyLargeFilter() + return m, nil + default: + return m, nil + } +} + +// updateEntryFilterInput is the directory-view counterpart to +// updateLargeFilterInput: it edits the drill-down filter query live, applies on +// Enter, clears on Esc, and clears multi-selection on any query change so an +// action can never touch a row hidden by the filter. +func (m model) updateEntryFilterInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEsc: + m.resetEntryFilter() + m.clampEntrySelection() + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + return m, nil + case tea.KeyEnter: + m.entryFiltering = false + if m.entryFilter == "" { + m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize)) + } else { + m.status = fmt.Sprintf("Filter %q, %d matches", m.entryFilter, len(m.entries)) + } + return m, nil + case tea.KeyBackspace, tea.KeyDelete: + if r := []rune(m.entryFilter); len(r) > 0 { + m.entryFilter = string(r[:len(r)-1]) + m.multiSelected = make(map[string]bool) + m.applyEntryFilter() + } + return m, nil + case tea.KeySpace: + m.entryFilter += " " + m.multiSelected = make(map[string]bool) + m.applyEntryFilter() + return m, nil + case tea.KeyRunes: + m.entryFilter += string(msg.Runes) + m.multiSelected = make(map[string]bool) + m.applyEntryFilter() + return m, nil + default: + return m, nil + } +} + +func (m model) goBack() (tea.Model, tea.Cmd) { + m.cancelLiveScan() + 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 + m.resetEntryFilter() + m.resetLargeFilter() + m.entriesAll = last.Entries + m.entries = last.Entries + m.largeFilesAll = last.LargeFiles + 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.cancelLiveScan() + m.isOverview = true + m.path = "/" + m.scanning = false + m.showLargeFiles = false + m.entriesAll = nil + m.resetEntryFilter() + m.resetLargeFilter() + m.largeFilesAll = nil + m.largeFiles = nil + m.largeSelected = 0 + m.largeOffset = 0 + m.deleteConfirm = false + m.deleteTarget = nil + m.selected = 0 + m.offset = 0 + m.hydrateOverviewEntries() + cmd := m.scheduleOverviewScans() + if cmd == nil { + m.status = "Ready" + return nil + } + return tea.Batch(cmd, tickCmd()) +} + +func (m model) enterSelectedDir() (tea.Model, tea.Cmd) { + if len(m.entries) == 0 { + return m, nil + } + selected := m.entries[m.selected] + if selected.IsDir { + m.cancelLiveScan() + // Drilling in commits and drops any active directory filter so the parent + // is snapshotted in full. Remap the selection onto the unfiltered list so + // the entry we enter stays highlighted when we navigate back. + if m.entryFilter != "" { + for i := range m.entriesAll { + if m.entriesAll[i].Path == selected.Path { + m.selected = i + break + } + } + } + m.resetEntryFilter() + m.clampEntrySelection() + + if len(m.history) == 0 || m.history[len(m.history)-1].Path != m.path { + m.history = append(m.history, snapshotFromModel(m)) + } + m.path = selected.Path + m.selected = 0 + m.offset = 0 + m.status = "Scanning..." + m.scanning = true + m.isOverview = false + m.viewNeedsRefresh = false + m.multiSelected = make(map[string]bool) + m.largeMultiSelected = make(map[string]bool) + + atomic.StoreInt64(m.filesScanned, 0) + atomic.StoreInt64(m.dirsScanned, 0) + atomic.StoreInt64(m.bytesScanned, 0) + if m.currentPath != nil { + m.currentPath.Store("") + } + + m.resetLargeFilter() + if cached, ok := m.cache[m.path]; ok { + m.entriesAll = slices.Clone(cached.Entries) + m.entries = m.entriesAll + m.largeFilesAll = slices.Clone(cached.LargeFiles) + m.largeFiles = m.largeFilesAll + 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 + } + m.lastTotalFiles = 0 + if total, err := peekCacheTotalFiles(m.path); err == nil && total > 0 { + m.lastTotalFiles = total + } + return m, tea.Batch(m.scanCmd(m.path), tickCmd()) + } + m.status = fmt.Sprintf("File: %s, %s", selected.Name, humanizeBytes(selected.Size)) + return m, nil +} + +func scanOverviewPathCmd(path string, index int) tea.Cmd { + return func() tea.Msg { + size, err := measureInsightSize(path) + return overviewSizeMsg{ + Path: path, + Index: index, + Size: size, + Err: err, + } + } +} diff --git a/Resources/mole/cmd/analyze/view.go b/Resources/mole/cmd/analyze/view.go index d696969..36e795b 100644 --- a/Resources/mole/cmd/analyze/view.go +++ b/Resources/mole/cmd/analyze/view.go @@ -13,36 +13,30 @@ func (m model) View() string { var b strings.Builder fmt.Fprintln(&b) + // A warm cache already loaded for the current path keeps rendering while the + // background refresh runs, instead of blanking to a scan-only screen. Fresh + // scans (no cached entries yet) still fall back to the scan-only view. + showingCachedView := m.scanning && !m.inOverviewMode() && m.viewNeedsRefresh && len(m.entries) > 0 + showingLiveScanView := m.scanning && !m.inOverviewMode() && len(m.entries) > 0 && + (m.liveScanEvents != nil || len(m.liveScanningPaths) > 0) + 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 { - if entry.Size >= 0 { - allPending = false - break - } - } - - if allPending { - fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n", - colorCyan, colorBold, - spinnerFrames[m.spinner], - colorReset, colorReset) - return b.String() + if allOverviewEntriesPending(m.entries) { + fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) + fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage...\n\n", + colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) } else { fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) fmt.Fprintf(&b, "%s%s%s%s %s\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, m.status) } } else { - hasPending := false - for _, entry := range m.entries { - if entry.Size < 0 { - hasPending = true - break - } - } - if hasPending { + if hasPendingOverviewEntries(m.entries) { fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset) fmt.Fprintf(&b, "%s%s%s%s %s\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset, m.status) } else { @@ -51,7 +45,7 @@ func (m model) View() string { } } else { fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurpleBold, colorReset, colorGray, displayPath(m.path), colorReset) - if !m.scanning { + if !m.scanning || m.totalSize > 0 { fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize)) } fmt.Fprintf(&b, "\n\n") @@ -99,7 +93,7 @@ func (m model) View() string { colorGreen, humanizeBytes(bytesScanned), colorReset) if m.currentPath != nil { - currentPath := m.currentPath.Load().(string) + currentPath, _ := m.currentPath.Load().(string) if currentPath != "" { shortPath := displayPath(currentPath) shortPath = truncateMiddle(shortPath, 50) @@ -107,22 +101,37 @@ func (m model) View() string { } } - return b.String() + if !showingCachedView && !showingLiveScanView { + return b.String() + } + if showingCachedView { + fmt.Fprintf(&b, "%sShowing cached results while refreshing...%s\n\n", colorGray, colorReset) + } else { + fmt.Fprintln(&b) + } } if m.showLargeFiles { + if m.largeFiltering || m.largeFilter != "" { + cursor := "" + if m.largeFiltering { + cursor = "▌" + } + fmt.Fprintf(&b, " %sFilter:%s %s%s %s(%d matches)%s\n\n", + colorCyan, colorReset, m.largeFilter, cursor, + colorGray, len(m.largeFiles), colorReset) + } if len(m.largeFiles) == 0 { - fmt.Fprintln(&b, " No large files found") + if m.largeFilter != "" { + fmt.Fprintf(&b, " No matches for %q\n", m.largeFilter) + } else { + fmt.Fprintln(&b, " No large files found") + } } else { viewport := calculateViewport(m.height, true) start := max(m.largeOffset, 0) end := min(start+viewport, len(m.largeFiles)) - maxLargeSize := int64(1) - for _, file := range m.largeFiles { - if file.Size > maxLargeSize { - maxLargeSize = file.Size - } - } + maxLargeSize := maxLargeFileSize(m.largeFiles) nameWidth := calculateNameWidth(m.width) for idx := start; idx < end; idx++ { file := m.largeFiles[idx] @@ -156,22 +165,36 @@ func (m model) View() string { } } } else { + if !m.inOverviewMode() && (m.entryFiltering || m.entryFilter != "") { + cursor := "" + if m.entryFiltering { + cursor = "▌" + } + fmt.Fprintf(&b, " %sFilter:%s %s%s %s(%d matches)%s\n\n", + colorCyan, colorReset, m.entryFilter, cursor, + colorGray, len(m.entries), colorReset) + } if len(m.entries) == 0 { - fmt.Fprintln(&b, " Empty directory") + if !m.inOverviewMode() && m.entryFilter != "" { + fmt.Fprintf(&b, " No matches for %q\n", m.entryFilter) + } else { + fmt.Fprintln(&b, " Empty directory") + } } else { if m.inOverviewMode() { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } + maxSize := maxDirEntrySize(m.entries) 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 { @@ -190,16 +213,7 @@ func (m model) View() string { } sizeColor := colorGray if sizeVal >= 0 && totalSize > 0 { - switch { - case percent >= 50: - sizeColor = colorRed - case percent >= 20: - sizeColor = colorYellow - case percent >= 5: - sizeColor = colorBlue - default: - sizeColor = colorGray - } + sizeColor = sizeColorForPercent(percent) } entryPrefix := " " name := trimNameWithWidth(entry.Name, nameWidth) @@ -214,15 +228,17 @@ func (m model) View() string { percentColor = colorCyan sizeColor = colorCyan } - displayIndex := idx + 1 - - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) - } else { - if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } + displayNum++ + displayIndex := displayNum + + // In overview mode the leading icon (👀 vs 📁) already + // signals "inspect this" vs "browse this", so the + // right-side hint shows only the unused-time tag. + // The cleanable broom (🧹) belongs to non-overview + // directory rows, where it acts as a per-row marker. + hintLabel := "" + if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { + hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) } if hintLabel == "" { @@ -236,12 +252,7 @@ func (m model) View() string { } } } else { - maxSize := int64(1) - for _, entry := range m.entries { - if entry.Size > maxSize { - maxSize = entry.Size - } - } + maxSize := maxDirEntrySize(m.entries) viewport := calculateViewport(m.height, false) nameWidth := calculateNameWidth(m.width) @@ -254,24 +265,26 @@ func (m model) View() string { if entry.IsDir { icon = "📁" } - size := humanizeBytes(entry.Size) name := trimNameWithWidth(entry.Name, nameWidth) paddedName := padName(name, nameWidth) - percent := float64(entry.Size) / float64(m.totalSize) * 100 + sizeValue := max(entry.Size, 0) + percent := 0.0 + if m.totalSize > 0 && entry.Size >= 0 { + percent = float64(entry.Size) / float64(m.totalSize) * 100 + } percentStr := fmt.Sprintf("%5.1f%%", percent) + if entry.Size < 0 || m.totalSize <= 0 { + percentStr = " -- " + } - bar := coloredProgressBar(entry.Size, maxSize, percent) + bar := coloredProgressBar(sizeValue, maxSize, percent) - var sizeColor string - if percent >= 50 { - sizeColor = colorRed - } else if percent >= 20 { - sizeColor = colorYellow - } else if percent >= 5 { - sizeColor = colorBlue - } else { - sizeColor = colorGray + sizeColor := sizeColorForPercent(percent) + size := humanizeBytes(entry.Size) + if entry.Size < 0 { + size = fmt.Sprintf("%s %s", spinnerFrames[m.spinner], "scanning") + sizeColor = colorCyan } isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path] @@ -301,23 +314,20 @@ func (m model) View() string { displayIndex := idx + 1 - var hintLabel string - if entry.IsDir && isCleanableDir(entry.Path) { - hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset) - } else { - if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { - hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) - } + hintLabel := entryHintLabel(entry) + activityMarker := "|" + if entry.IsDir && m.liveScanningPaths != nil && m.liveScanningPaths[entry.Path] { + activityMarker = fmt.Sprintf("%s%s%s%s", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset) } if hintLabel == "" { - fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n", + fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s %s %s %s%10s%s\n", entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset) + activityMarker, nameSegment, sizeColor, size, colorReset) } else { - fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n", + fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s %s %s %s%10s%s %s\n", entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset, - nameSegment, sizeColor, size, colorReset, hintLabel) + activityMarker, nameSegment, sizeColor, size, colorReset, hintLabel) } } } @@ -327,31 +337,41 @@ 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) + if m.largeFiltering { + fmt.Fprintf(&b, "%sType to filter | Enter Apply | Esc Clear | Ctrl+C Quit%s\n", colorGray, colorReset) + } else if m.largeFilter != "" { + fmt.Fprintf(&b, "%s↑↓← | Space Select | / Edit | Esc Clear filter | O Open | P Preview | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset) } else { - fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset) + selectCount := len(m.largeMultiSelected) + if selectCount > 0 { + fmt.Fprintf(&b, "%s↑↓← | Space Select | / Filter | 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 | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) + } } + } else if m.entryFiltering { + fmt.Fprintf(&b, "%sType to filter | Enter Apply | Esc Clear | Ctrl+C Quit%s\n", colorGray, colorReset) + } else if m.entryFilter != "" { + fmt.Fprintf(&b, "%s↑↓←→ | Enter | Space Select | / Edit | Esc Clear filter | O Open | P Preview | F File | ⌫ Del | Q 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 | / Filter | 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 | / Filter | 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 | / Filter | 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 | / Filter | R Refresh | O Open | P Preview | F File | ⌫ Del | Esc Back | Q/Ctrl+C Quit%s\n", colorGray, colorReset) } } } @@ -396,6 +416,58 @@ func (m model) View() string { return b.String() } +func allOverviewEntriesPending(entries []dirEntry) bool { + for _, entry := range entries { + if entry.Size >= 0 { + return false + } + } + return true +} + +func maxLargeFileSize(files []fileEntry) int64 { + var maxSize int64 = 1 + for _, file := range files { + if file.Size > maxSize { + maxSize = file.Size + } + } + return maxSize +} + +func maxDirEntrySize(entries []dirEntry) int64 { + var maxSize int64 = 1 + for _, entry := range entries { + if entry.Size > maxSize { + maxSize = entry.Size + } + } + return maxSize +} + +func sizeColorForPercent(percent float64) string { + switch { + case percent >= 50: + return colorRed + case percent >= 20: + return colorYellow + case percent >= 5: + return colorBlue + default: + return colorGray + } +} + +func entryHintLabel(entry dirEntry) string { + if entry.IsDir && isCleanableDir(entry.Path) { + return fmt.Sprintf("%s🧹%s", colorYellow, colorReset) + } + if unusedTime := formatUnusedTime(entry.LastAccess); unusedTime != "" { + return fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset) + } + return "" +} + // calculateViewport returns visible rows for the current terminal height. func calculateViewport(termHeight int, isLargeFiles bool) int { if termHeight <= 0 { diff --git a/Resources/mole/cmd/status/diagnosis.go b/Resources/mole/cmd/status/diagnosis.go new file mode 100644 index 0000000..3184e2f --- /dev/null +++ b/Resources/mole/cmd/status/diagnosis.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "strings" +) + +func statusDiagnosisLine(m MetricsSnapshot) string { + if m.CPU.Usage > cpuHighThreshold { + if proc, ok := leadingCPUProcess(m.TopProcesses, 50); ok { + return fmt.Sprintf("%s high CPU", shorten(proc.Name, 18)) + } + return "CPU load high" + } + if m.Memory.Pressure == "warn" || m.Memory.Pressure == "critical" || m.Memory.UsedPercent > memHighThreshold { + if proc, ok := leadingMemoryProcess(m.TopProcesses); ok && proc.Memory > 0 { + return fmt.Sprintf("%s memory pressure", shorten(proc.Name, 18)) + } + return "Memory pressure high" + } + if disk, ok := rootDisk(m.Disks); ok && disk.UsedPercent > diskCritThreshold { + free := uint64(0) + if disk.Total > disk.Used { + free = disk.Total - disk.Used + } + return fmt.Sprintf("Disk low, %s free", humanBytesShort(free)) + } + for _, battery := range m.Batteries { + if battery.Capacity > 0 && battery.Capacity < batteryCapWarn { + return "Battery health low" + } + if battery.CycleCount > batteryCycleWarn { + return "Battery cycles high" + } + } + if m.Thermal.CPUTemp > thermalNormalThreshold { + return "CPU temperature high" + } + if totalIO := m.DiskIO.ReadRate + m.DiskIO.WriteRate; totalIO > ioHighThreshold { + return "Disk I/O busy" + } + if strings.Contains(m.HealthScoreMsg, ":") { + return m.HealthScoreMsg + } + return "All clear" +} + +func leadingCPUProcess(procs []ProcessInfo, threshold float64) (ProcessInfo, bool) { + var best ProcessInfo + for i, proc := range procs { + if i == 0 || proc.CPU > best.CPU { + best = proc + } + } + if best.CPU < threshold { + return ProcessInfo{}, false + } + return best, true +} + +func leadingMemoryProcess(procs []ProcessInfo) (ProcessInfo, bool) { + var best ProcessInfo + for i, proc := range procs { + if i == 0 || proc.Memory > best.Memory { + best = proc + } + } + return best, len(procs) > 0 +} + +func rootDisk(disks []DiskStatus) (DiskStatus, bool) { + for _, disk := range disks { + if disk.Mount == "/" { + return disk, true + } + } + if len(disks) == 0 { + return DiskStatus{}, false + } + return disks[0], true +} diff --git a/Resources/mole/cmd/status/main.go b/Resources/mole/cmd/status/main.go index a31a8ba..ce329aa 100644 --- a/Resources/mole/cmd/status/main.go +++ b/Resources/mole/cmd/status/main.go @@ -14,14 +14,18 @@ import ( "github.com/charmbracelet/lipgloss" ) -const refreshInterval = time.Second +const ( + refreshInterval = time.Second + processWatchInterval = refreshInterval + slowRefreshInterval = 30 * time.Second +) var ( - Version = "dev" - 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 { @@ -41,22 +45,48 @@ func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool { type tickMsg struct{} type animTickMsg struct{} +type collectionMode int + +const ( + collectionFast collectionMode = iota + collectionProcess + collectionFull +) + type metricsMsg struct { data MetricsSnapshot err error + mode collectionMode } type model struct { - collector *Collector - width int - height int - metrics MetricsSnapshot - errMessage string - ready bool - lastUpdated time.Time - collecting bool - animFrame int - catHidden bool // true = hidden, false = visible + collector *Collector + width int + height int + metrics MetricsSnapshot + errMessage string + ready bool + lastUpdated time.Time + lastFullAt time.Time + lastProcessAt time.Time + collecting bool + animFrame int + 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. @@ -101,11 +131,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()) } @@ -131,8 +179,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.collecting = true - return m, m.collectCmd() + return m, m.collectCmd(m.nextCollectionMode(time.Now())) case metricsMsg: + wasReady := m.ready if msg.err != nil { m.errMessage = msg.err.Error() } else { @@ -140,12 +189,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.metrics = msg.data m.lastUpdated = msg.data.CollectedAt + if msg.mode == collectionFull && msg.err == nil { + m.lastFullAt = msg.data.CollectedAt + } + if (msg.mode == collectionProcess || msg.mode == collectionFull) && msg.err == nil { + m.lastProcessAt = msg.data.CollectedAt + } m.collecting = false // Mark ready after first successful data collection. if !m.ready { m.ready = true } - return m, tickAfter(refreshInterval) + delay := refreshInterval + if !wasReady { + delay = 0 + } + return m, tickAfter(delay) case animTickMsg: m.animFrame++ return m, animTickWithSpeed(m.metrics.CPU.Usage) @@ -164,7 +223,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,33 +240,54 @@ 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 { +func (m model) nextCollectionMode(now time.Time) collectionMode { + if !m.ready { + return collectionFast + } + if m.lastFullAt.IsZero() || now.Sub(m.lastFullAt) >= slowRefreshInterval { + return collectionFull + } + if m.lastProcessAt.IsZero() || now.Sub(m.lastProcessAt) >= processWatchInterval { + return collectionProcess + } + return collectionFast +} + +func (m model) collectCmd(mode collectionMode) tea.Cmd { return func() tea.Msg { - data, err := m.collector.Collect() - return metricsMsg{data: data, err: err} + var ( + data MetricsSnapshot + err error + ) + switch mode { + case collectionFull: + data, err = m.collector.Collect() + case collectionProcess: + data, err = m.collector.CollectProcesses() + default: + data, err = m.collector.CollectFast() + } + return metricsMsg{data: data, err: err, mode: mode} } } @@ -225,15 +307,8 @@ func animTickWithSpeed(cpuUsage float64) tea.Cmd { // runJSONMode collects metrics once and outputs as JSON. func runJSONMode() { - collector := NewCollector() - - // First collection initializes network state (returns nil for network) - _, _ = collector.Collect() + collector := NewCollector(processWatchOptionsFromFlags()) - // Wait 1 second for network rate calculation - time.Sleep(1 * time.Second) - - // Second collection has actual network data data, err := collector.Collect() if err != nil { fmt.Fprintf(os.Stderr, "error collecting metrics: %v\n", err) @@ -259,6 +334,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 +345,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..b38f024 100644 --- a/Resources/mole/cmd/status/main_test.go +++ b/Resources/mole/cmd/status/main_test.go @@ -1,8 +1,11 @@ package main import ( + "errors" "os" + "reflect" "testing" + "time" ) func TestShouldUseJSONOutput_ForceFlag(t *testing.T) { @@ -42,3 +45,336 @@ 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") + } +} + +func TestNextCollectionModeUsesFastFirstThenPeriodicFull(t *testing.T) { + now := time.Now() + + m := model{} + if got := m.nextCollectionMode(now); got != collectionFast { + t.Fatalf("new model nextCollectionMode() = %v, want fast", got) + } + + m.ready = true + if got := m.nextCollectionMode(now); got != collectionFull { + t.Fatalf("ready model without full collection = %v, want full", got) + } + + m.lastFullAt = now.Add(-slowRefreshInterval + time.Second) + if got := m.nextCollectionMode(now); got != collectionProcess { + t.Fatalf("fresh full without process collection mode = %v, want process", got) + } + + m.lastProcessAt = now.Add(-processWatchInterval + time.Millisecond) + if got := m.nextCollectionMode(now); got != collectionFast { + t.Fatalf("fresh process collection mode = %v, want fast", got) + } + + m.lastProcessAt = now.Add(-processWatchInterval) + if got := m.nextCollectionMode(now); got != collectionProcess { + t.Fatalf("stale process collection mode = %v, want process", got) + } + + m.lastFullAt = now.Add(-slowRefreshInterval) + if got := m.nextCollectionMode(now); got != collectionFull { + t.Fatalf("expired full collection mode = %v, want full", got) + } +} + +func TestFullCollectionErrorDoesNotMarkFullFresh(t *testing.T) { + now := time.Now() + lastFull := now.Add(-slowRefreshInterval) + m := model{ + ready: true, + lastFullAt: lastFull, + } + + updated, _ := m.Update(metricsMsg{ + data: MetricsSnapshot{ + CollectedAt: now, + }, + err: errors.New("full collector failed"), + mode: collectionFull, + }) + got := updated.(model) + + if !got.lastFullAt.Equal(lastFull) { + t.Fatalf("full error updated lastFullAt = %v, want %v", got.lastFullAt, lastFull) + } + if got.nextCollectionMode(now) != collectionFull { + t.Fatalf("full error should leave the next tick eligible for a full retry") + } +} + +func TestProcessCollectionUpdatesProcessFreshness(t *testing.T) { + now := time.Now() + m := model{ready: true} + + updated, _ := m.Update(metricsMsg{ + data: MetricsSnapshot{CollectedAt: now}, + mode: collectionProcess, + }) + got := updated.(model) + + if !got.lastProcessAt.Equal(now) { + t.Fatalf("process collection updated lastProcessAt = %v, want %v", got.lastProcessAt, now) + } + if !got.lastFullAt.IsZero() { + t.Fatalf("process collection should not update lastFullAt, got %v", got.lastFullAt) + } +} + +func TestCollectorAppliesCachedEnrichmentToFastSnapshot(t *testing.T) { + previous := MetricsSnapshot{ + CPU: CPUStatus{PCoreCount: 8, ECoreCount: 4}, + Memory: MemoryStatus{Cached: 512, Pressure: "warn"}, + Hardware: HardwareInfo{Model: "MacBook Pro", CPUModel: "M3", OSVersion: "macOS 15", RefreshRate: "120Hz"}, + GPU: []GPUStatus{{Name: "Apple GPU", Usage: 12}}, + TrashSize: 42, + TrashApprox: true, + Proxy: ProxyStatus{Enabled: true, Type: "HTTP", Host: "127.0.0.1:8080"}, + Batteries: []BatteryStatus{{Percent: 80, Capacity: 92}}, + Thermal: ThermalStatus{CPUTemp: 45}, + Sensors: []SensorReading{{Label: "Fan", Value: 1200, Unit: "rpm"}}, + Bluetooth: []BluetoothDevice{{Name: "Keyboard", Connected: true}}, + TopProcesses: []ProcessInfo{ + {PID: 42, Name: "Xcode", CPU: 82}, + }, + ProcessAlerts: []ProcessAlert{ + {PID: 42, Name: "Xcode", CPU: 140, Status: "active"}, + }, + } + + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(previous) + previous.GPU[0].Name = "mutated" + + next := MetricsSnapshot{ + UptimeSeconds: 60, + Hardware: HardwareInfo{TotalRAM: "16G", DiskSize: "1T"}, + CPU: CPUStatus{Usage: 10}, + Memory: MemoryStatus{UsedPercent: 30, Pressure: "normal"}, + Disks: []DiskStatus{{Mount: "/", Total: 100, Used: 20, UsedPercent: 20}}, + DiskIO: DiskIOStatus{ReadRate: 1, WriteRate: 1}, + } + + collector.applyEnrichment(&next, false) + + if next.Hardware.Model != "MacBook Pro" { + t.Fatalf("expected hardware details to be preserved, got %#v", next.Hardware) + } + if next.CPU.PCoreCount != 8 || next.CPU.ECoreCount != 4 { + t.Fatalf("expected CPU topology to be preserved, got %#v", next.CPU) + } + if next.Memory.Cached != 512 || next.Memory.Pressure != "warn" { + t.Fatalf("expected slow memory annotations to be preserved, got %#v", next.Memory) + } + if next.TrashSize != 42 || !next.TrashApprox { + t.Fatalf("expected trash metadata to be preserved, got size=%d approx=%v", next.TrashSize, next.TrashApprox) + } + if !next.Proxy.Enabled || next.Proxy.Host != "127.0.0.1:8080" { + t.Fatalf("expected proxy metadata to be preserved, got %#v", next.Proxy) + } + if len(next.GPU) != 1 || next.GPU[0].Name != "Apple GPU" { + t.Fatalf("expected GPU metadata to be preserved from cache, got %#v", next.GPU) + } + if len(next.Batteries) != 1 || next.Batteries[0].Capacity != 92 { + t.Fatalf("expected battery metadata to be preserved, got %#v", next.Batteries) + } + if next.Thermal.CPUTemp != 45 { + t.Fatalf("expected thermal metadata to be preserved, got %#v", next.Thermal) + } + if len(next.Bluetooth) != 1 || next.Bluetooth[0].Name != "Keyboard" { + t.Fatalf("expected Bluetooth metadata to be preserved, got %#v", next.Bluetooth) + } + if len(next.TopProcesses) != 1 || next.TopProcesses[0].Name != "Xcode" { + t.Fatalf("expected top processes to be preserved, got %#v", next.TopProcesses) + } + if len(next.ProcessAlerts) != 1 || next.ProcessAlerts[0].Status != "active" { + t.Fatalf("expected process alerts to be preserved, got %#v", next.ProcessAlerts) + } + if next.HealthScore == 0 || next.HealthScoreMsg == "" { + t.Fatalf("expected health score to be recalculated, got %d %q", next.HealthScore, next.HealthScoreMsg) + } +} + +func TestCollectorAppliesZeroValueEnrichmentExactly(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + Memory: MemoryStatus{ + Cached: 0, + Pressure: "", + }, + }) + + next := MetricsSnapshot{ + Memory: MemoryStatus{ + Cached: 512, + Pressure: "critical", + }, + } + + collector.applyEnrichment(&next, false) + + if next.Memory.Cached != 0 || next.Memory.Pressure != "" { + t.Fatalf("expected exact memory enrichment, got %#v", next.Memory) + } +} + +func TestCollectorOverridesFastDisksWithCorrectedCache(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + Disks: []DiskStatus{ + {Mount: "/", Total: 1000, Used: 600, UsedPercent: 60, External: false}, + }, + }) + + // Fast path produced raw statfs numbers that ignore APFS purgeable space. + next := MetricsSnapshot{ + Disks: []DiskStatus{ + {Mount: "/", Total: 1000, Used: 900, UsedPercent: 90, External: true}, + }, + } + + collector.applyEnrichment(&next, false) + + if len(next.Disks) != 1 { + t.Fatalf("expected one disk, got %#v", next.Disks) + } + if next.Disks[0].Used != 600 || next.Disks[0].UsedPercent != 60 || next.Disks[0].External { + t.Fatalf("expected corrected disk values from cache, got %#v", next.Disks[0]) + } +} + +func TestCollectorKeepsFastDisksWhenCacheHasNone(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + // First full refresh failed to enumerate disks; the cache should not blank + // out the fast path's raw disks. + collector.cacheEnrichment(MetricsSnapshot{Disks: nil}) + + next := MetricsSnapshot{ + Disks: []DiskStatus{ + {Mount: "/", Total: 1000, Used: 900, UsedPercent: 90}, + }, + } + + collector.applyEnrichment(&next, false) + + if len(next.Disks) != 1 || next.Disks[0].Used != 900 { + t.Fatalf("expected raw fast disks to survive empty cache, got %#v", next.Disks) + } +} + +func TestCollectorKeepsLiveProcessDataWhenApplyingEnrichment(t *testing.T) { + collector := NewCollector(ProcessWatchOptions{}) + collector.cacheEnrichment(MetricsSnapshot{ + TopProcesses: []ProcessInfo{{PID: 1, Name: "old", CPU: 10}}, + ProcessAlerts: []ProcessAlert{ + {PID: 1, Name: "old", Status: "active"}, + }, + }) + + next := MetricsSnapshot{ + TopProcesses: []ProcessInfo{{PID: 2, Name: "new", CPU: 90}}, + ProcessAlerts: []ProcessAlert{ + {PID: 2, Name: "new", Status: "active"}, + }, + } + + collector.applyEnrichment(&next, true) + + if len(next.TopProcesses) != 1 || next.TopProcesses[0].Name != "new" { + t.Fatalf("expected live top process data, got %#v", next.TopProcesses) + } + if len(next.ProcessAlerts) != 1 || next.ProcessAlerts[0].Name != "new" { + t.Fatalf("expected live process alerts, got %#v", next.ProcessAlerts) + } +} + +func TestMetricsSnapshotFieldsHaveCollectionClassifications(t *testing.T) { + classified := map[string]string{ + "CollectedAt": "fast", + "Host": "fast", + "Platform": "fast", + "Uptime": "fast", + "UptimeSeconds": "fast", + "Procs": "fast", + "Hardware": "enrichment", + "HealthScore": "recomputed", + "HealthScoreMsg": "recomputed", + "CPU": "mixed", + "GPU": "enrichment", + "Memory": "mixed", + "Disks": "enrichment", + "TrashSize": "enrichment", + "TrashApprox": "enrichment", + "DiskIO": "fast", + "Network": "fast", + "NetworkHistory": "fast", + "Proxy": "enrichment", + "Batteries": "enrichment", + "Thermal": "enrichment", + "Sensors": "enrichment", + "Bluetooth": "enrichment", + "TopProcesses": "live-or-enrichment", + "ProcessWatch": "config", + "ProcessAlerts": "live-or-enrichment", + } + + typ := reflect.TypeFor[MetricsSnapshot]() + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, ok := classified[name]; !ok { + t.Fatalf("MetricsSnapshot.%s has no collection classification", name) + } + } + if len(classified) != typ.NumField() { + t.Fatalf("field classification count = %d, want %d", len(classified), typ.NumField()) + } +} diff --git a/Resources/mole/cmd/status/metrics.go b/Resources/mole/cmd/status/metrics.go index 75ecd13..ca27e79 100644 --- a/Resources/mole/cmd/status/metrics.go +++ b/Resources/mole/cmd/status/metrics.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os/exec" + "slices" "sync" "time" @@ -61,24 +62,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 +102,13 @@ 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"` // Percent of physical memory, kept for compatibility. + MemoryBytes uint64 `json:"memory_bytes,omitempty"` } type CPUStatus struct { @@ -126,6 +136,7 @@ type GPUStatus struct { type MemoryStatus struct { Used uint64 `json:"used"` Total uint64 `json:"total"` + Available uint64 `json:"available"` UsedPercent float64 `json:"used_percent"` SwapUsed uint64 `json:"swap_used"` SwapTotal uint64 `json:"swap_total"` @@ -176,6 +187,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 @@ -207,149 +219,353 @@ type Collector struct { lastBT []BluetoothDevice // Fast metrics (1s). - prevNet map[string]net.IOCountersStat - lastNetAt time.Time - rxHistoryBuf *RingBuffer - txHistoryBuf *RingBuffer - lastGPUAt time.Time - cachedGPU []GPUStatus - prevDiskIO disk.IOCountersStat - lastDiskAt time.Time -} - -func NewCollector() *Collector { - return &Collector{ - prevNet: make(map[string]net.IOCountersStat), - rxHistoryBuf: NewRingBuffer(NetworkHistorySize), - txHistoryBuf: NewRingBuffer(NetworkHistorySize), - } + prevNet map[string]net.IOCountersStat + lastNetAt time.Time + rxHistoryBuf *RingBuffer + txHistoryBuf *RingBuffer + lastNetIPAt time.Time + cachedNetIPs map[string]string + lastGPUAt time.Time + cachedGPU []GPUStatus + lastGPUUsageAt time.Time + cachedGPUUsage float64 + prevDiskIO disk.IOCountersStat + lastDiskAt time.Time + + watchMu sync.Mutex + processWatch ProcessWatchConfig + processWatcher *ProcessWatcher + enrichment snapshotEnrichment + hasEnrichment bool } -func (c *Collector) Collect() (MetricsSnapshot, error) { - now := time.Now() +type collectedMetrics struct { + cpuStats CPUStatus + memStats MemoryStatus + diskStats []DiskStatus + trashSize uint64 + trashApprox bool + diskIO DiskIOStatus + netStats []NetworkStatus + proxyStats ProxyStatus + batteryStats []BatteryStatus + thermalStats ThermalStatus + sensorStats []SensorReading + gpuStats []GPUStatus + btStats []BluetoothDevice + allProcs []ProcessInfo + hasProcesses bool +} + +type snapshotEnrichment struct { + // When adding MetricsSnapshot fields, update + // TestMetricsSnapshotFieldsHaveCollectionClassifications. + hardware HardwareInfo + cpuPCores int + cpuECores int + memoryCached uint64 + memoryPressure string + disks []DiskStatus + hasDisks bool + gpu []GPUStatus + trashSize uint64 + trashApprox bool + proxy ProxyStatus + batteries []BatteryStatus + thermal ThermalStatus + sensors []SensorReading + bluetooth []BluetoothDevice + topProcesses []ProcessInfo + processAlerts []ProcessAlert +} + +func NewCollector(options ProcessWatchOptions) *Collector { + c := &Collector{ + prevNet: make(map[string]net.IOCountersStat), + rxHistoryBuf: NewRingBuffer(NetworkHistorySize), + txHistoryBuf: NewRingBuffer(NetworkHistorySize), + cachedNetIPs: make(map[string]string), + processWatch: options.SnapshotConfig(), + processWatcher: NewProcessWatcher(options), + } + c.primeNetworkCounters(time.Now()) + return c +} - // Host info is cached by gopsutil; fetch once. +func collectHostInfo() *host.InfoStat { hostInfo, _ := host.Info() if hostInfo == nil { hostInfo = &host.InfoStat{} } + return hostInfo +} +func collectConcurrently(tasks ...func() error) error { var ( - wg sync.WaitGroup - errMu sync.Mutex - mergeErr error - - cpuStats CPUStatus - memStats MemoryStatus - diskStats []DiskStatus - diskIO DiskIOStatus - netStats []NetworkStatus - proxyStats ProxyStatus - batteryStats []BatteryStatus - thermalStats ThermalStatus - sensorStats []SensorReading - gpuStats []GPUStatus - btStats []BluetoothDevice - topProcs []ProcessInfo + wg sync.WaitGroup + errMu sync.Mutex + merged error ) - // Helper to launch concurrent collection. - collect := func(fn func() error) { - wg.Add(1) - go func() { - defer wg.Done() + for _, task := range tasks { + wg.Go(func() { defer func() { if r := recover(); r != nil { errMu.Lock() panicErr := fmt.Errorf("collector panic: %v", r) - if mergeErr == nil { - mergeErr = panicErr + if merged == nil { + merged = panicErr } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, panicErr) + merged = fmt.Errorf("%v; %w", merged, panicErr) } errMu.Unlock() } }() - if err := fn(); err != nil { + if err := task(); err != nil { errMu.Lock() - if mergeErr == nil { - mergeErr = err + if merged == nil { + merged = err } else { - mergeErr = fmt.Errorf("%v; %w", mergeErr, err) + merged = fmt.Errorf("%v; %w", merged, err) } errMu.Unlock() } - }() + }) + } + + wg.Wait() + return merged +} + +func (c *Collector) CollectFast() (MetricsSnapshot, error) { + return c.collectFast(false) +} + +func (c *Collector) CollectProcesses() (MetricsSnapshot, error) { + return c.collectFast(true) +} + +func (c *Collector) collectFast(includeProcesses bool) (MetricsSnapshot, error) { + now := time.Now() + hostInfo := collectHostInfo() + var collected collectedMetrics + + tasks := []func() error{ + func() (err error) { collected.cpuStats, err = collectCPUFast(); return }, + func() (err error) { collected.memStats, err = collectMemoryFast(); return }, + func() (err error) { collected.diskStats, err = collectDisksFast(); return }, + func() (err error) { collected.diskIO = c.collectDiskIO(now); return nil }, + func() (err error) { collected.netStats = c.collectNetwork(now); return nil }, } + if includeProcesses { + tasks = append(tasks, func() error { return collectProcessesInto(&collected) }) + } + + mergeErr := collectConcurrently(tasks...) + + snapshot := c.snapshotFromMetrics(now, hostInfo, collected, false) + c.applyEnrichment(&snapshot, collected.hasProcesses) + return snapshot, mergeErr +} + +func (c *Collector) Collect() (MetricsSnapshot, error) { + return c.collectFull() +} + +func (c *Collector) collectFull() (MetricsSnapshot, error) { + now := time.Now() + hostInfo := collectHostInfo() + var collected collectedMetrics // 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 }) - 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 }) - collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil }) - collect(func() (err error) { thermalStats = collectThermal(); return nil }) - // Sensors disabled - CPU temp already shown in CPU card - // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) - collect(func() (err error) { gpuStats, err = c.collectGPU(now); return }) - collect(func() (err error) { - // Bluetooth is slow; cache for 30s. - if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { - btStats = c.collectBluetooth(now) - c.lastBT = btStats - c.lastBTAt = now - } else { - btStats = c.lastBT - } - return nil - }) - collect(func() (err error) { topProcs = collectTopProcesses(); return nil }) + tasks := []func() error{ + func() (err error) { collected.cpuStats, err = collectCPU(); return }, + func() (err error) { collected.memStats, err = collectMemory(); return }, + func() (err error) { collected.diskStats, err = collectDisks(); return }, + func() (err error) { collected.trashSize, collected.trashApprox = collectTrashSize(); return nil }, + func() (err error) { collected.diskIO = c.collectDiskIO(now); return nil }, + func() (err error) { collected.netStats = c.collectNetwork(now); return nil }, + func() (err error) { collected.proxyStats = collectProxy(); return nil }, + func() (err error) { collected.batteryStats, _ = collectBatteries(); return nil }, + func() (err error) { collected.thermalStats = collectThermal(); return nil }, + // Sensors disabled - CPU temp already shown in CPU card + // collect(func() (err error) { sensorStats, _ = collectSensors(); return nil }) + func() (err error) { collected.gpuStats, err = c.collectGPU(now); return }, + func() (err error) { + // Bluetooth is slow; cache for 30s. + if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 { + collected.btStats = c.collectBluetooth(now) + c.lastBT = collected.btStats + c.lastBTAt = now + } else { + collected.btStats = c.lastBT + } + return nil + }, + func() error { return collectProcessesInto(&collected) }, + } + mergeErr := collectConcurrently(tasks...) - // Wait for all to complete. - wg.Wait() + snapshot := c.snapshotFromMetrics(now, hostInfo, collected, true) + if mergeErr == nil { + c.cacheEnrichment(snapshot) + } + return snapshot, mergeErr +} +func collectProcessesInto(collected *collectedMetrics) error { + procs, err := collectProcessesFunc() + if err != nil { + return err + } + collected.allProcs = procs + collected.hasProcesses = true + return nil +} + +func (c *Collector) snapshotFromMetrics(now time.Time, hostInfo *host.InfoStat, collected collectedMetrics, refreshHardware bool) MetricsSnapshot { // Dependent tasks (post-collect). // Cache hardware info as it's expensive and rarely changes. - if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute { - c.cachedHW = collectHardware(memStats.Total, diskStats) + if refreshHardware && (!c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute) { + c.cachedHW = collectHardware(collected.memStats.Total, collected.diskStats) c.lastHWAt = now c.hasStatic = true } - hwInfo := c.cachedHW + hwInfo := c.hardwareForSnapshot() + + score, scoreMsg := calculateHealthScore( + collected.cpuStats, + collected.memStats, + collected.diskStats, + collected.diskIO, + collected.thermalStats, + collected.batteryStats, + hostInfo.Uptime, + ) + var topProcs []ProcessInfo + if collected.hasProcesses { + topProcs = topProcesses(collected.allProcs, 5) + } - score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats) + var processAlerts []ProcessAlert + c.watchMu.Lock() + if c.processWatcher != nil { + if collected.hasProcesses { + processAlerts = c.processWatcher.Update(now, collected.allProcs) + } else { + processAlerts = c.processWatcher.Snapshot() + } + } + 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, HealthScoreMsg: scoreMsg, - CPU: cpuStats, - GPU: gpuStats, - Memory: memStats, - Disks: diskStats, - DiskIO: diskIO, - Network: netStats, + CPU: collected.cpuStats, + GPU: collected.gpuStats, + Memory: collected.memStats, + Disks: collected.diskStats, + TrashSize: collected.trashSize, + TrashApprox: collected.trashApprox, + DiskIO: collected.diskIO, + Network: collected.netStats, NetworkHistory: NetworkHistory{ RxHistory: c.rxHistoryBuf.Slice(), TxHistory: c.txHistoryBuf.Slice(), }, - Proxy: proxyStats, - Batteries: batteryStats, - Thermal: thermalStats, - Sensors: sensorStats, - Bluetooth: btStats, - TopProcesses: topProcs, - }, mergeErr + Proxy: collected.proxyStats, + Batteries: collected.batteryStats, + Thermal: collected.thermalStats, + Sensors: collected.sensorStats, + Bluetooth: collected.btStats, + TopProcesses: topProcs, + ProcessWatch: c.processWatch, + ProcessAlerts: processAlerts, + } +} + +func (c *Collector) hardwareForSnapshot() HardwareInfo { + if c.hasStatic { + return c.cachedHW + } + return HardwareInfo{} +} + +func (c *Collector) cacheEnrichment(snapshot MetricsSnapshot) { + c.enrichment = snapshotEnrichment{ + hardware: snapshot.Hardware, + cpuPCores: snapshot.CPU.PCoreCount, + cpuECores: snapshot.CPU.ECoreCount, + memoryCached: snapshot.Memory.Cached, + memoryPressure: snapshot.Memory.Pressure, + disks: slices.Clone(snapshot.Disks), + hasDisks: true, + gpu: slices.Clone(snapshot.GPU), + trashSize: snapshot.TrashSize, + trashApprox: snapshot.TrashApprox, + proxy: snapshot.Proxy, + batteries: slices.Clone(snapshot.Batteries), + thermal: snapshot.Thermal, + sensors: slices.Clone(snapshot.Sensors), + bluetooth: slices.Clone(snapshot.Bluetooth), + topProcesses: slices.Clone(snapshot.TopProcesses), + processAlerts: slices.Clone(snapshot.ProcessAlerts), + } + c.hasEnrichment = true +} + +func (c *Collector) applyEnrichment(snapshot *MetricsSnapshot, preserveLiveProcesses bool) { + if snapshot == nil || !c.hasEnrichment { + return + } + c.enrichment.apply(snapshot, preserveLiveProcesses) + snapshot.HealthScore, snapshot.HealthScoreMsg = calculateHealthScore( + snapshot.CPU, + snapshot.Memory, + snapshot.Disks, + snapshot.DiskIO, + snapshot.Thermal, + snapshot.Batteries, + snapshot.UptimeSeconds, + ) +} + +func (e snapshotEnrichment) apply(snapshot *MetricsSnapshot, preserveLiveProcesses bool) { + snapshot.Hardware = e.hardware + snapshot.CPU.PCoreCount = e.cpuPCores + snapshot.CPU.ECoreCount = e.cpuECores + snapshot.Memory.Cached = e.memoryCached + snapshot.Memory.Pressure = e.memoryPressure + // Disk capacity is slow-changing and the corrections (APFS purgeable, + // diskutil, Finder) are expensive, so the fast path collects raw statfs + // values and we overwrite them with the last full-refresh corrected + // snapshot. DiskIO stays live. Skip when the cache is empty so the first + // fast paint still shows raw disks instead of a blank card. + if e.hasDisks && len(e.disks) > 0 { + snapshot.Disks = slices.Clone(e.disks) + } + snapshot.GPU = slices.Clone(e.gpu) + snapshot.TrashSize = e.trashSize + snapshot.TrashApprox = e.trashApprox + snapshot.Proxy = e.proxy + snapshot.Batteries = slices.Clone(e.batteries) + snapshot.Thermal = e.thermal + snapshot.Sensors = slices.Clone(e.sensors) + snapshot.Bluetooth = slices.Clone(e.bluetooth) + if !preserveLiveProcesses { + snapshot.TopProcesses = slices.Clone(e.topProcesses) + snapshot.ProcessAlerts = slices.Clone(e.processAlerts) + } } -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,13 +574,36 @@ 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 } + + commandExistsCacheMu.Lock() + if exists, ok := commandExistsCache[name]; ok { + commandExistsCacheMu.Unlock() + return exists + } + commandExistsCacheMu.Unlock() + + exists := lookPathExists(name) + + commandExistsCacheMu.Lock() + commandExistsCache[name] = exists + commandExistsCacheMu.Unlock() + return exists +} + +var ( + commandExistsCacheMu sync.Mutex + commandExistsCache = make(map[string]bool) +) + +func lookPathExists(name string) (exists bool) { defer func() { - // Treat LookPath panics as "missing". - _ = recover() + if recover() != nil { + exists = false + } }() _, err := exec.LookPath(name) return err == nil diff --git a/Resources/mole/cmd/status/metrics_battery.go b/Resources/mole/cmd/status/metrics_battery.go index c28e319..2880972 100644 --- a/Resources/mole/cmd/status/metrics_battery.go +++ b/Resources/mole/cmd/status/metrics_battery.go @@ -2,21 +2,27 @@ package main import ( "context" + "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "runtime" "strconv" "strings" + "sync" "time" ) var ( // Cache for heavy system_profiler output. - lastPowerAt time.Time - cachedPower string - powerCacheTTL = 30 * time.Second + powerCacheMu sync.Mutex + lastPowerAt time.Time + lastPowerJSONAt time.Time + cachedPower string + cachedPowerJSON string + powerCacheTTL = 30 * time.Second ) func collectBatteries() (batts []BatteryStatus, err error) { @@ -30,7 +36,7 @@ func collectBatteries() (batts []BatteryStatus, err error) { // macOS: pmset for real-time percentage/status. if runtime.GOOS == "darwin" && commandExists("pmset") { if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil { - // Health/cycles/capacity from cached system_profiler. + // Health/cycles/capacity from AppleSmartBattery and cached system_profiler. health, cycles, capacity := getCachedPowerData() if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 { return batts, nil @@ -118,13 +124,38 @@ func parsePMSet(raw string, health string, cycles int, capacity int) []BatterySt return out } -// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler. +// getCachedPowerData returns condition, cycles, and capacity from macOS power sources. func getCachedPowerData() (health string, cycles int, capacity int) { + health, cycles, capacity = getCachedSystemPowerData() + ioregCycles, ioregCapacity := getAppleSmartBatteryHealthData() + return mergeBatteryHealthData(health, cycles, capacity, ioregCycles, ioregCapacity) +} + +func getCachedSystemPowerData() (health string, cycles int, capacity int) { + if out := getSystemPowerJSONOutput(); out != "" { + if health, cycles, capacity, ok := parseSystemPowerJSON(out); ok { + return health, cycles, capacity + } + } + out := getSystemPowerOutput() if out == "" { return "", 0, 0 } + return parseSystemPowerText(out) +} + +func mergeBatteryHealthData(health string, cycles int, capacity int, ioregCycles int, ioregCapacity int) (string, int, int) { + if ioregCycles > 0 { + cycles = ioregCycles + } + if ioregCapacity > 0 { + capacity = ioregCapacity + } + return health, cycles, capacity +} +func parseSystemPowerText(out string) (health string, cycles int, capacity int) { for line := range strings.Lines(out) { lower := strings.ToLower(line) if strings.Contains(lower, "cycle count") { @@ -139,20 +170,158 @@ func getCachedPowerData() (health string, cycles int, capacity int) { } if strings.Contains(lower, "maximum capacity") { if _, after, found := strings.Cut(line, ":"); found { - capacityStr := strings.TrimSpace(after) - capacityStr = strings.TrimSuffix(capacityStr, "%") - capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr)) + capacity = parsePercentInt(after) } } } return health, cycles, capacity } +type systemPowerJSON struct { + SPPowerDataType []struct { + BatteryHealthInfo struct { + CycleCount int `json:"sppower_battery_cycle_count"` + Health string `json:"sppower_battery_health"` + MaximumCapacity string `json:"sppower_battery_health_maximum_capacity"` + } `json:"sppower_battery_health_info"` + } `json:"SPPowerDataType"` +} + +func parseSystemPowerJSON(raw string) (health string, cycles int, capacity int, ok bool) { + var payload systemPowerJSON + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return "", 0, 0, false + } + + for _, item := range payload.SPPowerDataType { + info := item.BatteryHealthInfo + parsedCapacity := parsePercentInt(info.MaximumCapacity) + if info.Health != "" || info.CycleCount > 0 || parsedCapacity > 0 { + return info.Health, info.CycleCount, parsedCapacity, true + } + } + return "", 0, 0, false +} + +func parsePercentInt(raw string) int { + raw = strings.TrimSpace(raw) + raw = strings.TrimSuffix(raw, "%") + raw = strings.TrimSpace(raw) + value, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return value +} + +func getAppleSmartBatteryHealthData() (cycles int, capacity int) { + if runtime.GOOS != "darwin" || !commandExists("ioreg") { + return 0, 0 + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + out, err := runCmd(ctx, "ioreg", "-rn", "AppleSmartBattery") + if err != nil { + return 0, 0 + } + return parseAppleSmartBatteryHealth(out) +} + +func parseAppleSmartBatteryHealth(out string) (cycles int, capacity int) { + var design, nominal, rawMax int + for line := range strings.Lines(out) { + line = strings.TrimSpace(line) + if cycles == 0 { + if raw, found := ioRegValueForKey(line, "CycleCount"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 && value < 100000 { + cycles = value + } + } + } + if design == 0 { + if raw, found := ioRegValueForKey(line, "DesignCapacity"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + design = value + } + } + } + if nominal == 0 { + if raw, found := ioRegValueForKey(line, "NominalChargeCapacity"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + nominal = value + } + } + } + if rawMax == 0 { + if raw, found := ioRegValueForKey(line, "AppleRawMaxCapacity"); found { + if value, err := strconv.Atoi(raw); err == nil && value > 0 { + rawMax = value + } + } + } + } + return cycles, batteryHealthPercent(design, nominal, rawMax) +} + +// batteryHealthPercent mirrors the algorithm used by the Mole Mac app +// (SystemMetricsCollector.batteryHealthPercent): NominalChargeCapacity is +// preferred over AppleRawMaxCapacity, the ratio is rounded half-away-from-zero, +// and the result is clamped to [0, 100]. +func batteryHealthPercent(design, nominal, rawMax int) int { + if design <= 0 { + return 0 + } + capacity := nominal + if capacity == 0 { + capacity = rawMax + } + if capacity <= 0 { + return 0 + } + pct := math.Round(float64(capacity) * 100.0 / float64(design)) + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + return int(pct) +} + +func getSystemPowerJSONOutput() string { + if runtime.GOOS != "darwin" { + return "" + } + + powerCacheMu.Lock() + defer powerCacheMu.Unlock() + + now := time.Now() + if cachedPowerJSON != "" && now.Sub(lastPowerJSONAt) < powerCacheTTL { + return cachedPowerJSON + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + out, err := runCmd(ctx, "system_profiler", "SPPowerDataType", "-json") + if err == nil { + cachedPowerJSON = out + lastPowerJSONAt = now + } + return cachedPowerJSON +} + func getSystemPowerOutput() string { if runtime.GOOS != "darwin" { return "" } + powerCacheMu.Lock() + defer powerCacheMu.Unlock() + now := time.Now() if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL { return cachedPower @@ -195,89 +364,171 @@ 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 +} + +func parseAppleSmartBatteryThermal(out string) ThermalStatus { + var thermal ThermalStatus + var ( + voltageMV float64 + amperageMA float64 + ) + + for line := range strings.Lines(out) { + line = strings.TrimSpace(line) + + // AppleSmartBattery reports battery temperature in centi-degrees Celsius. + if tempRaw, found := parseIORegFloatValue(line, "Temperature"); found && tempRaw > 0 { + if tempRaw < 1000 { + // Some fixtures and non-Apple platforms report Celsius directly. + thermal.BatteryTemp = tempRaw + } else { + thermal.BatteryTemp = float64(tempRaw) / 100.0 } + } - // Adapter power (Watts) from current adapter. - if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") { - if _, after, found := strings.Cut(line, "\"Watts\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 { - thermal.AdapterPower = watts - } - } + // Adapter power (Watts) from current adapter. Ignore AppleRawAdapterDetails: + // raw entries can appear before the normalized adapter details and should + // not win the display value. + if strings.Contains(line, `"AdapterDetails"`) && !strings.Contains(line, "AppleRaw") && thermal.AdapterPower == 0 { + watts, found := parseIORegFloatValue(line, "Watts") + if found && watts > 0 { + thermal.AdapterPower = watts } + } - // System power consumption (mW -> W). - if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found { - valStr := strings.TrimSpace(after) - valStr, _, _ = strings.Cut(valStr, ",") - valStr, _, _ = strings.Cut(valStr, "}") - valStr = strings.TrimSpace(valStr) - if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil { - // SystemPower should always be positive, reject invalid values - if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W - thermal.SystemPower = powerMW / 1000.0 - } - } + // System power consumption (mW -> W). + if powerMW, found := parseIORegFloatValue(line, "SystemPowerIn"); found { + setSystemPowerMW(&thermal, powerMW) + } + if thermal.SystemPower == 0 { + if powerMW, found := parseIORegFloatValue(line, "SystemPower"); found { + setSystemPowerMW(&thermal, powerMW) } + } - // 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) - - var powerMW float64 - var parsed bool - - // 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 - } + // Battery power (mW -> W, positive = discharging, negative = charging). + if powerMW, found := parseIORegSignedNumber(line, "BatteryPower"); found { + setBatteryPowerMW(&thermal, powerMW) + } - if parsed { - // Validate reasonable battery power range: -200W to 200W - if powerMW > -200000 && powerMW < 200000 { - thermal.BatteryPower = powerMW / 1000.0 - } - } - } + if voltage, found := parseIORegFloatValue(line, "Voltage"); found && voltage > 0 { + voltageMV = voltage + } + if voltage, found := parseIORegFloatValue(line, "AppleRawBatteryVoltage"); found && voltage > 0 { + voltageMV = voltage + } + if amperage, found := parseIORegSignedNumber(line, "InstantAmperage"); found && amperage != 0 { + amperageMA = amperage + } + if amperage, found := parseIORegSignedNumber(line, "Amperage"); found && amperage != 0 && amperageMA == 0 { + amperageMA = amperage } } - // 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 thermal.BatteryPower == 0 && voltageMV > 0 && amperageMA != 0 { + // AppleSmartBattery amperage is signed mA. Negative current means the + // battery is discharging, so keep BatteryPower positive for discharge. + batteryPowerW := -(voltageMV * amperageMA) / 1000000.0 + if batteryPowerW > -200 && batteryPowerW < 200 { + thermal.BatteryPower = batteryPowerW } } - return thermal } + +func setSystemPowerMW(thermal *ThermalStatus, powerMW float64) { + // SystemPower should always be positive; reject invalid values. + if powerMW >= 0 && powerMW < 1000000 { // 0 to 1000W + thermal.SystemPower = powerMW / 1000.0 + } +} + +func setBatteryPowerMW(thermal *ThermalStatus, powerMW float64) { + // Validate reasonable battery power range: -200W to 200W. + if powerMW > -200000 && powerMW < 200000 { + thermal.BatteryPower = powerMW / 1000.0 + } +} + +func parseIORegFloatValue(line string, key string) (float64, bool) { + raw, found := ioRegValueForKey(line, key) + if !found { + return 0, false + } + val, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, false + } + return val, true +} + +func parseIORegSignedNumber(line string, key string) (float64, bool) { + raw, found := ioRegValueForKey(line, key) + if !found { + return 0, false + } + val, ok := parseIORegSignedInteger(raw) + if !ok { + return 0, false + } + return float64(val), true +} + +func parseIORegSignedInteger(raw string) (int64, bool) { + if valInt, err := strconv.ParseInt(raw, 10, 64); err == nil { + return valInt, true + } + valUint, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, false + } + if valUint <= math.MaxInt64 { + return int64(valUint), true + } + // ioreg sometimes prints negative int64 values as uint64 two's complement. + negMag := ^valUint + 1 + if negMag > math.MaxInt64 { + return 0, false + } + return -int64(negMag), true +} + +func ioRegValueForKey(line string, key string) (string, bool) { + marker := `"` + key + `"` + _, rest, found := strings.Cut(line, marker) + if !found { + return "", false + } + rest = strings.TrimLeft(rest, " \t") + if !strings.HasPrefix(rest, "=") { + return "", false + } + rest = strings.TrimLeft(rest[1:], " \t") + if rest == "" || strings.HasPrefix(rest, ",") { + return "", false + } + end := len(rest) +scan: + for i, r := range rest { + switch r { + case ',', '}', ')', ' ', '\t', '\n', '\r': + end = i + break scan + } + } + value := strings.Trim(rest[:end], `"`) + if value == "" { + return "", false + } + return value, true +} 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..4aa0c53 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_battery_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "math" + "testing" +) + +func TestParseSystemPowerJSON(t *testing.T) { + raw := `{ + "SPPowerDataType" : [ + { + "sppower_battery_health_info" : { + "sppower_battery_cycle_count" : 4, + "sppower_battery_health" : "Good", + "sppower_battery_health_maximum_capacity" : "100\u00a0%" + } + } + ] +}` + + health, cycles, capacity, ok := parseSystemPowerJSON(raw) + + if !ok { + t.Fatal("expected battery health JSON to parse") + } + if health != "Good" { + t.Fatalf("health = %q, want Good", health) + } + if cycles != 4 { + t.Fatalf("cycles = %d, want 4", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want 100", capacity) + } +} + +func TestParseSystemPowerJSONRejectsInvalidPayload(t *testing.T) { + _, _, _, ok := parseSystemPowerJSON(`{"SPPowerDataType":[{}]}`) + if ok { + t.Fatal("expected empty battery health JSON to be ignored") + } +} + +func TestParseSystemPowerText(t *testing.T) { + nonBreakingSpace := string(rune(0x00a0)) + raw := " Battery Information:\n\n" + + " Health Information:\n" + + " Cycle Count: 12\n" + + " Condition: Normal\n" + + " Maximum Capacity: 97" + nonBreakingSpace + "%\n" + + health, cycles, capacity := parseSystemPowerText(raw) + + if health != "Normal" { + t.Fatalf("health = %q, want Normal", health) + } + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 97 { + t.Fatalf("capacity = %d, want 97", capacity) + } +} + +func TestMergeBatteryHealthDataPrefersAppleSmartBatteryCapacity(t *testing.T) { + health, cycles, capacity := mergeBatteryHealthData("Good", 12, 97, 13, 100) + + if health != "Good" { + t.Fatalf("health = %q, want Good", health) + } + if cycles != 13 { + t.Fatalf("cycles = %d, want 13", cycles) + } + if capacity != 100 { + t.Fatalf("capacity = %d, want 100", capacity) + } +} + +func TestParseAppleSmartBatteryHealthPrefersNominalCharge(t *testing.T) { + out := ` + | | "DesignCapacity" = 10000 + | | "AppleRawMaxCapacity" = 7800 + | | "NominalChargeCapacity" = 8300 + | | "CycleCount" = 250 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 250 { + t.Fatalf("cycles = %d, want 250", cycles) + } + if capacity != 83 { + t.Fatalf("capacity = %d, want 83", capacity) + } +} + +func TestParseAppleSmartBatteryHealthFallsBackToRawMaxCapacity(t *testing.T) { + out := ` + | | "DesignCapacity" = 10000 + | | "AppleRawMaxCapacity" = 7800 + | | "CycleCount" = 12 +` + + cycles, capacity := parseAppleSmartBatteryHealth(out) + + if cycles != 12 { + t.Fatalf("cycles = %d, want 12", cycles) + } + if capacity != 78 { + t.Fatalf("capacity = %d, want 78", capacity) + } +} + +func TestBatteryHealthPercentRoundsAndClamps(t *testing.T) { + if got := batteryHealthPercent(10000, 8249, 0); got != 82 { + t.Errorf("8249/10000 rounded = %d, want 82", got) + } + if got := batteryHealthPercent(10000, 8250, 0); got != 83 { + t.Errorf("8250/10000 rounded = %d, want 83", got) + } + if got := batteryHealthPercent(10000, 12000, 0); got != 100 { + t.Errorf("12000/10000 clamped = %d, want 100", got) + } + if got := batteryHealthPercent(0, 8000, 0); got != 0 { + t.Errorf("zero design = %d, want 0", got) + } + if got := batteryHealthPercent(10000, 0, 0); got != 0 { + t.Errorf("no capacity = %d, want 0", got) + } +} + +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) + } +} + +func TestParseAppleSmartBatteryThermalDerivesBatteryWattsFromVoltageAndAmperage(t *testing.T) { + out := ` + | | "Voltage" = 12000 + | | "InstantAmperage" = -1500 +` + + thermal := parseAppleSmartBatteryThermal(out) + + if math.Abs(thermal.BatteryPower-18.0) > 0.001 { + t.Fatalf("expected derived battery power 18W, got %v", thermal.BatteryPower) + } +} + +func TestParseAppleSmartBatteryThermalIgnoresRawAdapterWatts(t *testing.T) { + out := ` + | | "AppleRawAdapterDetails" = {"Watts" = 140} + | | "AdapterDetails" = {"Watts" = 96} +` + + thermal := parseAppleSmartBatteryThermal(out) + + if thermal.AdapterPower != 96 { + t.Fatalf("expected normalized adapter power 96W, got %v", thermal.AdapterPower) + } +} diff --git a/Resources/mole/cmd/status/metrics_cpu.go b/Resources/mole/cmd/status/metrics_cpu.go index 1c89df5..7072f09 100644 --- a/Resources/mole/cmd/status/metrics_cpu.go +++ b/Resources/mole/cmd/status/metrics_cpu.go @@ -14,10 +14,18 @@ import ( ) const ( - cpuSampleInterval = 200 * time.Millisecond + cpuSampleInterval = 100 * time.Millisecond ) func collectCPU() (CPUStatus, error) { + return collectCPUWithOptions(true) +} + +func collectCPUFast() (CPUStatus, error) { + return collectCPUWithOptions(false) +} + +func collectCPUWithOptions(includeSlowFallbacks bool) (CPUStatus, error) { counts, countsErr := cpu.Counts(false) if countsErr != nil || counts == 0 { counts = runtime.NumCPU() @@ -31,23 +39,32 @@ func collectCPU() (CPUStatus, error) { logical = 1 } - // Two-call pattern for more reliable CPU usage. - warmUpCPU() - time.Sleep(cpuSampleInterval) + if includeSlowFallbacks { + // Two-call pattern for more reliable CPU usage on the full refresh path. + warmUpCPU() + time.Sleep(cpuSampleInterval) + } percents, err := cpu.Percent(0, true) var totalPercent float64 perCoreEstimated := false if err != nil || len(percents) == 0 { - fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) - if fallbackErr != nil { - if err != nil { - return CPUStatus{}, err + if !includeSlowFallbacks { + // Fast path: skip the expensive secondary sampling and just + // estimate zeroed per-core usage. The next full refresh corrects it. + percents = make([]float64, logical) + perCoreEstimated = true + } else { + fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical) + if fallbackErr != nil { + if err != nil { + return CPUStatus{}, err + } + return CPUStatus{}, fallbackErr } - return CPUStatus{}, fallbackErr + totalPercent = fallbackUsage + percents = fallbackPerCore + perCoreEstimated = true } - totalPercent = fallbackUsage - percents = fallbackPerCore - perCoreEstimated = true } else { for _, v := range percents { totalPercent += v @@ -60,14 +77,17 @@ func collectCPU() (CPUStatus, error) { if loadStats != nil { loadAvg = *loadStats } - if loadErr != nil || isZeroLoad(loadAvg) { + if includeSlowFallbacks && (loadErr != nil || isZeroLoad(loadAvg)) { if fallback, err := fallbackLoadAvgFromUptime(); err == nil { loadAvg = fallback } } // P/E core counts for Apple Silicon. - pCores, eCores := getCoreTopology() + var pCores, eCores int + if includeSlowFallbacks { + pCores, eCores = getCoreTopology() + } return CPUStatus{ Usage: totalPercent, diff --git a/Resources/mole/cmd/status/metrics_disk.go b/Resources/mole/cmd/status/metrics_disk.go index da14f4d..669155e 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,8 +27,38 @@ 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, +} + +var ( + diskPartitionsFunc = disk.Partitions + diskUsageFunc = disk.Usage +) + func collectDisks() ([]DiskStatus, error) { - partitions, err := disk.Partitions(false) + return collectDisksWithCorrections(true) +} + +func collectDisksFast() ([]DiskStatus, error) { + return collectDisksWithCorrections(false) +} + +func collectDisksWithCorrections(useCorrections bool) ([]DiskStatus, error) { + partitions, err := diskPartitionsFunc(false) if err != nil { return nil, err } @@ -34,17 +69,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) @@ -54,32 +79,45 @@ func collectDisks() ([]DiskStatus, error) { if seenDevice[baseDevice] { continue } - usage, err := disk.Usage(part.Mountpoint) + usage, err := diskUsageFunc(part.Mountpoint) if err != nil || usage.Total == 0 { continue } + total := usage.Total + if useCorrections && 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 useCorrections && 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, + External: !useCorrections && strings.HasPrefix(part.Mountpoint, "/Volumes/"), }) seenDevice[baseDevice] = true seenVolume[volKey] = true } - annotateDiskTypes(disks) + if useCorrections { + annotateDiskTypes(disks) + } sort.Slice(disks, func(i, j int) bool { // First, prefer internal disks over external @@ -97,11 +135,54 @@ 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 + + // Trash size cache. ~/.Trash can contain deep trees, and status refreshes + // every second; a short cache prevents repeated WalkDir work without + // hiding changes for long. + trashSizeCacheMu sync.Mutex + trashSizeCachedAt time.Time + trashSizeCachedValue uint64 + trashSizeCachedApprox bool + trashSizeCacheTTL = 5 * time.Second ) func annotateDiskTypes(disks []DiskStatus) { @@ -179,6 +260,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 { @@ -202,8 +442,8 @@ func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { elapsed = 1 } - readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed - writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed + readRate := float64(counterDelta(total.ReadBytes, c.prevDiskIO.ReadBytes)) / 1024 / 1024 / elapsed + writeRate := float64(counterDelta(total.WriteBytes, c.prevDiskIO.WriteBytes)) / 1024 / 1024 / elapsed c.prevDiskIO = total c.lastDiskAt = now @@ -217,3 +457,62 @@ func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus { return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate} } + +func counterDelta(current, previous uint64) uint64 { + if current < previous { + return 0 + } + return current - previous +} + +// 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) { + trashSizeCacheMu.Lock() + if !trashSizeCachedAt.IsZero() && time.Since(trashSizeCachedAt) < trashSizeCacheTTL { + value := trashSizeCachedValue + approx := trashSizeCachedApprox + trashSizeCacheMu.Unlock() + return value, approx + } + trashSizeCacheMu.Unlock() + + total, approx := scanTrashSize() + + trashSizeCacheMu.Lock() + trashSizeCachedValue = total + trashSizeCachedApprox = approx + trashSizeCachedAt = time.Now() + trashSizeCacheMu.Unlock() + + return total, approx +} + +func scanTrashSize() (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..739cb35 --- /dev/null +++ b/Resources/mole/cmd/status/metrics_disk_test.go @@ -0,0 +1,214 @@ +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 TestCollectDisksFastSkipsSlowCorrections(t *testing.T) { + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origRunCmd := runCmd + origCommandExists := commandExists + t.Cleanup(func() { + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + runCmd = origRunCmd + commandExists = origCommandExists + }) + + const rawTotal = uint64(2 * 1024 * 1024 * 1024) + const rawUsed = uint64(1024 * 1024 * 1024) + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + if all { + t.Fatalf("collectDisksFast() should request physical partitions only") + } + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + if path != "/" { + t.Fatalf("unexpected disk usage path %q", path) + } + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: rawTotal, + Used: rawUsed, + UsedPercent: 50, + }, nil + } + commandExists = func(name string) bool { + t.Fatalf("collectDisksFast() should not check external command %q", name) + return false + } + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + t.Fatalf("collectDisksFast() should not run external command %q", name) + return "", errors.New("unexpected command") + } + + got, err := collectDisksFast() + if err != nil { + t.Fatalf("collectDisksFast() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("collectDisksFast() returned %d disks, want 1: %#v", len(got), got) + } + if got[0].Total != rawTotal || got[0].Used != rawUsed || got[0].UsedPercent != 50 { + t.Fatalf("collectDisksFast() should keep raw usage, got %#v", got[0]) + } +} + +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)) + } + }) +} + +func TestCounterDeltaClampsCounterReset(t *testing.T) { + if got := counterDelta(150, 100); got != 50 { + t.Fatalf("counterDelta increasing = %d, want 50", got) + } + if got := counterDelta(10, 100); got != 0 { + t.Fatalf("counterDelta reset = %d, want 0", got) + } +} diff --git a/Resources/mole/cmd/status/metrics_fast_test.go b/Resources/mole/cmd/status/metrics_fast_test.go new file mode 100644 index 0000000..f4810ec --- /dev/null +++ b/Resources/mole/cmd/status/metrics_fast_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/shirou/gopsutil/v4/disk" + gopsutilnet "github.com/shirou/gopsutil/v4/net" +) + +func TestCollectFastAvoidsExternalCommands(t *testing.T) { + origRunCmd := runCmd + origCommandExists := commandExists + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origIOCounters := ioCountersFunc + t.Cleanup(func() { + runCmd = origRunCmd + commandExists = origCommandExists + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + ioCountersFunc = origIOCounters + }) + + var externalCalls atomic.Int32 + runCmd = func(ctx context.Context, name string, args ...string) (string, error) { + externalCalls.Add(1) + return "", errors.New("unexpected command") + } + commandExists = func(name string) bool { + externalCalls.Add(1) + return false + } + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: 2 * 1024 * 1024 * 1024, + Used: 1024 * 1024 * 1024, + UsedPercent: 50, + }, nil + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 1024, BytesSent: 2048}, + }, nil + } + + collector := NewCollector(ProcessWatchOptions{}) + if _, err := collector.CollectFast(); err != nil { + t.Fatalf("CollectFast() error = %v", err) + } + if externalCalls.Load() != 0 { + t.Fatalf("CollectFast() made %d external command calls", externalCalls.Load()) + } +} + +func TestCollectProcessesKeepsLiveProcessesWithCachedEnrichment(t *testing.T) { + origPartitions := diskPartitionsFunc + origUsage := diskUsageFunc + origIOCounters := ioCountersFunc + origCollectProcesses := collectProcessesFunc + t.Cleanup(func() { + diskPartitionsFunc = origPartitions + diskUsageFunc = origUsage + ioCountersFunc = origIOCounters + collectProcessesFunc = origCollectProcesses + }) + + diskPartitionsFunc = func(all bool) ([]disk.PartitionStat, error) { + return []disk.PartitionStat{ + {Device: "/dev/disk3s1s1", Mountpoint: "/", Fstype: "apfs"}, + }, nil + } + diskUsageFunc = func(path string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: path, + Fstype: "apfs", + Total: 2 * 1024 * 1024 * 1024, + Used: 1024 * 1024 * 1024, + UsedPercent: 50, + }, nil + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{{Name: "en0", BytesRecv: 1024, BytesSent: 2048}}, nil + } + collectProcessesFunc = func() ([]ProcessInfo, error) { + return []ProcessInfo{ + {PID: 200, PPID: 1, Name: "new-hot-process", Command: "/usr/bin/new-hot-process", CPU: 240, Memory: 1.5}, + }, nil + } + + collector := NewCollector(ProcessWatchOptions{Enabled: true, CPUThreshold: 50}) + collector.cacheEnrichment(MetricsSnapshot{ + Hardware: HardwareInfo{Model: "MacBook Pro"}, + TrashSize: 99, + TopProcesses: []ProcessInfo{ + {PID: 100, Name: "old-process", CPU: 10}, + }, + ProcessAlerts: []ProcessAlert{ + {PID: 100, Name: "old-process", Status: "active"}, + }, + }) + + snapshot, err := collector.CollectProcesses() + if err != nil { + t.Fatalf("CollectProcesses() error = %v", err) + } + + if snapshot.Hardware.Model != "MacBook Pro" || snapshot.TrashSize != 99 { + t.Fatalf("expected cached enrichment to be preserved, got hardware=%#v trash=%d", snapshot.Hardware, snapshot.TrashSize) + } + if len(snapshot.TopProcesses) != 1 || snapshot.TopProcesses[0].Name != "new-hot-process" { + t.Fatalf("expected live top process data, got %#v", snapshot.TopProcesses) + } + if len(snapshot.ProcessAlerts) != 1 || snapshot.ProcessAlerts[0].Name != "new-hot-process" { + t.Fatalf("expected live process alert data, got %#v", snapshot.ProcessAlerts) + } +} diff --git a/Resources/mole/cmd/status/metrics_gpu.go b/Resources/mole/cmd/status/metrics_gpu.go index bb60235..e83879c 100644 --- a/Resources/mole/cmd/status/metrics_gpu.go +++ b/Resources/mole/cmd/status/metrics_gpu.go @@ -14,6 +14,7 @@ import ( const ( systemProfilerTimeout = 4 * time.Second macGPUInfoTTL = 10 * time.Minute + macGPUUsageTTL = 5 * time.Second powermetricsTimeout = 2 * time.Second ) @@ -35,7 +36,7 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) { // Real-time GPU usage. if len(c.cachedGPU) > 0 { - usage := getMacGPUUsage() + usage := c.getMacGPUUsage(now) result := make([]GPUStatus, len(c.cachedGPU)) copy(result, c.cachedGPU) // Apply usage to first GPU (Apple Silicon). @@ -151,6 +152,17 @@ func readMacGPUInfo() ([]GPUStatus, error) { return gpus, nil } +func (c *Collector) getMacGPUUsage(now time.Time) float64 { + if !c.lastGPUUsageAt.IsZero() && now.Sub(c.lastGPUUsageAt) < macGPUUsageTTL { + return c.cachedGPUUsage + } + + usage := getMacGPUUsage() + c.cachedGPUUsage = usage + c.lastGPUUsageAt = now + return usage +} + // getMacGPUUsage reads GPU active residency from powermetrics. func getMacGPUUsage() float64 { ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout) diff --git a/Resources/mole/cmd/status/metrics_health.go b/Resources/mole/cmd/status/metrics_health.go index 4bfd090..7415f74 100644 --- a/Resources/mole/cmd/status/metrics_health.go +++ b/Resources/mole/cmd/status/metrics_health.go @@ -15,29 +15,46 @@ const ( healthIOWeight = 10.0 // CPU. - cpuNormalThreshold = 30.0 - cpuHighThreshold = 70.0 + cpuNormalThreshold = 50.0 + cpuHighThreshold = 85.0 // Memory. - memNormalThreshold = 50.0 - memHighThreshold = 80.0 + memNormalThreshold = 70.0 + memHighThreshold = 88.0 memPressureWarnPenalty = 5.0 memPressureCritPenalty = 15.0 // Disk. - diskWarnThreshold = 70.0 - diskCritThreshold = 90.0 + diskWarnThreshold = 80.0 + diskCritThreshold = 93.0 // Thermal. - thermalNormalThreshold = 60.0 + thermalNormalThreshold = 65.0 thermalHighThreshold = 85.0 // Disk IO (MB/s). ioNormalThreshold = 50.0 ioHighThreshold = 150.0 + + // Battery. + batteryCycleWarn = 800 + batteryCycleDanger = 900 + batteryCapWarn = 80 + batteryCapDanger = 60 + + // Uptime (seconds). + uptimeWarnDays = 7 + uptimeDangerDays = 14 + uptimeWarnSecs = uptimeWarnDays * 86400 + uptimeDangerSecs = uptimeDangerDays * 86400 + + // Score display bands (shared with view.go score styling). + scoreExcellentThreshold = 85 + scoreGoodThreshold = 65 + scoreFairThreshold = 45 ) -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 +140,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 @@ -134,16 +172,14 @@ func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, d // Build message. var msg string switch { - case score >= 90: + case score >= scoreExcellentThreshold: msg = "Excellent" - case score >= 75: + case score >= scoreGoodThreshold: msg = "Good" - case score >= 60: + case score >= scoreFairThreshold: msg = "Fair" - case score >= 40: - msg = "Poor" default: - msg = "Critical" + msg = "Needs Attention" } if len(issues) > 0 { @@ -153,6 +189,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..9893103 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 { @@ -25,13 +26,14 @@ func TestCalculateHealthScorePerfect(t *testing.T) { func TestCalculateHealthScoreDetectsIssues(t *testing.T) { score, msg := calculateHealthScore( CPUStatus{Usage: 95}, - MemoryStatus{UsedPercent: 90, Pressure: "critical"}, - []DiskStatus{{UsedPercent: 95}}, + MemoryStatus{UsedPercent: 95, Pressure: "critical"}, + []DiskStatus{{UsedPercent: 98}}, DiskIOStatus{ReadRate: 120, WriteRate: 80}, ThermalStatus{CPUTemp: 90}, + nil, 0, ) - if score >= 40 { + if score >= 60 { t.Fatalf("expected heavy penalties bringing score down, got %d", score) } if msg == "Excellent" { @@ -63,11 +65,11 @@ func TestColorizeTempThresholds(t *testing.T) { expected string }{ {temp: 30.0, expected: "30.0"}, // Normal - should use okStyle (green) - {temp: 55.9, expected: "55.9"}, // Just below warning threshold - {temp: 56.0, expected: "56.0"}, // Warning threshold - should use warnStyle (yellow) - {temp: 65.0, expected: "65.0"}, // Mid warning range - {temp: 75.9, expected: "75.9"}, // Just below danger threshold - {temp: 76.0, expected: "76.0"}, // Danger threshold - should use dangerStyle (red) + {temp: 64.9, expected: "64.9"}, // Just below warning threshold + {temp: 65.0, expected: "65.0"}, // Warning threshold - should use warnStyle (yellow) + {temp: 78.0, expected: "78.0"}, // Mid warning range + {temp: 84.9, expected: "84.9"}, // Just below danger threshold + {temp: 85.0, expected: "85.0"}, // Danger threshold - should use dangerStyle (red) {temp: 90.0, expected: "90.0"}, // High temperature {temp: 0.0, expected: "0.0"}, // Edge case: zero } @@ -87,8 +89,8 @@ func TestColorizeTempThresholds(t *testing.T) { func TestColorizeTempStyleRanges(t *testing.T) { normalTemp := colorizeTemp(40.0) - warningTemp := colorizeTemp(65.0) - dangerTemp := colorizeTemp(85.0) + warningTemp := colorizeTemp(72.0) + dangerTemp := colorizeTemp(90.0) if normalTemp == "" || warningTemp == "" || dangerTemp == "" { t.Fatal("colorizeTemp should not return empty strings") @@ -97,11 +99,11 @@ func TestColorizeTempStyleRanges(t *testing.T) { if !strings.Contains(normalTemp, "40.0") { t.Errorf("normal temp should contain '40.0', got: %s", normalTemp) } - if !strings.Contains(warningTemp, "65.0") { - t.Errorf("warning temp should contain '65.0', got: %s", warningTemp) + if !strings.Contains(warningTemp, "72.0") { + t.Errorf("warning temp should contain '72.0', got: %s", warningTemp) } - if !strings.Contains(dangerTemp, "85.0") { - t.Errorf("danger temp should contain '85.0', got: %s", dangerTemp) + if !strings.Contains(dangerTemp, "90.0") { + t.Errorf("danger temp should contain '90.0', got: %s", dangerTemp) } } @@ -118,11 +120,11 @@ func TestCalculateHealthScoreEdgeCases(t *testing.T) { }{ { name: "all metrics at normal threshold", - cpu: CPUStatus{Usage: 30.0}, - mem: MemoryStatus{UsedPercent: 50.0}, - disks: []DiskStatus{{UsedPercent: 70.0}}, + cpu: CPUStatus{Usage: 50.0}, + mem: MemoryStatus{UsedPercent: 70.0}, + disks: []DiskStatus{{UsedPercent: 80.0}}, diskIO: DiskIOStatus{ReadRate: 25.0, WriteRate: 25.0}, - thermal: ThermalStatus{CPUTemp: 60.0}, + thermal: ThermalStatus{CPUTemp: 65.0}, wantMin: 95, wantMax: 100, }, @@ -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, "Healthy", "ok"}, + {"high cycles", 950, 85, "Service Soon", "danger"}, + {"low capacity", 200, 55, "Service Soon", "danger"}, + {"warn capacity", 200, 75, "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_memory.go b/Resources/mole/cmd/status/metrics_memory.go index 3e61631..ea1687b 100644 --- a/Resources/mole/cmd/status/metrics_memory.go +++ b/Resources/mole/cmd/status/metrics_memory.go @@ -11,6 +11,14 @@ import ( ) func collectMemory() (MemoryStatus, error) { + return collectMemoryWithOptions(true) +} + +func collectMemoryFast() (MemoryStatus, error) { + return collectMemoryWithOptions(false) +} + +func collectMemoryWithOptions(includeSlowAnnotations bool) (MemoryStatus, error) { vm, err := mem.VirtualMemory() if err != nil { return MemoryStatus{}, err @@ -20,17 +28,21 @@ func collectMemory() (MemoryStatus, error) { if swap == nil { swap = &mem.SwapMemoryStat{} } - pressure := getMemoryPressure() + var pressure string + if includeSlowAnnotations { + pressure = getMemoryPressure() + } // On macOS, vm.Cached is 0, so we calculate from file-backed pages. cached := vm.Cached - if runtime.GOOS == "darwin" && cached == 0 { + if includeSlowAnnotations && runtime.GOOS == "darwin" && cached == 0 { cached = getFileBackedMemory() } return MemoryStatus{ Used: vm.Used, Total: vm.Total, + Available: vm.Available, UsedPercent: vm.UsedPercent, SwapUsed: swap.Used, SwapTotal: swap.Total, diff --git a/Resources/mole/cmd/status/metrics_network.go b/Resources/mole/cmd/status/metrics_network.go index a310474..c8bcf38 100644 --- a/Resources/mole/cmd/status/metrics_network.go +++ b/Resources/mole/cmd/status/metrics_network.go @@ -16,39 +16,66 @@ import ( var ioCountersFunc = net.IOCounters -func collectIOCountersSafely(pernic bool) (stats []net.IOCountersStat, err error) { +const ( + minNetworkSampleInterval = 100 * time.Millisecond + networkIPCacheTTL = 10 * time.Second +) + +var noiseInterfacePrefixes = [...]string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} + +func collectIOCountersSafely() (stats []net.IOCountersStat, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic collecting network counters: %v", r) } }() - return ioCountersFunc(pernic) + return ioCountersFunc(true) } -func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { - stats, err := collectIOCountersSafely(true) +func (c *Collector) primeNetworkCounters(now time.Time) { + stats, err := collectIOCountersSafely() + if err != nil { + return + } + c.lastNetAt = now + for _, s := range stats { + c.prevNet[s.Name] = s + } +} + +func (c *Collector) collectNetwork(now time.Time) []NetworkStatus { + if c.prevNet == nil { + c.prevNet = make(map[string]net.IOCountersStat) + } + if c.rxHistoryBuf == nil { + c.rxHistoryBuf = NewRingBuffer(NetworkHistorySize) + } + if c.txHistoryBuf == nil { + c.txHistoryBuf = NewRingBuffer(NetworkHistorySize) + } + + stats, err := collectIOCountersSafely() if err != nil { // Some restricted environments can break netstat-backed collectors. // Degrade gracefully to keep status output available. c.rxHistoryBuf.Add(0) c.txHistoryBuf.Add(0) - return nil, nil + return nil } // Map interface IPs. - ifAddrs := getInterfaceIPs() + ifAddrs := c.getInterfaceIPsCached(now) if c.lastNetAt.IsZero() { c.lastNetAt = now for _, s := range stats { c.prevNet[s.Name] = s } - return nil, nil } elapsed := now.Sub(c.lastNetAt).Seconds() - if elapsed <= 0 { - elapsed = 1 + if elapsed < minNetworkSampleInterval.Seconds() { + elapsed = minNetworkSampleInterval.Seconds() } var result []NetworkStatus @@ -60,14 +87,8 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { if !ok { continue } - rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed - tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed - if rx < 0 { - rx = 0 - } - if tx < 0 { - tx = 0 - } + rx := float64(counterDelta(cur.BytesRecv, prev.BytesRecv)) / 1024.0 / 1024.0 / elapsed + tx := float64(counterDelta(cur.BytesSent, prev.BytesSent)) / 1024.0 / 1024.0 / elapsed result = append(result, NetworkStatus{ Name: cur.Name, RxRateMBs: rx, @@ -98,7 +119,16 @@ func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) { c.rxHistoryBuf.Add(totalRx) c.txHistoryBuf.Add(totalTx) - return result, nil + return result +} + +func (c *Collector) getInterfaceIPsCached(now time.Time) map[string]string { + if c.cachedNetIPs != nil && now.Sub(c.lastNetIPAt) < networkIPCacheTTL { + return c.cachedNetIPs + } + c.cachedNetIPs = getInterfaceIPs() + c.lastNetIPAt = now + return c.cachedNetIPs } func getInterfaceIPs() map[string]string { @@ -122,8 +152,7 @@ func getInterfaceIPs() map[string]string { func isNoiseInterface(name string) bool { lower := strings.ToLower(name) - noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"} - for _, prefix := range noiseList { + for _, prefix := range noiseInterfacePrefixes { if strings.HasPrefix(lower, prefix) { return true } diff --git a/Resources/mole/cmd/status/metrics_network_test.go b/Resources/mole/cmd/status/metrics_network_test.go index 51e47cc..550d1ec 100644 --- a/Resources/mole/cmd/status/metrics_network_test.go +++ b/Resources/mole/cmd/status/metrics_network_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" gopsutilnet "github.com/shirou/gopsutil/v4/net" ) @@ -71,7 +72,7 @@ func TestCollectIOCountersSafelyRecoversPanic(t *testing.T) { } t.Cleanup(func() { ioCountersFunc = original }) - stats, err := collectIOCountersSafely(true) + stats, err := collectIOCountersSafely() if err == nil { t.Fatalf("expected error from panic recovery") } @@ -93,7 +94,7 @@ func TestCollectIOCountersSafelyReturnsData(t *testing.T) { } t.Cleanup(func() { ioCountersFunc = original }) - got, err := collectIOCountersSafely(true) + got, err := collectIOCountersSafely() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -101,3 +102,83 @@ func TestCollectIOCountersSafelyReturnsData(t *testing.T) { t.Fatalf("unexpected stats: %+v", got) } } + +func TestCollectNetworkFirstSampleReturnsZeroRateInterfaces(t *testing.T) { + original := ioCountersFunc + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 1000, BytesSent: 2000}, + }, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + c := &Collector{} + got := c.collectNetwork(time.Now()) + if len(got) != 1 { + t.Fatalf("expected first sample to render one interface, got %+v", got) + } + if got[0].RxRateMBs != 0 || got[0].TxRateMBs != 0 { + t.Fatalf("expected first sample zero rates, got %+v", got[0]) + } + if len(c.rxHistoryBuf.Slice()) != 1 || len(c.txHistoryBuf.Slice()) != 1 { + t.Fatalf("expected history to be seeded on first sample") + } +} + +func TestCollectNetworkUsesPrimedCountersForInitialRates(t *testing.T) { + original := ioCountersFunc + calls := 0 + samples := [][]gopsutilnet.IOCountersStat{ + {{Name: "en0", BytesRecv: 1024 * 1024, BytesSent: 0}}, + {{Name: "en0", BytesRecv: 2 * 1024 * 1024, BytesSent: 512 * 1024}}, + } + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + if calls >= len(samples) { + return samples[len(samples)-1], nil + } + got := samples[calls] + calls++ + return got, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + c := NewCollector(ProcessWatchOptions{}) + got := c.collectNetwork(c.lastNetAt.Add(time.Second)) + if len(got) != 1 { + t.Fatalf("expected one interface, got %+v", got) + } + if got[0].RxRateMBs != 1.0 { + t.Fatalf("expected 1 MB/s down, got %v", got[0].RxRateMBs) + } + if got[0].TxRateMBs != 0.5 { + t.Fatalf("expected 0.5 MB/s up, got %v", got[0].TxRateMBs) + } +} + +func TestCollectNetworkClampsCounterReset(t *testing.T) { + original := ioCountersFunc + ioCountersFunc = func(bool) ([]gopsutilnet.IOCountersStat, error) { + return []gopsutilnet.IOCountersStat{ + {Name: "en0", BytesRecv: 10, BytesSent: 20}, + }, nil + } + t.Cleanup(func() { ioCountersFunc = original }) + + base := time.Now() + c := &Collector{ + prevNet: map[string]gopsutilnet.IOCountersStat{ + "en0": {Name: "en0", BytesRecv: 1024 * 1024, BytesSent: 1024 * 1024}, + }, + lastNetAt: base, + rxHistoryBuf: NewRingBuffer(NetworkHistorySize), + txHistoryBuf: NewRingBuffer(NetworkHistorySize), + } + + got := c.collectNetwork(base.Add(time.Second)) + if len(got) != 1 { + t.Fatalf("expected one interface, got %+v", got) + } + if got[0].RxRateMBs != 0 || got[0].TxRateMBs != 0 { + t.Fatalf("expected reset counters to clamp to zero, got %+v", got[0]) + } +} diff --git a/Resources/mole/cmd/status/metrics_process.go b/Resources/mole/cmd/status/metrics_process.go index b11f25c..c3b3c5a 100644 --- a/Resources/mole/cmd/status/metrics_process.go +++ b/Resources/mole/cmd/status/metrics_process.go @@ -1,53 +1,204 @@ package main import ( + "container/heap" "context" + "fmt" "runtime" + "slices" "strconv" "strings" "time" ) -func collectTopProcesses() []ProcessInfo { +var collectProcessesFunc = collectProcesses + +func collectProcesses() ([]ProcessInfo, error) { if runtime.GOOS != "darwin" { - return nil + return nil, nil } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 3*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=,rss=,comm=", "-r") if err != nil { - return nil + out, err = runCmd(ctx, "ps", "aux") + if err != nil { + return nil, err + } + return parsePsAuxOutput(out), nil } + return parseProcessOutput(out), nil +} + +func parseProcessOutput(raw string) []ProcessInfo { + procs := make([]ProcessInfo, 0, strings.Count(raw, "\n")) + for line := range strings.Lines(strings.TrimSpace(raw)) { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } - var procs []ProcessInfo - i := 0 - for line := range strings.Lines(strings.TrimSpace(out)) { - if i == 0 { - i++ + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { continue } - if i > 5 { - break + ppid, _ := strconv.Atoi(fields[1]) + cpuVal, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + continue + } + memVal, err := strconv.ParseFloat(fields[3], 64) + if err != nil { + continue + } + + rssBytes := uint64(0) + commandStart := 4 + if len(fields) >= 6 { + if rssKB, err := strconv.ParseUint(fields[4], 10, 64); err == nil { + rssBytes = rssKB * 1024 + commandStart = 5 + } + } + + command := strings.Join(fields[commandStart:], " ") + if command == "" { + continue + } + procs = append(procs, ProcessInfo{ + PID: pid, + PPID: ppid, + Name: processNameFromCommand(command), + Command: command, + CPU: cpuVal, + Memory: memVal, + MemoryBytes: rssBytes, + }) + } + return procs +} + +// parsePsAuxOutput parses the fallback "ps aux" format. +// Columns: USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND +func parsePsAuxOutput(raw string) []ProcessInfo { + procs := make([]ProcessInfo, 0, strings.Count(raw, "\n")) + first := true + for line := range strings.Lines(strings.TrimSpace(raw)) { + if first { + first = false + continue } - i++ fields := strings.Fields(line) - if len(fields) < 3 { + if len(fields) < 11 { continue } - cpuVal, _ := strconv.ParseFloat(fields[0], 64) - memVal, _ := strconv.ParseFloat(fields[1], 64) - name := fields[len(fields)-1] - // Strip path from command name. - if idx := strings.LastIndex(name, "/"); idx >= 0 { - name = name[idx+1:] + pid, err := strconv.Atoi(fields[1]) + if err != nil || pid <= 0 { + continue + } + cpuVal, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + continue + } + memVal, err := strconv.ParseFloat(fields[3], 64) + if err != nil { + continue + } + rssKB, err := strconv.ParseUint(fields[5], 10, 64) + if err != nil { + rssKB = 0 + } + command := strings.Join(fields[10:], " ") + if command == "" { + continue } procs = append(procs, ProcessInfo{ - Name: name, - CPU: cpuVal, - Memory: memVal, + PID: pid, + PPID: 0, + Name: processNameFromCommand(command), + Command: command, + CPU: cpuVal, + Memory: memVal, + MemoryBytes: rssKB * 1024, }) } return procs } + +func processNameFromCommand(command string) string { + name := command + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + if spIdx := strings.Index(name, " "); spIdx >= 0 { + name = name[:spIdx] + } + return name +} + +func topProcesses(processes []ProcessInfo, limit int) []ProcessInfo { + if limit <= 0 || len(processes) == 0 { + return nil + } + + h := &processHeap{} + heap.Init(h) + for _, proc := range processes { + if h.Len() < limit { + heap.Push(h, proc) + continue + } + if processRanksBefore(proc, (*h)[0]) { + heap.Pop(h) + heap.Push(h, proc) + } + } + + top := make([]ProcessInfo, h.Len()) + for i := range slices.Backward(top) { + top[i] = heap.Pop(h).(ProcessInfo) + } + return top +} + +func formatProcessLabel(proc ProcessInfo) string { + if proc.Name != "" { + return fmt.Sprintf("%s (%d)", proc.Name, proc.PID) + } + return fmt.Sprintf("pid %d", proc.PID) +} + +func processRanksBefore(a, b ProcessInfo) bool { + if a.CPU != b.CPU { + return a.CPU > b.CPU + } + if a.Memory != b.Memory { + return a.Memory > b.Memory + } + return a.PID < b.PID +} + +type processHeap []ProcessInfo + +func (h processHeap) Len() int { return len(h) } + +func (h processHeap) Less(i, j int) bool { + return processRanksBefore(h[j], h[i]) +} + +func (h processHeap) Swap(i, j int) { + h[i], h[j] = h[j], h[i] +} + +func (h *processHeap) Push(x any) { + *h = append(*h, x.(ProcessInfo)) +} + +func (h *processHeap) Pop() any { + old := *h + n := len(old) + x := old[n-1] + *h = old[:n-1] + return x +} 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..a0f25fc --- /dev/null +++ b/Resources/mole/cmd/status/process_watch_test.go @@ -0,0 +1,240 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestParseProcessOutput(t *testing.T) { + raw := strings.Join([]string{ + "123 1 145.2 10.1 7340032 /Applications/Visual Studio Code.app/Contents/MacOS/Electron", + "456 1 99.5 2.2 262144 /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) + } + if procs[0].MemoryBytes != 7340032*1024 { + t.Fatalf("unexpected memory bytes %d", procs[0].MemoryBytes) + } +} + +func TestParseProcessOutputKeepsOldFiveColumnShape(t *testing.T) { + raw := "123 1 145.2 10.1 /Applications/Visual Studio Code.app/Contents/MacOS/Electron" + + procs := parseProcessOutput(raw) + if len(procs) != 1 { + t.Fatalf("parseProcessOutput() len = %d, want 1", len(procs)) + } + if procs[0].Memory != 10.1 { + t.Fatalf("unexpected memory percent %.1f", procs[0].Memory) + } + if procs[0].MemoryBytes != 0 { + t.Fatalf("old ps shape should not invent memory bytes, got %d", procs[0].MemoryBytes) + } + if procs[0].Command != "/Applications/Visual Studio Code.app/Contents/MacOS/Electron" { + t.Fatalf("unexpected command %q", procs[0].Command) + } +} + +func TestParsePsAuxOutputCapturesResidentMemory(t *testing.T) { + raw := strings.Join([]string{ + "USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND", + "raj 123 4.5 6.0 123456 2097152 ?? S 10:00AM 1:23 /Applications/Chrome.app/Contents/MacOS/Chrome --type=renderer", + }, "\n") + + procs := parsePsAuxOutput(raw) + if len(procs) != 1 { + t.Fatalf("parsePsAuxOutput() len = %d, want 1", len(procs)) + } + if procs[0].MemoryBytes != 2097152*1024 { + t.Fatalf("unexpected memory bytes %d", procs[0].MemoryBytes) + } + if !strings.Contains(procs[0].Command, "--type=renderer") { + t.Fatalf("command path missing args: %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 TestProcessNameFromCommand(t *testing.T) { + tests := []struct { + command string + want string + }{ + {"/Applications/Visual Studio Code.app/Contents/MacOS/Electron", "Electron"}, + {"/usr/local/bin/node /tmp/server.js", "server.js"}, + {"Finder", "Finder"}, + } + + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + if got := processNameFromCommand(tt.command); got != tt.want { + t.Fatalf("processNameFromCommand(%q) = %q, want %q", tt.command, got, tt.want) + } + }) + } +} + +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..d0e5824 100644 --- a/Resources/mole/cmd/status/view.go +++ b/Resources/mole/cmd/status/view.go @@ -3,10 +3,11 @@ package main import ( "fmt" "sort" - "strconv" "strings" "github.com/charmbracelet/lipgloss" + + "github.com/tw93/mole/internal/units" ) var ( @@ -17,7 +18,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 ( @@ -140,6 +146,10 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int scoreStyle := getScoreStyle(m.HealthScore) scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore)) + if errMsg == "" { + diagnosis := statusDiagnosisLine(m) + scoreText += " " + subtleStyle.Render(diagnosis) + } // Hardware info for a single line. infoParts := []string{} @@ -157,9 +167,13 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int var specs []string if m.Hardware.TotalRAM != "" { specs = append(specs, m.Hardware.TotalRAM) + } else if m.Memory.Total > 0 { + specs = append(specs, humanBytes(m.Memory.Total)) } if m.Hardware.DiskSize != "" { specs = append(specs, m.Hardware.DiskSize) + } else if disk, ok := rootDisk(m.Disks); ok && disk.Total > 0 { + specs = append(specs, humanBytes(disk.Total)) } if len(specs) > 0 { infoParts = append(infoParts, strings.Join(specs, "/")) @@ -172,7 +186,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 @@ -221,19 +244,46 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int func getScoreStyle(score int) lipgloss.Style { switch { - case score >= 90: + case score >= scoreExcellentThreshold: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true) - case score >= 75: + case score >= scoreGoodThreshold: return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true) - case score >= 60: + case score >= scoreFairThreshold: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true) - case score >= 40: - return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true) default: return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true) } } +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 @@ -288,7 +338,10 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent)) // Line 2: Free - freePercent := 100 - mem.UsedPercent + var freePercent float64 + if mem.Total > 0 { + freePercent = (float64(mem.Available) / float64(mem.Total)) * 100.0 + } lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent)) if hasSwap { @@ -312,7 +365,7 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { } lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total))) - lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Available))) } else { // Layout without Swap: // 3. Total @@ -323,13 +376,7 @@ func renderMemoryCard(mem MemoryStatus, cardWidth int) cardData { if mem.Cached > 0 { lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached))) } - // Calculate available if not provided directly, or use Total-Used as proxy if needed, - // but typically available is more nuanced. Using what we have. - // Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail" - // in simple terms for this view or we could use the passed definition. - // Original code calculated: available := mem.Total - mem.Used - available := mem.Total - mem.Used - lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available))) + lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Available))) } // Memory pressure status. if mem.Pressure != "" { @@ -346,7 +393,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 +412,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 +454,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 { @@ -423,7 +490,7 @@ func renderProcessCard(procs []ProcessInfo) cardData { } name := shorten(p.Name, 12) cpuBar := miniBar(p.CPU) - lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU)) + lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%%s", name, cpuBar, p.CPU, processHint(p))) } if len(lines) == 0 { lines = append(lines, subtleStyle.Render("No data")) @@ -431,11 +498,28 @@ func renderProcessCard(procs []ProcessInfo) cardData { return cardData{icon: iconProcs, title: "Processes", lines: lines} } +func processHint(p ProcessInfo) string { + if p.MemoryBytes > 0 { + hint := " " + humanBytesCompact(p.MemoryBytes) + if p.CPU >= cpuHighThreshold { + hint += " hot" + } + return hint + } + if p.Memory >= 10 { + return fmt.Sprintf(" M%.0f%%", p.Memory) + } + if p.CPU >= cpuHighThreshold { + return " hot" + } + return "" +} + 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), @@ -466,7 +550,7 @@ func renderNetworkCard(netStats []NetworkStatus, history NetworkHistory, proxy P } if len(netStats) == 0 { - lines = []string{subtleStyle.Render("Collecting...")} + lines = append(lines, subtleStyle.Render("Collecting...")) } else { // Calculate dynamic width // Layout: "Down " (7) + graph + " " (2) + rate (approx 10-12) @@ -565,44 +649,58 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText)) } - statusIcon := "" + if thermal.AdapterPower > 0 && isPoweredByAC(statusLower) { + lines = append(lines, fmt.Sprintf("%-6s %s %6s", + "Input", + okStyle.Render(plainProgressBar(100)), + fmt.Sprintf("%.0fW max", thermal.AdapterPower), + )) + } + statusStyle := subtleStyle - if statusLower == "charging" || statusLower == "charged" { - statusIcon = " ⚡" + if isPoweredByAC(statusLower) { statusStyle = okStyle } else if b.Percent < 20 { statusStyle = dangerStyle } - statusText := b.Status - if len(statusText) > 0 { - statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:]) - } + statusText := formatBatteryStatus(b.Status) if b.TimeLeft != "" { statusText += " · " + b.TimeLeft } - // Add power info. - if statusLower == "charging" || statusLower == "charged" { - if thermal.SystemPower > 0 { - statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower) - } else if thermal.AdapterPower > 0 { - statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower) - } - } else if thermal.BatteryPower > 0 { - // Only show battery power when discharging (positive value) - statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower) + if thermal.AdapterPower > 0 && isPoweredByAC(statusLower) { + statusText += fmt.Sprintf(" · %.0fW adapter", thermal.AdapterPower) } - lines = append(lines, statusStyle.Render(statusText+statusIcon)) + lines = append(lines, statusStyle.Render(statusText)) 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) } @@ -618,6 +716,32 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData { return cardData{icon: iconBattery, title: "Power", lines: lines} } +func isPoweredByAC(statusLower string) bool { + return statusLower == "charging" || + statusLower == "charged" || + statusLower == "ac" || + strings.Contains(statusLower, "ac attached") +} + +func formatBatteryStatus(status string) string { + status = strings.TrimSpace(status) + if status == "" { + return "Unknown" + } + lower := strings.ToLower(status) + switch lower { + case "ac": + return "AC" + case "charged": + return "Charged" + case "charging": + return "Charging" + case "discharging": + return "Discharging" + } + return strings.ToUpper(status[:1]) + strings.ToLower(status[1:]) +} + func renderCard(data cardData, width int, height int) string { if width <= 0 { width = colWidth @@ -651,6 +775,10 @@ func wrapToWidth(text string, width int) []string { } func progressBar(percent float64) string { + return colorizePercent(percent, plainProgressBar(percent)) +} + +func plainProgressBar(percent float64) string { total := 16 if percent < 0 { percent = 0 @@ -668,7 +796,7 @@ func progressBar(percent float64) string { builder.WriteString("░") } } - return colorizePercent(percent, builder.String()) + return builder.String() } func batteryProgressBar(percent float64) string { @@ -716,9 +844,9 @@ func colorizeBattery(percent float64, s string) string { func colorizeTemp(t float64) string { switch { - case t >= 76: + case t >= thermalHighThreshold: return dangerStyle.Render(fmt.Sprintf("%.1f", t)) - case t >= 56: + case t >= thermalNormalThreshold: return warnStyle.Render(fmt.Sprintf("%.1f", t)) default: return okStyle.Render(fmt.Sprintf("%.1f", t)) @@ -739,48 +867,15 @@ func formatRate(mb float64) string { } func humanBytes(v uint64) string { - switch { - case v > 1<<40: - return fmt.Sprintf("%.1f TB", float64(v)/(1<<40)) - case v > 1<<30: - return fmt.Sprintf("%.1f GB", float64(v)/(1<<30)) - case v > 1<<20: - return fmt.Sprintf("%.1f MB", float64(v)/(1<<20)) - case v > 1<<10: - return fmt.Sprintf("%.1f KB", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) + " B" - } + return units.BytesBin(v) } func humanBytesShort(v uint64) string { - switch { - case v >= 1<<40: - return fmt.Sprintf("%.0fT", float64(v)/(1<<40)) - case v >= 1<<30: - return fmt.Sprintf("%.0fG", float64(v)/(1<<30)) - case v >= 1<<20: - return fmt.Sprintf("%.0fM", float64(v)/(1<<20)) - case v >= 1<<10: - return fmt.Sprintf("%.0fK", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) - } + return units.BytesBinShort(v) } func humanBytesCompact(v uint64) string { - switch { - case v >= 1<<40: - return fmt.Sprintf("%.1fT", float64(v)/(1<<40)) - case v >= 1<<30: - return fmt.Sprintf("%.1fG", float64(v)/(1<<30)) - case v >= 1<<20: - return fmt.Sprintf("%.1fM", float64(v)/(1<<20)) - case v >= 1<<10: - return fmt.Sprintf("%.1fK", float64(v)/(1<<10)) - default: - return strconv.FormatUint(v, 10) - } + return units.BytesBinCompact(v) } func shorten(s string, maxLen int) string { diff --git a/Resources/mole/cmd/status/view_test.go b/Resources/mole/cmd/status/view_test.go index d49f72b..5ce7db3 100644 --- a/Resources/mole/cmd/status/view_test.go +++ b/Resources/mole/cmd/status/view_test.go @@ -164,126 +164,23 @@ func TestShorten(t *testing.T) { } } +// Core byte-format coverage lives in internal/units; these are wiring sanity +// checks to ensure the cmd/status helpers still delegate to that package. func TestHumanBytesShort(t *testing.T) { - tests := []struct { - name string - input uint64 - want string - }{ - // Zero and small values. - {"zero", 0, "0"}, - {"one byte", 1, "1"}, - {"999 bytes", 999, "999"}, - - // Kilobyte boundaries. - {"exactly 1KB", 1 << 10, "1K"}, - {"just under 1KB", (1 << 10) - 1, "1023"}, - {"1.5KB rounds to 2K", 1536, "2K"}, - {"999KB", 999 << 10, "999K"}, - - // Megabyte boundaries. - {"exactly 1MB", 1 << 20, "1M"}, - {"just under 1MB", (1 << 20) - 1, "1024K"}, - {"500MB", 500 << 20, "500M"}, - - // Gigabyte boundaries. - {"exactly 1GB", 1 << 30, "1G"}, - {"just under 1GB", (1 << 30) - 1, "1024M"}, - {"100GB", 100 << 30, "100G"}, - - // Terabyte boundaries. - {"exactly 1TB", 1 << 40, "1T"}, - {"just under 1TB", (1 << 40) - 1, "1024G"}, - {"2TB", 2 << 40, "2T"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := humanBytesShort(tt.input) - if got != tt.want { - t.Errorf("humanBytesShort(%d) = %q, want %q", tt.input, got, tt.want) - } - }) + if got := humanBytesShort(100 << 30); got != "100G" { + t.Errorf("humanBytesShort(100<<30) = %q, want %q", got, "100G") } } func TestHumanBytes(t *testing.T) { - tests := []struct { - name string - input uint64 - want string - }{ - // Zero and small values. - {"zero", 0, "0 B"}, - {"one byte", 1, "1 B"}, - {"1023 bytes", 1023, "1023 B"}, - - // Kilobyte boundaries (uses > not >=). - {"exactly 1KB", 1 << 10, "1024 B"}, - {"just over 1KB", (1 << 10) + 1, "1.0 KB"}, - {"1.5KB", 1536, "1.5 KB"}, - - // Megabyte boundaries (uses > not >=). - {"exactly 1MB", 1 << 20, "1024.0 KB"}, - {"just over 1MB", (1 << 20) + 1, "1.0 MB"}, - {"500MB", 500 << 20, "500.0 MB"}, - - // Gigabyte boundaries (uses > not >=). - {"exactly 1GB", 1 << 30, "1024.0 MB"}, - {"just over 1GB", (1 << 30) + 1, "1.0 GB"}, - {"100GB", 100 << 30, "100.0 GB"}, - - // Terabyte boundaries (uses > not >=). - {"exactly 1TB", 1 << 40, "1024.0 GB"}, - {"just over 1TB", (1 << 40) + 1, "1.0 TB"}, - {"2TB", 2 << 40, "2.0 TB"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := humanBytes(tt.input) - if got != tt.want { - t.Errorf("humanBytes(%d) = %q, want %q", tt.input, got, tt.want) - } - }) + if got := humanBytes((1 << 20) + 1); got != "1.0 MB" { + t.Errorf("humanBytes(1MB+1) = %q, want %q", got, "1.0 MB") } } func TestHumanBytesCompact(t *testing.T) { - tests := []struct { - name string - input uint64 - want string - }{ - // Zero and small values. - {"zero", 0, "0"}, - {"one byte", 1, "1"}, - {"1023 bytes", 1023, "1023"}, - - // Kilobyte boundaries (uses >= not >). - {"exactly 1KB", 1 << 10, "1.0K"}, - {"1.5KB", 1536, "1.5K"}, - - // Megabyte boundaries. - {"exactly 1MB", 1 << 20, "1.0M"}, - {"500MB", 500 << 20, "500.0M"}, - - // Gigabyte boundaries. - {"exactly 1GB", 1 << 30, "1.0G"}, - {"100GB", 100 << 30, "100.0G"}, - - // Terabyte boundaries. - {"exactly 1TB", 1 << 40, "1.0T"}, - {"2TB", 2 << 40, "2.0T"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := humanBytesCompact(tt.input) - if got != tt.want { - t.Errorf("humanBytesCompact(%d) = %q, want %q", tt.input, got, tt.want) - } - }) + if got := humanBytesCompact(1536); got != "1.5K" { + t.Errorf("humanBytesCompact(1536) = %q, want %q", got, "1.5K") } } @@ -658,6 +555,40 @@ func TestBatteryProgressBar(t *testing.T) { } } +func TestRenderBatteryCardShowsAdapterInputOnly(t *testing.T) { + card := renderBatteryCard([]BatteryStatus{{ + Percent: 80, + Status: "AC", + Capacity: 100, + CycleCount: 4, + }}, ThermalStatus{ + BatteryTemp: 30.7, + AdapterPower: 94, + }) + + var joined []string + for _, line := range card.lines { + joined = append(joined, stripANSI(line)) + } + got := strings.Join(joined, "\n") + + if !strings.Contains(got, "Input") || !strings.Contains(got, "94W max") { + t.Fatalf("expected input line with adapter max watts, got:\n%s", got) + } + if strings.Contains(got, "Draw") || strings.Contains(got, "Charge") { + t.Fatalf("expected no live draw or charge watt row, got:\n%s", got) + } + if !strings.Contains(got, "AC · 94W adapter") { + t.Fatalf("expected AC adapter status, got:\n%s", got) + } + if strings.Contains(got, "Ac") { + t.Fatalf("expected AC to stay uppercase, got:\n%s", got) + } + if strings.Contains(got, "⚡") { + t.Fatalf("expected no charging glyph, got:\n%s", got) + } +} + func TestColorizeTemp(t *testing.T) { tests := []struct { name string @@ -665,12 +596,12 @@ func TestColorizeTemp(t *testing.T) { }{ {"very low", 20.0}, {"low", 40.0}, - {"normal threshold", 55.9}, - {"at warn threshold", 56.0}, - {"warn range", 65.0}, - {"just below danger", 75.9}, - {"at danger threshold", 76.0}, - {"high", 85.0}, + {"normal range", 55.0}, + {"at warn threshold", 65.0}, + {"warn range", 75.0}, + {"just below danger", 84.9}, + {"at danger threshold", 85.0}, + {"high", 92.0}, {"very high", 95.0}, } @@ -749,29 +680,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 +740,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) + } }) } } @@ -936,6 +966,99 @@ func TestRenderHeaderErrorReturnsMoleOnce(t *testing.T) { } } +func TestStatusDiagnosisLineUsesTopCPUProcess(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 95}, + TopProcesses: []ProcessInfo{ + {Name: "Safari", CPU: 12}, + {Name: "Xcode", CPU: 82}, + }, + } + + got := statusDiagnosisLine(m) + if got != "Xcode high CPU" { + t.Fatalf("statusDiagnosisLine() = %q, want top CPU process", got) + } +} + +func TestStatusDiagnosisLineUsesMemoryContributorWhenCPUIsCalm(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 20}, + Memory: MemoryStatus{ + UsedPercent: 86, + Pressure: "warn", + }, + TopProcesses: []ProcessInfo{ + {Name: "Chrome", Memory: 31}, + {Name: "Finder", Memory: 2}, + }, + } + + got := statusDiagnosisLine(m) + if got != "Chrome memory pressure" { + t.Fatalf("statusDiagnosisLine() = %q, want memory contributor", got) + } +} + +func TestStatusDiagnosisLineFallsBackToAllClear(t *testing.T) { + m := MetricsSnapshot{ + CPU: CPUStatus{Usage: 10}, + Memory: MemoryStatus{UsedPercent: 20, Pressure: "normal"}, + HealthScoreMsg: "Excellent", + } + + got := statusDiagnosisLine(m) + if got != "All clear" { + t.Fatalf("statusDiagnosisLine() = %q, want All clear", got) + } +} + +func TestRenderProcessCardAddsInlineHintWithoutExtraRows(t *testing.T) { + card := renderProcessCard([]ProcessInfo{ + {Name: "Chrome", CPU: 12, Memory: 22, MemoryBytes: 2 * 1024 * 1024 * 1024}, + {Name: "Xcode", CPU: 95, Memory: 8, MemoryBytes: 512 * 1024 * 1024}, + }) + + if len(card.lines) != 2 { + t.Fatalf("renderProcessCard() lines = %d, want 2", len(card.lines)) + } + plain := stripANSI(strings.Join(card.lines, "\n")) + if !strings.Contains(plain, "2.0G") { + t.Fatalf("renderProcessCard() missing resident memory hint, got %q", plain) + } + if !strings.Contains(plain, "hot") { + t.Fatalf("renderProcessCard() missing cpu hint, got %q", plain) + } +} + +func TestRenderProcessCardFallsBackToMemoryPercent(t *testing.T) { + card := renderProcessCard([]ProcessInfo{ + {Name: "Chrome", CPU: 12, Memory: 22}, + }) + + plain := stripANSI(strings.Join(card.lines, "\n")) + if !strings.Contains(plain, "M22%") { + t.Fatalf("renderProcessCard() missing memory percent fallback, got %q", plain) + } +} + +func TestRenderHeaderUsesFastMetricSpecFallbacks(t *testing.T) { + const ram = uint64(16 * 1024 * 1024 * 1024) + const diskSize = uint64(512 * 1024 * 1024 * 1024) + m := MetricsSnapshot{ + HealthScore: 90, + Memory: MemoryStatus{Total: ram}, + Disks: []DiskStatus{{Mount: "/", Total: diskSize}}, + } + + header, _ := renderHeader(m, "", 0, 120, true) + plain := stripANSI(header) + want := humanBytes(ram) + "/" + humanBytes(diskSize) + if !strings.Contains(plain, want) { + t.Fatalf("renderHeader() should use fast metric specs %q, got %q", want, plain) + } +} + func TestRenderHeaderWrapsOnNarrowWidth(t *testing.T) { m := MetricsSnapshot{ HealthScore: 91, @@ -1032,6 +1155,7 @@ func TestRenderMemoryCardHidesSwapSizeOnNarrowWidth(t *testing.T) { card := renderMemoryCard(MemoryStatus{ Used: 8 << 30, Total: 16 << 30, + Available: 8 << 30, UsedPercent: 50.0, SwapUsed: 482, SwapTotal: 1000, @@ -1051,6 +1175,7 @@ func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) { card := renderMemoryCard(MemoryStatus{ Used: 8 << 30, Total: 16 << 30, + Available: 8 << 30, UsedPercent: 50.0, SwapUsed: 482, SwapTotal: 1000, @@ -1066,6 +1191,54 @@ func TestRenderMemoryCardShowsSwapSizeOnWideWidth(t *testing.T) { } } +func TestRenderMemoryCardUsesCollectedAvailableMemory(t *testing.T) { + card := renderMemoryCard(MemoryStatus{ + Used: 12 << 30, + Total: 16 << 30, + Available: 9 << 30, + UsedPercent: 75.0, + }, 60) + + plain := stripANSI(strings.Join(card.lines, "\n")) + if !strings.Contains(plain, "Free") || !strings.Contains(plain, "56.2%") { + t.Fatalf("renderMemoryCard() should derive free percent from Available, got %q", plain) + } + if !strings.Contains(plain, "Avail 9.0 GB") { + t.Fatalf("renderMemoryCard() should render collected Available memory, got %q", plain) + } +} + +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 +1275,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/docs/SECURITY_DESIGN.md b/Resources/mole/docs/SECURITY_DESIGN.md new file mode 100644 index 0000000..3a0dfdb --- /dev/null +++ b/Resources/mole/docs/SECURITY_DESIGN.md @@ -0,0 +1,229 @@ +# Mole Security Design + +This document describes the safety mechanisms that prevent mole from +destroying data it shouldn't. It is written for reviewers, contributors, +and anyone evaluating mole for production use. + +The corresponding implementation lives in `lib/core/file_ops.sh`, +`lib/core/app_protection.sh`, and `lib/core/app_protection_data.sh`. Path +validation has machine-checked fuzz tests in `cmd/analyze/delete_fuzz_test.go` +and `tests/path_validation_fuzz.bats`. + +--- + +## Threat model + +Mole is a user-invoked CLI that performs three classes of destructive +operations on the local machine: + +1. **Cleanup** — remove caches, logs, and temp data the OS or apps regenerate. +2. **Uninstall** — remove an app bundle and its data directories. +3. **Trash routing** — move user-selected files in `mo analyze` to Trash. + +We assume: +- The invoking user has shell access and runs mole intentionally. +- The user is **not** trying to attack their own machine. +- The user **does** make mistakes (typo a path, click wrong menu, run + cleanup with stale config). +- Third-party apps writing into `~/Library` may have arbitrary names and + may not follow Apple naming conventions. + +We are **not** defending against: +- A user who runs `sudo mole` with malicious flags they typed in deliberately. +- A compromised macOS host where SIP is disabled and `/System` is writable. +- Supply-chain compromise of the mole binary itself (covered separately + by signed releases + SHA256SUMS attestations in `release.yml`). + +The lines we will not cross, regardless of input: +- Never delete a path inside `/System`, `/bin`, `/sbin`, `/usr`, `/etc`, + `/Library/Extensions`, or `/var/db` (system databases). +- Never delete a path that resolves (after symlink chasing) into one of + the above. +- Never uninstall a `com.apple.*` system app, except the explicit list + of App Store / developer-portal Apple apps that users actually buy + (Xcode, Final Cut Pro, Logic, GarageBand, iWork, MainStage, etc.). + +--- + +## Layer 1: `validate_path_for_deletion` + +Every removal in mole funnels through `mole_delete` / +`safe_remove` / `safe_sudo_remove`, which all call +`validate_path_for_deletion` before touching the filesystem. The validator +applies five independent checks. Any one rejecting kills the operation. + +Location: `lib/core/file_ops.sh:67`. + +1. **Non-empty + absolute.** Empty paths and any path not starting with + `/` are rejected. Eliminates ambiguity from relative paths interacting + with caller `$PWD`. + +2. **Symlink resolution.** If the path is itself a symlink, the validator + reads the link target, resolves it to an absolute path, and re-checks + the target against the protected-path list. Prevents an attacker (or + an accidental config bug) from pointing `/tmp/foo` at `/System` and + getting the validator to wave it through. + +3. **Path traversal.** `..` is rejected only when it appears as a full + path component (`/foo/../bar`, `/..`, `../bar`, `foo/..`). This is + tighter than naive substring matching: it allows legitimate names + like Firefox's `name..files` directory while still blocking + `/Users/me/Library/../../etc`. + +4. **Control characters.** Any path containing `\n`, `\t`, or other + `[[:cntrl:]]` bytes is rejected. Defends against log-injection and + surprising-shell-interpretation scenarios. + +5. **Allow-then-deny match.** + - First, explicit allow-list for known-safe subtrees under `/private` + (`/private/tmp`, `/private/var/log`, `/private/var/folders`, + `/private/var/db/diagnostics`, etc.) and `/System/Library/Caches/com.apple.coresymbolicationd/data` (rebuildable). + - Then, deny-list for `/`, `/bin*`, `/sbin*`, `/usr*`, `/System*`, + `/Library/Extensions*`, `/etc*`, `/var/db*`, `/private`, and + `/private/etc*`. + - Finally, calls `should_protect_path` (Layer 2) for fine-grained + bundle / app / data protection. + +The allow-then-deny ordering matters: rebuildable system caches we +*want* to clean live under paths we'd otherwise block. Listing them +first means a maintainer adding a new safe path doesn't have to surgically +weaken the deny rules. + +--- + +## Layer 2: `# SAFE: ` contract for raw `rm` + +The validator is opt-in: a contributor could bypass it by writing `rm -rf` +directly. To make that bypass loud and reviewable, the CI security job in +`.github/workflows/test.yml` greps for `rm -rf` outside known safe +wrappers and requires an explicit annotation: + +```bash +rm -rf "$temp_file" # SAFE: created by mktemp in this function, never user input +``` + +The CI rule rejects any `rm -rf` that is not either: +- Inside `safe_remove` / `safe_sudo_remove` (the validated wrappers), or +- A pure documentation line (comment-only or echoed help text), or +- Annotated with `# SAFE: `. + +Every annotated bypass in the codebase currently has a reason that +constrains the input: confined to `$temp_file` from `mktemp`, confined +to a stub container we just created, confined to `tests/tmp-*` from a +test runner. The annotation forces the author to articulate the constraint +before the code can land. + +See `lib/clean/apps.sh:848`, `lib/core/base.sh:750`, `scripts/test.sh` +(orphan-tmp cleanup) for current uses. + +--- + +## Layer 3: App protection — split fast vs. detailed lists + +Uninstall and per-app cleanup decisions go through +`should_protect_from_uninstall` and `should_protect_data` in +`lib/core/app_protection.sh`. They consult two data sources, both kept +in `lib/core/app_protection_data.sh`: + +| List | Used by | Shape | Purpose | +|---|---|---|---| +| `SYSTEM_CRITICAL_BUNDLES_FAST` | Cleanup paths (`should_protect_data`) | Wildcard patterns | Fast `com.apple.*` and family-pattern guards. Misses are acceptable here; cleanup of an unknown system component just means leftover files, not deletion of a live app. | +| `SYSTEM_CRITICAL_BUNDLES` | Uninstall (`should_protect_from_uninstall`) | Explicit bundle IDs | Detailed list of every `/System/Applications` and Apple system service. Must be exhaustive: a miss here would let a user uninstall Finder. | +| `APPLE_UNINSTALLABLE_APPS` | Uninstall | Explicit bundle IDs | Allow-list of Apple-developed apps the user actually installed (Xcode, FCP, Logic, etc.). Required because `com.apple.*` cannot be a blanket block. | +| `DATA_PROTECTED_BUNDLES` | Cleanup (`should_protect_data`) | Wildcard patterns | Third-party apps with sensitive state (1Password, JetBrains, IM tools, VPNs, etc.) whose caches must not be touched. | + +The deliberate redundancy between FAST and CRITICAL is **not** a bug: +- FAST is a wildcard fast-path used in tight loops during cleanup, where + a `com.apple.*` blanket is correct. +- CRITICAL is the detailed allow-list used at uninstall time, where the + blanket is wrong (it would block Xcode uninstall) so individual bundles + must be enumerated. + +### Keeping the lists honest + +A new macOS major release can ship new system apps and daemons. The +monthly `.github/workflows/bundle_audit.yml` job runs +`scripts/audit_bundle_drift.sh` against the latest `macos-latest` +runner. The script enumerates every `.app` under `/System/Applications`, +computes its `CFBundleIdentifier`, and reports any bundle ID not matched +by FAST + CRITICAL + DATA_PROTECTED. Any miss opens a tracking issue. + +Each macOS major release should also trigger the +`macos-release-review` issue template +(`.github/ISSUE_TEMPLATE/macos-release-review.yml`), which forces a +human checklist over: bundle drift, mdls timeout regression, SIP path +changes, and CI matrix updates. + +--- + +## Layer 4: Trash routing default + +`mo analyze` and `mo clean`'s ad-hoc paths route deletions to the macOS +Trash via Finder AppleScript (`cmd/analyze/delete.go:124`). This gives +users the standard Apple-native "Put Back" recovery flow. Permanent +deletion requires explicit `--permanent` or going through `mo clean`'s +batched cleanup path. + +The `osascript` call uses a 30-second timeout (`trashTimeout`) so a +hung Finder can't wedge the binary, and escapes both `\\` and `"` in +the path before substituting into the AppleScript literal. Defense in +depth: `validatePath` is also called before `osascript`, so even if +escape logic missed a case, a path containing `..` or null bytes is +rejected before it reaches Finder. + +--- + +## Layer 5: Test mode + dry run + property tests + +Three orthogonal mechanisms make the safety claims testable and +prevent live-machine test runs from doing real damage: + +- `MOLE_DRY_RUN=1` — every safe-remove logs what it would do and + returns 0 without touching the filesystem. Used in CI for the + no-mock path coverage and recommended before any local cleanup. +- `MOLE_TEST_NO_AUTH=1` — refuses to call `sudo`, `osascript`, + `launchctl`, or any path that would prompt the user. Required for + bats and the integration tests. Enforced by `scripts/test.sh` PATH + stubs that fail loudly when called. +- `tests/path_validation_fuzz.bats` and `cmd/analyze/delete_fuzz_test.go` + harden the validators. The bats test asserts that every line in + `tests/fuzz_corpus/dangerous_paths.txt` (79 adversarial paths today) + is rejected. The Go fuzz target runs its seed corpus during normal + `go test`; maintainers can run `go test -fuzz=FuzzValidatePath ./cmd/analyze` + when changing path validation. It asserts the invariant: + anything accepted must be absolute, free of null bytes, and free of + `..` components. + +If you add a new way to bypass these layers, you are expected to add +a corresponding test that fails before your code lands. + +--- + +## What this design intentionally does not do + +- **No code signing of the cleanup config.** We rely on filesystem + permissions to protect the protection lists from tampering. If a + user can edit `lib/core/app_protection_data.sh` they can already + edit `mole` itself; the threat model says we don't defend that. +- **No anti-rollback.** A user who restores an old mole binary or + installs a forked build with weaker lists gets weaker protection. + We address this through release signing, not runtime checks. +- **No protection for arbitrary user paths.** `~/Documents/important` + has no special status. The user is responsible for selecting safe + cleanup targets; mole only guarantees system integrity. +- **No telemetry.** We never report what was scanned, deleted, or + attempted. Mistakes are diagnosed locally via `~/.cache/mole/` + operation logs (path is `MOLE_OPLOG_PATH` overridable; + `MO_NO_OPLOG=1` disables entirely). + +--- + +## When to update this document + +- A new layer (e.g., a notarization check, a per-volume policy) is added. +- The validator gains a new check class or relaxes an existing one. +- A new app protection list is introduced. +- An incident occurred where one of the layers failed and the writeup + belongs in the "lessons" section here, not just the commit log. + +Last reviewed: 2026-05-21 (mole V1.39.0). diff --git a/Resources/mole/docs/release-notes/V1.42.0.md b/Resources/mole/docs/release-notes/V1.42.0.md new file mode 100644 index 0000000..2fae03a --- /dev/null +++ b/Resources/mole/docs/release-notes/V1.42.0.md @@ -0,0 +1,35 @@ +
+ Mole Logo +

Mole

+

Deep clean and optimize your Mac.

+
+ +### Changelog + +1. **mo clean**: Project artifacts scan now finishes on huge workspaces and shows partial results when slow scans time out +2. **mo clean**: Typing a sudo password no longer gets treated as skipping sudo +3. **mo clean**: App leftovers can remove dead macOS app records from deleted apps and unmounted DMGs without deleting files +4. **mo clean**: Developer cleanup finds more rebuildable caches, including VS Code WebStorage, QQ, LM Studio, Folo, SenPlayer, Xcode simulator caches, and XCTest test data +5. **mo analyze**: Cached folders keep their results visible while Mole refreshes in the background +6. **mo update**: Nightly updates skip reinstalling the same build, and `mo status` now matches the Mac App health signals more closely +7. **mo uninstall**: Related-file cleanup is stricter around system items, app helpers, symlinks, and container caches + +### 更新日志 + +1. **mo clean**:Project artifacts 在超大工作区里不会再卡住,慢扫描超时后仍会展示已找到的结果 +2. **mo clean**:在 sudo 提示里输入密码不会再被误判成跳过 sudo +3. **mo clean**:App leftovers 可以清理已删除应用、已卸载 DMG 留下的失效应用记录,且不会删除文件 +4. **mo clean**:Developer cleanup 能发现更多可再生成缓存,包括 VS Code WebStorage、QQ、LM Studio、Folo、SenPlayer、Xcode 模拟器缓存和 XCTest 测试数据 +5. **mo analyze**:打开已有缓存的文件夹时,后台刷新期间会继续显示已有结果 +6. **mo update**:Nightly 更新不再重复安装同一个版本,`mo status` 也更接近 Mac App 的健康状态判断 +7. **mo uninstall**:相关文件清理在系统项、应用 helper、symlink 和 container cache 上更谨慎 + +### Mole Mac App + +Prefer a GUI? Try [Mole Mac App](https://mole.fit), the CLI stays free and open source + +### Thanks 💖 + +Issue reporters and PR contributors this cycle: @QasimXAli · @thedavidweng · @iammike2 · @wottpal · @seepine · @Saafo · @wupeter0001 · @sebastianbreguel · @Puhavik + +> https://github.com/tw93/Mole diff --git a/Resources/mole/go.mod b/Resources/mole/go.mod index 153fbad..3060474 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.5 + golang.org/x/sync v0.21.0 ) require ( diff --git a/Resources/mole/go.sum b/Resources/mole/go.sum index 66dea7c..ce833b6 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.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.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..373b459 100755 --- a/Resources/mole/install.sh +++ b/Resources/mole/install.sh @@ -5,11 +5,20 @@ set -euo pipefail -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' +# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes. +if [[ -n "${NO_COLOR:-}" ]]; then + GREEN='' + BLUE='' + YELLOW='' + RED='' + NC='' +else + GREEN='\033[0;32m' + BLUE='\033[0;34m' + YELLOW='\033[1;33m' + RED='\033[0;31m' + NC='\033[0m' +fi _SPINNER_PID="" start_line_spinner() { @@ -19,6 +28,7 @@ start_line_spinner() { return } local chars="|/-\\" + # shellcheck disable=SC1003 [[ -z "$chars" ]] && chars='|/-\\' local i=0 (while true; do @@ -106,6 +116,10 @@ needs_sudo() { maybe_sudo() { if needs_sudo; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_error "Admin access required, blocked in test mode" + return 1 + fi sudo "$@" else "$@" @@ -264,6 +278,121 @@ normalize_release_tag() { fi } +release_checksums_url() { + local tag="$1" + printf 'https://github.com/tw93/mole/releases/download/%s/SHA256SUMS\n' "$tag" +} + +download_release_checksums() { + local tag="$1" + local output_file="$2" + local url + url="$(release_checksums_url "$tag")" + + curl -fsSL --connect-timeout 10 --max-time 60 -o "$output_file" "$url" +} + +# Verify the Sigstore/GitHub Actions build-provenance attestation for a release +# asset. Returns: +# 0 - attestation verified +# 1 - verification failed (asset has no matching attestation, or signature invalid) +# 2 - cannot verify (gh CLI missing or unauthenticated); caller decides policy +# +# The release workflow generates attestations via actions/attest-build-provenance +# covering SHA256SUMS, the per-arch binaries, and the homebrew tarballs. +# Verifying the SHA256SUMS file is sufficient: the binary's sha256 is then +# anchored to that attested file by verify_release_asset_checksum(). +verify_release_attestation() { + local file="$1" + + if ! command -v gh > /dev/null 2>&1; then + return 2 + fi + if ! gh auth status > /dev/null 2>&1; then + return 2 + fi + + # --owner restricts the trusted signer identity to the upstream repo's + # GitHub Actions workflow. --deny-self-hosted-runners blocks attestations + # produced by self-hosted runners, which a repo compromise could otherwise + # introduce as a sidechannel. + if gh attestation verify "$file" \ + --owner tw93 \ + --deny-self-hosted-runners \ + > /dev/null 2>&1; then + return 0 + fi + return 1 +} + +extract_release_checksum() { + local checksums_file="$1" + local asset_name="$2" + + awk -v asset="$asset_name" '$2 == asset { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$checksums_file" +} + +calculate_file_sha256() { + local file="$1" + + if command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1; exit}' + return + fi + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$file" | awk '{print $1; exit}' + return + fi + + return 1 +} + +verify_release_asset_checksum() { + local tag="$1" + local asset_name="$2" + local file="$3" + local checksums_file + checksums_file="$(mktemp "${TMPDIR:-/tmp}/mole-checksums.XXXXXX")" || return 1 + + local expected="" + local actual="" + local result=1 + local attestation_status=2 + + if download_release_checksums "$tag" "$checksums_file" > /dev/null 2>&1; then + # Anchor the SHA256SUMS file to its GitHub Actions build-provenance + # attestation before reading checksums from it. If gh is available, + # an attestation mismatch is fatal; without gh, fall through to + # checksum-only verification (matches prior behavior). + verify_release_attestation "$checksums_file" + attestation_status=$? + + if [[ "$attestation_status" -eq 1 ]]; then + log_error "Release attestation verification failed for ${asset_name}" + rm -f "$checksums_file" + return 1 + fi + + if [[ "$attestation_status" -eq 2 && "${MOLE_REQUIRE_ATTESTATION:-0}" == "1" ]]; then + log_error "MOLE_REQUIRE_ATTESTATION=1 set but gh CLI unavailable or unauthenticated" + rm -f "$checksums_file" + return 1 + fi + + expected=$(extract_release_checksum "$checksums_file" "$asset_name" 2> /dev/null || true) + actual=$(calculate_file_sha256 "$file" 2> /dev/null || true) + if [[ -n "$expected" && -n "$actual" && "$expected" == "$actual" ]]; then + result=0 + if [[ "$attestation_status" -eq 0 ]]; then + log_success "Verified ${asset_name} (sha256 + attestation)" + fi + fi + fi + + rm -f "$checksums_file" + return "$result" +} + get_installed_version() { local binary="$INSTALL_DIR/mole" if [[ -x "$binary" ]]; then @@ -302,11 +431,18 @@ 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 + # Use a plain if/fi so the block's exit code reflects only I/O failure. + # The previous form `[[ -n "$h" ]] && printf ...` returned 1 whenever the + # commit hash was empty (the stable channel always omits it), which made + # the redirect look like it had failed and tripped the warning. { printf 'CHANNEL=%s\n' "$channel" - [[ -n "$commit_hash" ]] && printf 'COMMIT_HASH=%s\n' "$commit_hash" + if [[ -n "$commit_hash" ]]; then + printf 'COMMIT_HASH=%s\n' "$commit_hash" + fi } > "$tmp_file" || { rm -f "$tmp_file" 2> /dev/null || true return 1 @@ -545,7 +681,10 @@ download_binary() { fi return 1 fi - local url="https://github.com/tw93/mole/releases/download/V${version}/${binary_name}-darwin-${arch_suffix}" + local release_tag + release_tag="$(normalize_release_tag "$version")" + local asset_name="${binary_name}-darwin-${arch_suffix}" + local url="https://github.com/tw93/mole/releases/download/${release_tag}/${asset_name}" # Skip preflight network checks to avoid false negatives. @@ -557,18 +696,51 @@ download_binary() { if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then if [[ -t 1 ]]; then stop_line_spinner; fi - chmod +x "$target_path" - xattr -c "$target_path" 2> /dev/null || true - log_success "Downloaded ${binary_name} binary" - else - if [[ -t 1 ]]; then stop_line_spinner; fi - log_warning "Could not download ${binary_name} binary, v${version}, trying local build" + if verify_release_asset_checksum "$release_tag" "$asset_name" "$target_path"; then + chmod +x "$target_path" + xattr -c "$target_path" 2> /dev/null || true + log_success "Downloaded ${binary_name} binary" + return 0 + fi + rm -f "$target_path" + log_warning "Checksum verification failed for ${binary_name}, trying local build" if build_binary_from_source "$binary_name" "$target_path"; then return 0 fi - log_error "Failed to install ${binary_name} binary" + log_error "Failed to install verified ${binary_name} binary" return 1 fi + if [[ -t 1 ]]; then stop_line_spinner; fi + + local fallback_tag + fallback_tag=$(get_latest_release_tag 2> /dev/null || true) + if [[ -n "$fallback_tag" && "$fallback_tag" != "$release_tag" ]]; then + local fallback_url="https://github.com/tw93/mole/releases/download/${fallback_tag}/${asset_name}" + if [[ -t 1 ]]; then + start_line_spinner "Retrying ${binary_name} from ${fallback_tag}..." + else + echo "Retrying ${binary_name} from ${fallback_tag}..." + fi + if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$fallback_url"; then + if [[ -t 1 ]]; then stop_line_spinner; fi + if verify_release_asset_checksum "$fallback_tag" "$asset_name" "$target_path"; then + chmod +x "$target_path" + xattr -c "$target_path" 2> /dev/null || true + log_success "Downloaded ${binary_name} from ${fallback_tag} (v${version} not yet published)" + return 0 + fi + rm -f "$target_path" + log_warning "Checksum verification failed for ${binary_name} from ${fallback_tag}" + fi + if [[ -t 1 ]]; then stop_line_spinner; fi + fi + + log_warning "Could not download ${binary_name} binary, v${version}, trying local build" + if build_binary_from_source "$binary_name" "$target_path"; then + return 0 + fi + log_error "Failed to install ${binary_name} binary" + return 1 } # File installation (bin/lib/scripts + go helpers). @@ -587,6 +759,11 @@ install_files() { if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then if needs_sudo; then log_admin "Admin access required for /usr/local/bin" + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_error "Admin access required, blocked in test mode" + return 1 + fi + sudo -v fi # Atomic update: copy to temporary name first, then move @@ -656,7 +833,9 @@ install_files() { fi if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then - maybe_sudo sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" + # Use absolute /usr/bin/sed (always BSD on macOS) so PATH-shadowed + # GNU sed from Homebrew gnu-sed does not break the -i '' syntax. + maybe_sudo /usr/bin/sed -i '' "s|SCRIPT_DIR=.*|SCRIPT_DIR=\"$CONFIG_DIR\"|" "$INSTALL_DIR/mole" fi if ! download_binary "analyze"; then diff --git a/Resources/mole/internal/units/bytes.go b/Resources/mole/internal/units/bytes.go new file mode 100644 index 0000000..eaf7b45 --- /dev/null +++ b/Resources/mole/internal/units/bytes.go @@ -0,0 +1,87 @@ +// Package units centralizes byte-size formatting helpers shared by the analyze +// and status commands. +// +// The two callers intentionally use different conventions: analyze formats +// disk-related figures with SI (1000-based) units to match Finder/diskutil, +// while status reports memory and live counters with binary (1024-based) +// units to match macOS Activity Monitor and gopsutil. Both styles live here so +// that any future tweak (precision, rounding, label set) stays in one place. +package units + +import ( + "fmt" + "strconv" +) + +// BytesSI formats a signed byte count using SI (1000-based) units, matching +// Finder/diskutil. Negative inputs are clamped to "0 B". +func BytesSI(size int64) string { + if size < 0 { + return "0 B" + } + const unit = 1000 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + value := float64(size) / float64(div) + return fmt.Sprintf("%.1f %cB", value, "kMGTPE"[exp]) +} + +// BytesBin formats an unsigned byte count using binary (1024-based) units with +// a trailing space and unit label (e.g. "1.0 GB"). Boundary uses '>' so values +// at exactly 1< "1024 B"). +func BytesBin(v uint64) string { + switch { + case v > 1<<40: + return fmt.Sprintf("%.1f TB", float64(v)/(1<<40)) + case v > 1<<30: + return fmt.Sprintf("%.1f GB", float64(v)/(1<<30)) + case v > 1<<20: + return fmt.Sprintf("%.1f MB", float64(v)/(1<<20)) + case v > 1<<10: + return fmt.Sprintf("%.1f KB", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + " B" + } +} + +// BytesBinShort formats an unsigned byte count using binary units, no decimal +// places, single-letter suffix and no space (e.g. "100G"). Boundary uses '>=' +// so values at exactly 1<= 1<<40: + return fmt.Sprintf("%.0fT", float64(v)/(1<<40)) + case v >= 1<<30: + return fmt.Sprintf("%.0fG", float64(v)/(1<<30)) + case v >= 1<<20: + return fmt.Sprintf("%.0fM", float64(v)/(1<<20)) + case v >= 1<<10: + return fmt.Sprintf("%.0fK", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + } +} + +// BytesBinCompact formats an unsigned byte count using binary units, one +// decimal place, single-letter suffix and no space (e.g. "1.5G"). Boundary +// uses '>=' to mirror BytesBinShort. +func BytesBinCompact(v uint64) string { + switch { + case v >= 1<<40: + return fmt.Sprintf("%.1fT", float64(v)/(1<<40)) + case v >= 1<<30: + return fmt.Sprintf("%.1fG", float64(v)/(1<<30)) + case v >= 1<<20: + return fmt.Sprintf("%.1fM", float64(v)/(1<<20)) + case v >= 1<<10: + return fmt.Sprintf("%.1fK", float64(v)/(1<<10)) + default: + return strconv.FormatUint(v, 10) + } +} diff --git a/Resources/mole/internal/units/bytes_test.go b/Resources/mole/internal/units/bytes_test.go new file mode 100644 index 0000000..f56d86f --- /dev/null +++ b/Resources/mole/internal/units/bytes_test.go @@ -0,0 +1,138 @@ +package units + +import "testing" + +func TestBytesSI(t *testing.T) { + tests := []struct { + input int64 + want string + }{ + {-100, "0 B"}, + {0, "0 B"}, + {512, "512 B"}, + {999, "999 B"}, + {1000, "1.0 kB"}, + {1500, "1.5 kB"}, + {10000, "10.0 kB"}, + {1000000, "1.0 MB"}, + {1500000, "1.5 MB"}, + {1000000000, "1.0 GB"}, + {1000000000000, "1.0 TB"}, + {1000000000000000, "1.0 PB"}, + } + + for _, tt := range tests { + got := BytesSI(tt.input) + if got != tt.want { + t.Errorf("BytesSI(%d) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestBytesBin(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + {"zero", 0, "0 B"}, + {"one byte", 1, "1 B"}, + {"1023 bytes", 1023, "1023 B"}, + + {"exactly 1KB", 1 << 10, "1024 B"}, + {"just over 1KB", (1 << 10) + 1, "1.0 KB"}, + {"1.5KB", 1536, "1.5 KB"}, + + {"exactly 1MB", 1 << 20, "1024.0 KB"}, + {"just over 1MB", (1 << 20) + 1, "1.0 MB"}, + {"500MB", 500 << 20, "500.0 MB"}, + + {"exactly 1GB", 1 << 30, "1024.0 MB"}, + {"just over 1GB", (1 << 30) + 1, "1.0 GB"}, + {"100GB", 100 << 30, "100.0 GB"}, + + {"exactly 1TB", 1 << 40, "1024.0 GB"}, + {"just over 1TB", (1 << 40) + 1, "1.0 TB"}, + {"2TB", 2 << 40, "2.0 TB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BytesBin(tt.input) + if got != tt.want { + t.Errorf("BytesBin(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBytesBinShort(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + {"zero", 0, "0"}, + {"one byte", 1, "1"}, + {"999 bytes", 999, "999"}, + + {"exactly 1KB", 1 << 10, "1K"}, + {"just under 1KB", (1 << 10) - 1, "1023"}, + {"1.5KB rounds to 2K", 1536, "2K"}, + {"999KB", 999 << 10, "999K"}, + + {"exactly 1MB", 1 << 20, "1M"}, + {"just under 1MB", (1 << 20) - 1, "1024K"}, + {"500MB", 500 << 20, "500M"}, + + {"exactly 1GB", 1 << 30, "1G"}, + {"just under 1GB", (1 << 30) - 1, "1024M"}, + {"100GB", 100 << 30, "100G"}, + + {"exactly 1TB", 1 << 40, "1T"}, + {"just under 1TB", (1 << 40) - 1, "1024G"}, + {"2TB", 2 << 40, "2T"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BytesBinShort(tt.input) + if got != tt.want { + t.Errorf("BytesBinShort(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBytesBinCompact(t *testing.T) { + tests := []struct { + name string + input uint64 + want string + }{ + {"zero", 0, "0"}, + {"one byte", 1, "1"}, + {"1023 bytes", 1023, "1023"}, + + {"exactly 1KB", 1 << 10, "1.0K"}, + {"1.5KB", 1536, "1.5K"}, + + {"exactly 1MB", 1 << 20, "1.0M"}, + {"500MB", 500 << 20, "500.0M"}, + + {"exactly 1GB", 1 << 30, "1.0G"}, + {"100GB", 100 << 30, "100.0G"}, + + {"exactly 1TB", 1 << 40, "1.0T"}, + {"2TB", 2 << 40, "2.0T"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BytesBinCompact(tt.input) + if got != tt.want { + t.Errorf("BytesBinCompact(%d) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/Resources/mole/lib/check/all.sh b/Resources/mole/lib/check/all.sh deleted file mode 100644 index 4a6960a..0000000 --- a/Resources/mole/lib/check/all.sh +++ /dev/null @@ -1,714 +0,0 @@ -#!/bin/bash -# System Checks Module -# Combines configuration, security, updates, and health checks - -set -euo pipefail - -# ============================================================================ -# Helper Functions -# ============================================================================ - -list_login_items() { - if ! command -v osascript > /dev/null 2>&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 - - IFS=',' read -ra login_items_array <<< "$raw_items" - for entry in "${login_items_array[@]}"; do - local trimmed - trimmed=$(echo "$entry" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') - [[ -n "$trimmed" ]] && printf "%s\n" "$trimmed" - done -} - -# ============================================================================ -# Configuration Checks -# ============================================================================ - -check_touchid_sudo() { - # Check whitelist - 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 - echo -e " ${GREEN}✓${NC} Touch ID Biometric authentication enabled" - else - # Check if Touch ID is supported - local is_supported=false - if command -v bioutil > /dev/null 2>&1; then - if bioutil -r 2> /dev/null | grep -q "Touch ID"; then - is_supported=true - fi - elif [[ "$(uname -m)" == "arm64" ]]; then - is_supported=true - fi - - if [[ "$is_supported" == "true" ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Touch ID ${YELLOW}Not configured for sudo${NC}" - export TOUCHID_NOT_CONFIGURED=true - fi - fi -} - -check_rosetta() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_rosetta"; then return; fi - # Check Rosetta 2 (for Apple Silicon Macs) - informational only, not auto-fixed - if [[ "$(uname -m)" == "arm64" ]]; then - if [[ -f "/Library/Apple/usr/share/rosetta/rosetta" ]]; then - echo -e " ${GREEN}✓${NC} Rosetta 2 Intel app translation ready" - else - echo -e " ${GRAY}${ICON_EMPTY}${NC} Rosetta 2 ${GRAY}Not installed${NC}" - fi - fi -} - -check_git_config() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_git_config"; then return; fi - # Check basic Git configuration - if command -v git > /dev/null 2>&1; then - local git_name=$(git config --global user.name 2> /dev/null || echo "") - local git_email=$(git config --global user.email 2> /dev/null || echo "") - - if [[ -n "$git_name" && -n "$git_email" ]]; then - echo -e " ${GREEN}✓${NC} Git Global identity configured" - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Git ${YELLOW}User identity not set${NC}" - fi - fi -} - -check_all_config() { - echo -e "${BLUE}${ICON_ARROW}${NC} System Configuration" - check_touchid_sudo - check_rosetta - check_git_config -} - -# ============================================================================ -# Security Checks -# ============================================================================ - -check_filevault() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_filevault"; then return; fi - # Check FileVault encryption status - if command -v fdesetup > /dev/null 2>&1; then - local fv_status=$(fdesetup status 2> /dev/null || echo "") - if echo "$fv_status" | grep -q "FileVault is On"; then - echo -e " ${GREEN}✓${NC} FileVault Disk encryption active" - else - echo -e " ${RED}✗${NC} FileVault ${RED}Disk encryption disabled${NC}" - export FILEVAULT_DISABLED=true - fi - fi -} - -check_firewall() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "firewall"; then return; fi - - unset FIREWALL_DISABLED - - # Check third-party firewalls first (lightweight path-based detection, no sudo required) - local third_party_firewall="" - if [[ -d "/Applications/Little Snitch.app" ]] || [[ -d "/Library/Little Snitch" ]]; then - third_party_firewall="Little Snitch" - elif [[ -d "/Applications/LuLu.app" ]]; then - third_party_firewall="LuLu" - elif [[ -d "/Applications/Radio Silence.app" ]]; then - third_party_firewall="Radio Silence" - elif [[ -d "/Applications/Hands Off!.app" ]]; then - third_party_firewall="Hands Off!" - elif [[ -d "/Applications/Murus.app" ]]; then - third_party_firewall="Murus" - elif [[ -d "/Applications/Vallum.app" ]]; then - third_party_firewall="Vallum" - fi - - if [[ -n "$third_party_firewall" ]]; then - echo -e " ${GREEN}✓${NC} Firewall ${third_party_firewall} active" - return - fi - - # Fall back to macOS built-in firewall check - local firewall_output=$(sudo /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 - echo -e " ${GRAY}${ICON_WARNING}${NC} Firewall ${YELLOW}Network protection disabled${NC}" - export FIREWALL_DISABLED=true - fi -} - -check_gatekeeper() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "gatekeeper"; then return; fi - # Check Gatekeeper status - if command -v spctl > /dev/null 2>&1; then - local gk_status=$(spctl --status 2> /dev/null || echo "") - if echo "$gk_status" | grep -q "enabled"; then - echo -e " ${GREEN}✓${NC} Gatekeeper App download protection active" - unset GATEKEEPER_DISABLED - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Gatekeeper ${YELLOW}App security disabled${NC}" - export GATEKEEPER_DISABLED=true - fi - fi -} - -check_sip() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_sip"; then return; fi - # Check System Integrity Protection - if command -v csrutil > /dev/null 2>&1; then - local sip_status=$(csrutil status 2> /dev/null || echo "") - if echo "$sip_status" | grep -q "enabled"; then - echo -e " ${GREEN}✓${NC} SIP System integrity protected" - else - echo -e " ${GRAY}${ICON_WARNING}${NC} SIP ${YELLOW}System protection disabled${NC}" - fi - fi -} - -check_all_security() { - echo -e "${BLUE}${ICON_ARROW}${NC} Security Status" - check_filevault - check_firewall - check_gatekeeper - check_sip -} - -# ============================================================================ -# Software Update Checks -# ============================================================================ - -# Cache configuration -CACHE_DIR="${HOME}/.cache/mole" -CACHE_TTL=600 # 10 minutes in seconds - -# Ensure cache directory exists -ensure_user_dir "$CACHE_DIR" - -clear_cache_file() { - local file="$1" - rm -f "$file" 2> /dev/null || true -} - -reset_brew_cache() { - clear_cache_file "$CACHE_DIR/brew_updates" -} - -reset_softwareupdate_cache() { - clear_cache_file "$CACHE_DIR/softwareupdate_list" - SOFTWARE_UPDATE_LIST="" -} - -reset_mole_cache() { - clear_cache_file "$CACHE_DIR/mole_version" -} - -# Check if cache is still valid -is_cache_valid() { - local cache_file="$1" - local ttl="${2:-$CACHE_TTL}" - - if [[ ! -f "$cache_file" ]]; then - return 1 - fi - - local cache_age=$(($(get_epoch_seconds) - $(get_file_mtime "$cache_file"))) - [[ $cache_age -lt $ttl ]] -} - -# Cache software update list to avoid calling softwareupdate twice -SOFTWARE_UPDATE_LIST="" - -get_software_updates() { - local cache_file="$CACHE_DIR/softwareupdate_list" - - # 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 [[ "$pending_updates" -gt 0 ]]; then - echo "Updates Available" - else - echo "" - fi -} - -check_homebrew_updates() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_homebrew_updates"; then return; fi - - export BREW_OUTDATED_COUNT=0 - export BREW_FORMULA_OUTDATED_COUNT=0 - export BREW_CASK_OUTDATED_COUNT=0 - - if ! command -v brew > /dev/null 2>&1; then - printf " ${GRAY}${ICON_EMPTY}${NC} %-12s %s\n" "Homebrew" "Not installed" - return - fi - - local cache_file="$CACHE_DIR/brew_updates" - local formula_count=0 - local cask_count=0 - local total_count=0 - local use_cache=false - - if is_cache_valid "$cache_file"; then - local cached_formula="" - local cached_cask="" - IFS=' ' read -r cached_formula cached_cask < "$cache_file" || true - if [[ "$cached_formula" =~ ^[0-9]+$ && "$cached_cask" =~ ^[0-9]+$ ]]; then - formula_count="$cached_formula" - cask_count="$cached_cask" - use_cache=true - fi - fi - - if [[ "$use_cache" == "false" ]]; then - local formula_outdated="" - local cask_outdated="" - local formula_status=0 - local cask_status=0 - local spinner_started=false - - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking 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 - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - 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 - 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 - else - printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check failed" - return - fi - fi - - total_count=$((formula_count + cask_count)) - export BREW_FORMULA_OUTDATED_COUNT="$formula_count" - export BREW_CASK_OUTDATED_COUNT="$cask_count" - export BREW_OUTDATED_COUNT="$total_count" - - if [[ $total_count -gt 0 ]]; then - local detail="" - if [[ $formula_count -gt 0 ]]; then - detail="${formula_count} formula" - fi - if [[ $cask_count -gt 0 ]]; then - [[ -n "$detail" ]] && detail="${detail}, " - detail="${detail}${cask_count} cask" - fi - [[ -z "$detail" ]] && detail="${total_count} updates" - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Homebrew" "${detail} available" - else - printf " ${GREEN}✓${NC} %-12s %s\n" "Homebrew" "Up to date" - fi -} - -check_appstore_updates() { - # Skipped for speed optimization - consolidated into check_macos_update - # We can't easily distinguish app store vs macos updates without the slow softwareupdate -l call - export APPSTORE_UPDATE_COUNT=0 -} - -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 softwareupdate_timeout=10 - if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then - : - else - sw_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - # Debug logging for troubleshooting - if [[ -n "${MO_DEBUG:-}" ]]; then - echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2 - fi - - # Prefer avoiding false negatives: if the system indicates updates are pending, - # only clear the flag when softwareupdate returns a list without any update entries. - if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then - if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then - updates_available="false" - fi - fi - fi - - 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" - else - printf " ${GREEN}✓${NC} %-12s %s\n" "macOS" "System up to date" - fi -} - -check_mole_update() { - if command -v is_whitelisted > /dev/null && is_whitelisted "check_mole_update"; then return; fi - - # Check if Mole has updates - # Auto-detect version from mole main script - local current_version - if [[ -f "${SCRIPT_DIR:-/usr/local/bin}/mole" ]]; then - current_version=$(grep '^VERSION=' "${SCRIPT_DIR:-/usr/local/bin}/mole" 2> /dev/null | head -1 | sed 's/VERSION="\(.*\)"/\1/' || echo "unknown") - else - current_version="${VERSION:-unknown}" - fi - - local latest_version="" - local cache_file="$CACHE_DIR/mole_version" - - export MOLE_UPDATE_AVAILABLE="false" - - # Check cache first - if is_cache_valid "$cache_file"; then - latest_version=$(cat "$cache_file" 2> /dev/null || echo "") - else - # Show spinner while checking - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Mole version..." - fi - - # Try to get latest version from GitHub - if command -v curl > /dev/null 2>&1; then - # Run in background to allow Ctrl+C to interrupt - local temp_version - temp_version=$(mktemp_file "mole_version_check") - curl -fsSL --connect-timeout 3 --max-time 5 https://api.github.com/repos/tw93/mole/releases/latest 2> /dev/null | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' > "$temp_version" & - local curl_pid=$! - - # Wait for curl to complete (allows Ctrl+C to interrupt) - if wait "$curl_pid" 2> /dev/null; then - latest_version=$(cat "$temp_version" 2> /dev/null || echo "") - # Save to cache - if [[ -n "$latest_version" ]]; then - ensure_user_file "$cache_file" - echo "$latest_version" > "$cache_file" 2> /dev/null || true - fi - fi - rm -f "$temp_version" 2> /dev/null || true - fi - - # Stop spinner - if [[ -t 1 ]]; then - stop_inline_spinner - fi - fi - - # Normalize version strings (remove leading 'v' or 'V') - current_version="${current_version#v}" - current_version="${current_version#V}" - latest_version="${latest_version#v}" - latest_version="${latest_version#V}" - - if [[ -n "$latest_version" && "$current_version" != "$latest_version" ]]; then - # Compare versions - if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then - export MOLE_UPDATE_AVAILABLE="true" - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}, running %s\n" "$ICON_WARNING" "Mole" "${latest_version} available" "${current_version}" - else - printf " ${GREEN}✓${NC} %-12s %s\n" "Mole" "Latest version ${current_version}" - fi - else - printf " ${GREEN}✓${NC} %-12s %s\n" "Mole" "Latest version ${current_version}" - fi -} - -check_all_updates() { - # Reset spinner flag for softwareupdate - unset SOFTWAREUPDATE_SPINNER_SHOWN - - # Preload software update data to avoid delays between subsequent checks - # Only redirect stdout, keep stderr for spinner display - get_software_updates > /dev/null - - echo -e "${BLUE}${ICON_ARROW}${NC} System Updates" - check_homebrew_updates - check_appstore_updates - check_macos_update - check_mole_update -} - -get_appstore_update_labels() { - get_software_updates | awk ' - /^\*/ { - label=$0 - sub(/^[[:space:]]*\* Label: */, "", label) - sub(/,.*/, "", label) - lower=tolower(label) - if (index(lower, "macos") == 0) { - print label - } - } - ' -} - -get_macos_update_labels() { - get_software_updates | awk ' - /^\*/ { - label=$0 - sub(/^[[:space:]]*\* Label: */, "", label) - sub(/,.*/, "", label) - lower=tolower(label) - if (index(lower, "macos") != 0) { - print label - } - } - ' -} - -# ============================================================================ -# System Health Checks -# ============================================================================ - -check_disk_space() { - # Use df -k to get KB values (always numeric), then calculate GB via math - # This avoids unit suffix parsing issues (df -H can return MB or GB) - local free_kb=$(command df -k / | awk 'NR==2 {print $4}') - local free_gb=$(awk "BEGIN {printf \"%.1f\", $free_kb / 1048576}") - local free_num=$(awk "BEGIN {printf \"%d\", $free_kb / 1048576}") - - export DISK_FREE_GB=$free_num - - if [[ $free_num -lt 20 ]]; then - echo -e " ${RED}✗${NC} Disk Space ${RED}${free_gb}GB free${NC}, Critical" - elif [[ $free_num -lt 50 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Disk Space ${YELLOW}${free_gb}GB free${NC}, Low" - else - echo -e " ${GREEN}✓${NC} Disk Space ${free_gb}GB free" - fi -} - -check_memory_usage() { - local mem_total - mem_total=$(sysctl -n hw.memsize 2> /dev/null || echo "0") - if [[ -z "$mem_total" || "$mem_total" -le 0 ]]; then - echo -e " ${GRAY}-${NC} Memory Unable to determine" - return - fi - - local vm_output - vm_output=$(vm_stat 2> /dev/null || echo "") - - local page_size - page_size=$(echo "$vm_output" | awk '/page size of/ {print $8}') - [[ -z "$page_size" ]] && page_size=4096 - - local free_pages inactive_pages spec_pages - free_pages=$(echo "$vm_output" | awk '/Pages free/ {gsub(/\./,"",$3); print $3}') - inactive_pages=$(echo "$vm_output" | awk '/Pages inactive/ {gsub(/\./,"",$3); print $3}') - spec_pages=$(echo "$vm_output" | awk '/Pages speculative/ {gsub(/\./,"",$3); print $3}') - - free_pages=${free_pages:-0} - inactive_pages=${inactive_pages:-0} - spec_pages=${spec_pages:-0} - - # Estimate used percent: (total - free - inactive - speculative) / total - local total_pages=$((mem_total / page_size)) - local free_total=$((free_pages + inactive_pages + spec_pages)) - local used_pages=$((total_pages - free_total)) - if ((used_pages < 0)); then - used_pages=0 - fi - - local used_percent - used_percent=$(awk "BEGIN {printf \"%.0f\", ($used_pages / $total_pages) * 100}") - ((used_percent > 100)) && used_percent=100 - ((used_percent < 0)) && used_percent=0 - - if [[ $used_percent -gt 90 ]]; then - echo -e " ${RED}✗${NC} Memory ${RED}${used_percent}% used${NC}, Critical" - elif [[ $used_percent -gt 80 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Memory ${YELLOW}${used_percent}% used${NC}, High" - else - echo -e " ${GREEN}✓${NC} Memory ${used_percent}% used" - fi -} - -check_login_items() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_login_items"; then return; fi - local login_items_count=0 - local -a login_items_list=() - - if [[ -t 0 ]]; then - # Show spinner while getting login items - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking login items..." - fi - - while IFS= read -r login_item; do - [[ -n "$login_item" ]] && login_items_list+=("$login_item") - done < <(list_login_items || true) - login_items_count=${#login_items_list[@]} - - # Stop spinner before output - if [[ -t 1 ]]; then - stop_inline_spinner - fi - fi - - if [[ $login_items_count -gt 15 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Login Items ${YELLOW}${login_items_count} apps${NC}" - elif [[ $login_items_count -gt 0 ]]; then - echo -e " ${GREEN}✓${NC} Login Items ${login_items_count} apps" - else - echo -e " ${GREEN}✓${NC} Login Items None" - return - fi - - # Show items in a single line (compact) - local preview_limit=3 - ((preview_limit > login_items_count)) && preview_limit=$login_items_count - - local items_display="" - for ((i = 0; i < preview_limit; i++)); do - if [[ $i -eq 0 ]]; then - items_display="${login_items_list[$i]}" - else - items_display="${items_display}, ${login_items_list[$i]}" - fi - done - - if ((login_items_count > preview_limit)); then - local remaining=$((login_items_count - preview_limit)) - items_display="${items_display} +${remaining}" - fi - - echo -e " ${GRAY}${items_display}${NC}" -} - -check_cache_size() { - local cache_size_kb=0 - - # Check common cache locations - local -a cache_paths=( - "$HOME/Library/Caches" - "$HOME/Library/Logs" - ) - - # Show spinner while calculating cache size - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Scanning cache..." - fi - - for cache_path in "${cache_paths[@]}"; do - if [[ -d "$cache_path" ]]; then - local size_output - size_output=$(get_path_size_kb "$cache_path") - [[ "$size_output" =~ ^[0-9]+$ ]] || size_output=0 - cache_size_kb=$((cache_size_kb + size_output)) - fi - done - - local cache_size_gb=$(echo "scale=1; $cache_size_kb / 1024 / 1024" | bc) - export CACHE_SIZE_GB=$cache_size_gb - - # Stop spinner before output - if [[ -t 1 ]]; then - stop_inline_spinner - fi - - # Convert to integer for comparison - local cache_size_int=$(echo "$cache_size_gb" | cut -d'.' -f1) - - if [[ $cache_size_int -gt 10 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" - elif [[ $cache_size_int -gt 5 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Cache Size ${YELLOW}${cache_size_gb}GB${NC} cleanable" - else - echo -e " ${GREEN}✓${NC} Cache Size ${cache_size_gb}GB" - fi -} - -check_swap_usage() { - # Check swap usage - if command -v sysctl > /dev/null 2>&1; then - local swap_info=$(sysctl vm.swapusage 2> /dev/null || echo "") - if [[ -n "$swap_info" ]]; then - local swap_used=$(echo "$swap_info" | grep -o "used = [0-9.]*[GM]" | awk 'NR==1{print $3}') - swap_used=${swap_used:-0M} - local swap_num="${swap_used//[GM]/}" - - if [[ "$swap_used" == *"G"* ]]; then - local swap_gb=${swap_num%.*} - if [[ $swap_gb -gt 2 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Swap Usage ${YELLOW}${swap_used}${NC}, High" - else - echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" - fi - else - echo -e " ${GREEN}✓${NC} Swap Usage ${swap_used}" - fi - fi - fi -} - -check_brew_health() { - # Check whitelist - if command -v is_whitelisted > /dev/null && is_whitelisted "check_brew_health"; then return; fi -} - -check_system_health() { - echo -e "${BLUE}${ICON_ARROW}${NC} System Health" - check_disk_space - check_memory_usage - check_swap_usage - check_login_items - 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/health_json.sh b/Resources/mole/lib/check/health_json.sh index cdda7fa..45cd079 100644 --- a/Resources/mole/lib/check/health_json.sh +++ b/Resources/mole/lib/check/health_json.sh @@ -130,18 +130,28 @@ 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+=('spotlight_orphan_rules_cleanup|Spotlight Orphan Rules|Remove Spotlight search-rule entries for apps that are no longer installed|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..dca04bc 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,29 +63,128 @@ 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 "$MOLE_TIMEOUT_QUICK_DETECT_SEC" 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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" - safe_clean ~/Library/Developer/Xcode/Archives/* "Xcode archives" + clean_xcode_derived_data safe_clean ~/Library/Developer/Xcode/DocumentationCache/* "Xcode documentation cache" safe_clean ~/Library/Developer/Xcode/DocumentationIndex/* "Xcode documentation index" else - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData/Archives/Documentation cleanup" + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode is running, skipping DerivedData/Documentation cleanup" fi } +# Remove extension directories that VS Code / Cursor have marked obsolete. +# Each editor writes a .obsolete JSON file under its extensions root whose keys +# are stale extension directory names left behind after an extension update. +clean_editor_obsolete_extensions() { + local -a editor_roots=( + "$HOME/.vscode/extensions|VS Code" + "$HOME/.vscode-insiders/extensions|VS Code Insiders" + "$HOME/.cursor/extensions|Cursor" + ) + local entry ext_root editor_label obsolete_file key target + for entry in "${editor_roots[@]}"; do + ext_root="${entry%%|*}" + editor_label="${entry##*|}" + obsolete_file="$ext_root/.obsolete" + [[ -f "$obsolete_file" ]] || continue + + while IFS= read -r key; do + # Each key must be a plain direct-child directory name; reject + # anything that could escape the extensions root. + case "$key" in + "" | "." | ".." | */*) continue ;; + esac + target="$ext_root/$key" + [[ -d "$target" ]] || continue + safe_clean "$target" "Obsolete $editor_label extension" + done < <(plutil -p "$obsolete_file" 2> /dev/null | + sed -nE 's/^[[:space:]]*"([^"]+)"[[:space:]]*=>.*/\1/p') + done +} # Code editors. clean_code_editors() { safe_clean ~/Library/Application\ Support/Code/logs/* "VS Code logs" safe_clean ~/Library/Application\ Support/Code/Cache/* "VS Code cache" safe_clean ~/Library/Application\ Support/Code/CachedExtensions/* "VS Code extension cache" safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code data cache" + safe_clean ~/Library/Application\ Support/Code/WebStorage/*/CacheStorage/* "VS Code webview cache" safe_clean ~/Library/Caches/com.sublimetext.*/* "Sublime Text cache" + safe_clean ~/Library/Caches/Zed/* "Zed cache" + safe_clean ~/Library/Logs/Zed/* "Zed logs" + clean_editor_obsolete_extensions + # CodeBuddy Extension (VS Code fork, Electron) + if [[ -d ~/Library/Application\ Support/CodeBuddyExtension ]]; then + safe_clean ~/Library/Application\ Support/CodeBuddyExtension/Cache/* "CodeBuddy Extension cache" + safe_clean ~/Library/Application\ Support/CodeBuddyExtension/logs/* "CodeBuddy Extension logs" + fi + # CodeBuddy CN (VS Code fork, Electron) + if [[ -d ~/Library/Application\ Support/CodeBuddy\ CN ]]; then + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/Cache/* "CodeBuddy CN cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/CachedData/* "CodeBuddy CN cached data" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/CachedExtensionVSIXs/* "CodeBuddy CN extension cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/Code\ Cache/* "CodeBuddy CN code cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/GPUCache/* "CodeBuddy CN GPU cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/DawnGraphiteCache/* "CodeBuddy CN Dawn cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/DawnWebGPUCache/* "CodeBuddy CN WebGPU cache" + safe_clean ~/Library/Application\ Support/CodeBuddy\ CN/logs/* "CodeBuddy CN logs" + fi } # Communication apps. clean_communication_apps() { @@ -40,31 +194,43 @@ 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" safe_clean ~/Library/Caches/com.tencent.meeting/* "Tencent Meeting cache" safe_clean ~/Library/Caches/com.tencent.WeWorkMac/* "WeCom cache" + safe_clean ~/Library/Caches/com.tencent.qq/* "QQ cache" safe_clean ~/Library/Caches/com.feishu.*/* "Feishu cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/Cache/* "Microsoft Teams legacy cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/Application\ Cache/* "Microsoft Teams legacy application cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/Code\ Cache/* "Microsoft Teams legacy code cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/GPUCache/* "Microsoft Teams legacy GPU cache" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/logs/* "Microsoft Teams legacy logs" - safe_clean ~/Library/Application\ Support/Microsoft/Teams/tmp/* "Microsoft Teams legacy temp files" + if [[ -d ~/Library/Application\ Support/Microsoft/Teams ]]; then + safe_clean ~/Library/Application\ Support/Microsoft/Teams/Cache/* "Microsoft Teams legacy cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/Application\ Cache/* "Microsoft Teams legacy application cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/Code\ Cache/* "Microsoft Teams legacy code cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/GPUCache/* "Microsoft Teams legacy GPU cache" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/logs/* "Microsoft Teams legacy logs" + safe_clean ~/Library/Application\ Support/Microsoft/Teams/tmp/* "Microsoft Teams legacy temp files" + fi } # DingTalk. clean_dingtalk() { safe_clean ~/Library/Caches/dd.work.exclusive4aliding/* "DingTalk iDingTalk cache" safe_clean ~/Library/Caches/com.alibaba.AliLang.osx/* "AliLang security component" - safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs" - safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs" + if [[ -d ~/Library/Application\ Support/iDingTalk ]]; then + safe_clean ~/Library/Application\ Support/iDingTalk/log/* "DingTalk logs" + safe_clean ~/Library/Application\ Support/iDingTalk/holmeslogs/* "DingTalk holmes logs" + fi } # AI assistants. 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/Caches/com.lmstudio.lmstudio/* "LM Studio cache" + safe_clean ~/Library/Caches/CCTClearcutLogger "Google Clearcut logs" + if [[ -d "$HOME/Library/Application Support/Codex" || -d "$HOME/Library/Logs/com.openai.codex" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex Desktop state · skipped by default" + note_activity + fi } # Design and creative tools. clean_design_tools() { @@ -74,13 +240,118 @@ 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. +final_cut_pro_is_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Final Cut Pro" > /dev/null 2>&1 && return 0 + pgrep -f "/Final Cut Pro.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +final_cut_pro_path_has_protected_component() { + local path="$1" + + case "$path" in + */Original\ Media | */Original\ Media/* | \ + */CurrentVersion.flexolibrary | */CurrentVersion.plist | */Settings.plist | \ + */Motion\ Templates | */Motion\ Templates/* | \ + */Final\ Cut\ Pro\ Backups | */Final\ Cut\ Pro\ Backups/*) + return 0 + ;; + esac + + return 1 +} + +is_final_cut_pro_generated_cache_target() { + local library="$1" + local target="$2" + + [[ -n "$library" && -n "$target" ]] || return 1 + [[ "$library" == /* && "$target" == /* ]] || return 1 + [[ "$library" == "$HOME"/Movies/*.fcpbundle ]] || return 1 + [[ "$target" == "$library"/* ]] || return 1 + [[ -d "$library" && ! -L "$library" ]] || return 1 + [[ -d "$target" && ! -L "$target" ]] || return 1 + + final_cut_pro_path_has_protected_component "$target" && return 1 + + if declare -f validate_path_for_deletion > /dev/null 2>&1; then + validate_path_for_deletion "$target" > /dev/null 2>&1 || return 1 + fi + + local relative_target="${target#"$library"/}" + case "$relative_target" in + */Render\ Files/High\ Quality\ Media | */Transcoded\ Media/Proxy\ Media) + return 0 + ;; + esac + + return 1 +} + +find_final_cut_pro_generated_cache_targets() { + local movies_dir="$HOME/Movies" + [[ -d "$movies_dir" ]] || return 0 + + local library target + while IFS= read -r -d '' library; do + [[ -d "$library" && ! -L "$library" ]] || continue + + while IFS= read -r -d '' target; do + if is_final_cut_pro_generated_cache_target "$library" "$target"; then + printf '%s\0' "$target" + fi + done < <(command find "$library" \ + \( -type d \( \ + -name "Original Media" -o \ + -name "Analysis Files" -o \ + -name "Motion Templates" -o \ + -name "Final Cut Pro Backups" \ + \) -prune \) -o \ + \( -type d \( \ + -path "*/Render Files/High Quality Media" -o \ + -path "*/Transcoded Media/Proxy Media" \ + \) -print0 \) 2> /dev/null || true) + done < <(command find "$movies_dir" -maxdepth 4 -type d -name "*.fcpbundle" -prune -print0 2> /dev/null || true) +} + +clean_final_cut_pro_generated_caches() { + if final_cut_pro_is_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Final Cut Pro is running, skipping generated cache cleanup" + note_activity + return 0 + fi + + local -a fcp_cache_targets=() + local target + while IFS= read -r -d '' target; do + fcp_cache_targets+=("$target") + done < <(find_final_cut_pro_generated_cache_targets) + + [[ ${#fcp_cache_targets[@]} -gt 0 ]] || return 0 + + # Final Cut Pro generated cache cleanup (issue #843). + # Safety scope for the first pass: + # - only scan ~/Movies, the default Apple library location; + # - only delete exact generated-media directories documented by Apple as + # regenerable: render media and proxy media; + # - never touch Original Media, library databases, plist settings, backups, + # Motion templates, Analysis Files, optimized media, or external .fcpcache. + # Future expansion can add explicit flags or configurable roots for + # optimized media, Analysis Files, and external cache bundles after more + # field feedback. + safe_clean "${fcp_cache_targets[@]}" "Final Cut Pro generated cache" +} + clean_video_tools() { safe_clean ~/Library/Caches/net.telestream.screenflow10/* "ScreenFlow cache" safe_clean ~/Library/Caches/com.apple.FinalCut/* "Final Cut Pro cache" + clean_final_cut_pro_generated_caches 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 +370,25 @@ 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" + safe_clean ~/Library/Application\ Support/spacedrive/thumbnails/* "Spacedrive thumbnail cache" + safe_clean ~/Library/Containers/is.follow/Data/Library/Application\ Support/Folo/Cache/Cache_Data/* "Folo 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" @@ -133,6 +407,16 @@ clean_media_players() { safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache" safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache" safe_clean ~/Library/Caches/com.tencent.QQMusic/* "QQ Music cache" + safe_clean ~/Library/Caches/com.tencent.QQMusicMac/* "QQ Music Mac cache" + # QQ Music Mac sandboxed container caches (protect offline downloads in iDownloadProxy). + local _qqmusic_container="$HOME/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac" + if [[ -d "$_qqmusic_container" ]]; then + safe_clean "$_qqmusic_container/iRRCache"/* "QQ Music streaming cache" + safe_clean "$_qqmusic_container/iLog"/* "QQ Music logs" + safe_clean "$_qqmusic_container/iCache"/* "QQ Music cache" + safe_clean "$_qqmusic_container/iTemp"/* "QQ Music temp files" + fi + safe_clean ~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Caches/* "QQ Music container cache" safe_clean ~/Library/Caches/com.kugou.mac/* "Kugou Music cache" safe_clean ~/Library/Caches/com.kuwo.mac/* "Kuwo Music cache" } @@ -143,9 +427,21 @@ clean_video_players() { safe_clean ~/Library/Caches/io.mpv "MPV cache" safe_clean ~/Library/Caches/com.iqiyi.player "iQIYI cache" safe_clean ~/Library/Caches/com.tencent.tenvideo "Tencent Video cache" + # Tencent Video sandboxed container caches. + local _tenvideo_as="$HOME/Library/Containers/com.tencent.tenvideo/Data/Library/Application Support" + if [[ -d "$_tenvideo_as" ]]; then + safe_clean "$_tenvideo_as/Upgrade"/* "Tencent Video old installer" + safe_clean "$_tenvideo_as/VideoNative"/* "Tencent Video native cache" + safe_clean "$_tenvideo_as/documentCache"/* "Tencent Video document cache" + fi 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/Containers/com.wuziqi.SenPlayer/Data/tmp/videoCache/* "SenPlayer video cache" + safe_clean ~/Library/Caches/smart.stremio*/* "Stremio cache" + if [[ -d ~/Library/Application\ Support/stremio ]]; then + safe_clean ~/Library/Application\ Support/stremio/stremio-server/stremio-cache/* "Stremio server cache" + fi } # Download managers. clean_download_managers() { @@ -155,30 +451,113 @@ clean_download_managers() { safe_clean ~/Library/Caches/com.downie.Downie-* "Downie cache" safe_clean ~/Library/Caches/com.folx.*/* "Folx cache" safe_clean ~/Library/Caches/com.charlessoft.pacifist/* "Pacifist cache" + clean_neatdm_stale_segments +} +# Neat Download Manager: clean stale incomplete download segments. +# History database (NeatDB.db) is never touched; only numbered segment +# directories whose seg.x0 file is older than MOLE_ORPHAN_AGE_DAYS are removed. +# Download URLs expire within hours/days so 30-day-old segments cannot be resumed. +clean_neatdm_stale_segments() { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + [[ -d "$neatdm_dir" ]] || return 0 + + local stale_count=0 + local stale_kb=0 + local current_epoch + current_epoch=$(get_epoch_seconds) + + local -a stale_dirs=() + local seg_dir + for seg_dir in "$neatdm_dir"/*/; do + [[ -d "$seg_dir" ]] || continue + local seg_name + seg_name=$(basename "${seg_dir%/}") + [[ "$seg_name" =~ ^[0-9]+$ ]] || continue + [[ -f "$seg_dir/seg.x0" ]] || continue + + local seg_mtime + seg_mtime=$(get_file_mtime "$seg_dir/seg.x0") + local age_days=$(((current_epoch - seg_mtime) / 86400)) + + if [[ $age_days -ge ${MOLE_ORPHAN_AGE_DAYS:-30} ]]; then + stale_dirs+=("$seg_dir") + fi + done + + [[ ${#stale_dirs[@]} -eq 0 ]] && return 0 + + for seg_dir in "${stale_dirs[@]}"; do + local size_kb + size_kb=$(get_path_size_kb "$seg_dir") + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + + if [[ "$DRY_RUN" != "true" ]]; then + if safe_remove "$seg_dir" true; then + stale_count=$((stale_count + 1)) + stale_kb=$((stale_kb + size_kb)) + fi + else + stale_count=$((stale_count + 1)) + stale_kb=$((stale_kb + size_kb)) + fi + done + + if [[ $stale_count -gt 0 ]]; then + local size_human + size_human=$(bytes_to_human "$((stale_kb * 1024))") + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} NeatDM stale downloads · ${stale_count} items, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$stale_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} NeatDM stale downloads · ${stale_count} items, ${line_color}${size_human}${NC}" + fi + files_cleaned=$((files_cleaned + stale_count)) + total_size_cleaned=$((total_size_cleaned + stale_kb)) + total_items=$((total_items + 1)) + note_activity + fi } # Gaming platforms. clean_gaming_platforms() { safe_clean ~/Library/Caches/com.valvesoftware.steam/* "Steam cache" - safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache" - safe_clean ~/Library/Application\ Support/Steam/appcache/* "Steam app cache" - safe_clean ~/Library/Application\ Support/Steam/depotcache/* "Steam depot cache" - safe_clean ~/Library/Application\ Support/Steam/steamapps/shadercache/* "Steam shader cache" - safe_clean ~/Library/Application\ Support/Steam/logs/* "Steam logs" + if [[ -d ~/Library/Application\ Support/Steam ]]; then + safe_clean ~/Library/Application\ Support/Steam/htmlcache/* "Steam web cache" + safe_clean ~/Library/Application\ Support/Steam/appcache/* "Steam app cache" + safe_clean ~/Library/Application\ Support/Steam/depotcache/* "Steam depot cache" + safe_clean ~/Library/Application\ Support/Steam/steamapps/shadercache/* "Steam shader cache" + safe_clean ~/Library/Application\ Support/Steam/logs/* "Steam logs" + fi safe_clean ~/Library/Caches/com.epicgames.EpicGamesLauncher/* "Epic Games cache" safe_clean ~/Library/Caches/com.blizzard.Battle.net/* "Battle.net cache" - safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache" + if [[ -d ~/Library/Application\ Support/Battle.net ]]; then + safe_clean ~/Library/Application\ Support/Battle.net/Cache/* "Battle.net app cache" + fi safe_clean ~/Library/Caches/com.ea.*/* "EA Origin cache" safe_clean ~/Library/Caches/com.gog.galaxy/* "GOG Galaxy cache" safe_clean ~/Library/Caches/com.riotgames.*/* "Riot Games cache" - safe_clean ~/Library/Application\ Support/minecraft/logs/* "Minecraft logs" - safe_clean ~/Library/Application\ Support/minecraft/crash-reports/* "Minecraft crash reports" - safe_clean ~/Library/Application\ Support/minecraft/webcache/* "Minecraft web cache" - safe_clean ~/Library/Application\ Support/minecraft/webcache2/* "Minecraft web cache 2" - safe_clean ~/.lunarclient/game-cache/* "Lunar Client game cache" - safe_clean ~/.lunarclient/launcher-cache/* "Lunar Client launcher cache" - 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" + if [[ -d ~/Library/Application\ Support/minecraft ]]; then + safe_clean ~/Library/Application\ Support/minecraft/logs/* "Minecraft logs" + safe_clean ~/Library/Application\ Support/minecraft/crash-reports/* "Minecraft crash reports" + safe_clean ~/Library/Application\ Support/minecraft/webcache/* "Minecraft web cache" + safe_clean ~/Library/Application\ Support/minecraft/webcache2/* "Minecraft web cache 2" + fi + if [[ -d ~/.lunarclient ]]; then + safe_clean ~/.lunarclient/game-cache/* "Lunar Client game cache" + safe_clean ~/.lunarclient/launcher-cache/* "Lunar Client launcher cache" + 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" + fi + safe_clean ~/Library/Caches/net.pcsx2.PCSX2/* "PCSX2 cache" + if [[ -d ~/Library/Application\ Support/PCSX2 ]]; then + safe_clean ~/Library/Application\ Support/PCSX2/cache/* "PCSX2 shader cache" + safe_clean ~/Library/Logs/PCSX2/* "PCSX2 logs" + fi + if [[ -d ~/Library/Application\ Support/rpcs3 ]]; then + safe_clean ~/Library/Caches/net.rpcs3.rpcs3/* "RPCS3 cache" + safe_clean ~/Library/Application\ Support/rpcs3/logs/* "RPCS3 logs" + fi } # Translation/dictionary apps. clean_translation_apps() { @@ -210,11 +589,29 @@ 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) + if [[ -d ~/Library/Application\ Support/mihomo-party ]]; then + 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" + fi + # Stash proxy tool + safe_clean ~/Library/Caches/ws.stash.app.mac/* "Stash cache" } # Note-taking apps. clean_note_apps() { @@ -240,8 +637,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..e2816cb 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)) @@ -106,13 +108,13 @@ scan_installed_apps() { local -a app_paths=() while IFS= read -r app_path; do [[ -n "$app_path" ]] && app_paths+=("$app_path") - done < <(find "$app_dir" -name '*.app' -maxdepth 3 -type d 2> /dev/null) + done < <(command find "$app_dir" -maxdepth 3 -type d -name '*.app' 2> /dev/null) local count=0 for app_path in "${app_paths[@]:-}"; do 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,16 +125,19 @@ 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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 + run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" lsappinfo list 2> /dev/null | grep -o '"CFBundleIdentifier"="[^"]*"' | cut -d'"' -f4 >> "$scan_tmp_dir/running.txt" 2> /dev/null || true fi ) & pids+=($!) ( - run_with_timeout 5 find ~/Library/LaunchAgents /Library/LaunchAgents \ + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" find ~/Library/LaunchAgents /Library/LaunchAgents \ -name "*.plist" -type f 2> /dev/null | xargs -I {} basename {} .plist > "$scan_tmp_dir/agents.txt" 2> /dev/null || true ) & @@ -166,8 +171,34 @@ readonly ORPHAN_NEVER_DELETE_PATTERNS=( "com.apple.keychain*" ) -# Cache file for mdfind results (Bash 3.2 compatible, no associative arrays) -ORPHAN_MDFIND_CACHE_FILE="" +# In-memory mdfind result cache (Bash 3.2 compatible, no associative arrays). +# Newline-delimited strings checked via case glob — no subprocess per lookup. +_MOLE_MDFIND_FOUND="" +_MOLE_MDFIND_NOTFOUND="" + +_mdfind_cache_check() { + local bundle_id="$1" + local _nl=$'\n' + case "${_nl}${_MOLE_MDFIND_FOUND}${_nl}" in + *"${_nl}${bundle_id}${_nl}"*) return 0 ;; + esac + case "${_nl}${_MOLE_MDFIND_NOTFOUND}${_nl}" in + *"${_nl}${bundle_id}${_nl}"*) return 1 ;; + esac + return 2 +} + +_mdfind_cache_store() { + local bundle_id="$1" + local found="$2" + if [[ "$found" == "true" ]]; then + _MOLE_MDFIND_FOUND="${_MOLE_MDFIND_FOUND:+${_MOLE_MDFIND_FOUND} +}${bundle_id}" + else + _MOLE_MDFIND_NOTFOUND="${_MOLE_MDFIND_NOTFOUND:+${_MOLE_MDFIND_NOTFOUND} +}${bundle_id}" + fi +} # Usage: is_bundle_orphaned "bundle_id" "directory_path" "installed_bundles_file" is_bundle_orphaned() { @@ -213,31 +244,21 @@ is_bundle_orphaned() { fi fi - # 6. Slow path: mdfind fallback with file-based caching (Bash 3.2 compatible) + # 6. Slow path: mdfind fallback with in-memory caching (Bash 3.2 compatible) # This catches apps installed in non-standard locations - 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") - register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" - fi - - # Check cache first (grep is fast for small files) - if grep -Fxq "FOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + local _cache_rc=0 + _mdfind_cache_check "$bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then return 1 - fi - if grep -Fxq "NOTFOUND:$bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then - # Already checked, not found - continue to return 0 - : - else - # Query mdfind with strict timeout (2 seconds max) + elif [[ $_cache_rc -eq 2 ]]; then local app_exists - app_exists=$(run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") + app_exists=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") if [[ -n "$app_exists" ]]; then - echo "FOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + _mdfind_cache_store "$bundle_id" "true" return 1 else - echo "NOTFOUND:$bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + _mdfind_cache_store "$bundle_id" "false" fi fi fi @@ -273,22 +294,18 @@ is_claude_vm_bundle_orphaned() { fi fi - if [[ -z "$ORPHAN_MDFIND_CACHE_FILE" ]]; then - ORPHAN_MDFIND_CACHE_FILE=$(mktemp "${TMPDIR:-/tmp}/mole_mdfind_cache.XXXXXX") - register_temp_file "$ORPHAN_MDFIND_CACHE_FILE" - fi - - if grep -Fxq "FOUND:$claude_bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then + local _cache_rc=0 + _mdfind_cache_check "$claude_bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then return 1 - fi - if ! grep -Fxq "NOTFOUND:$claude_bundle_id" "$ORPHAN_MDFIND_CACHE_FILE" 2> /dev/null; then + elif [[ $_cache_rc -eq 2 ]]; 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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" + _mdfind_cache_store "$claude_bundle_id" "true" return 1 fi - echo "NOTFOUND:$claude_bundle_id" >> "$ORPHAN_MDFIND_CACHE_FILE" + _mdfind_cache_store "$claude_bundle_id" "false" fi return 0 @@ -311,26 +328,35 @@ 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.*" "$HOME/Library/Saved Application State|States|*.savedState" - "$HOME/Library/WebKit|WebKit|com.*:org.*:net.*:io.*" - "$HOME/Library/HTTPStorages|HTTP|com.*:org.*:net.*:io.*" - "$HOME/Library/Cookies|Cookies|*.binarycookies" ) for resource_type in "${resource_types[@]}"; do IFS='|' read -r base_path label patterns <<< "$resource_type" @@ -369,7 +395,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 @@ -382,6 +413,7 @@ clean_orphaned_app_data() { fi done done + # eval: restore shopt state captured by $(shopt -p) eval "$_nullglob_state" fi done @@ -398,19 +430,20 @@ clean_orphaned_app_data() { # These are left behind when apps are uninstalled but their system services remain clean_orphaned_system_services() { # Requires sudo - if ! sudo -n true 2> /dev/null; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! sudo -n true 2> /dev/null; then return 0 fi start_section_spinner "Scanning orphaned system services..." local orphaned_count=0 - local total_orphaned_kb=0 local -a orphaned_files=() - - # Known bundle ID patterns for common apps that leave system services behind - # Format: "file_pattern:app_check_command" - local -a known_orphan_patterns=( + # Force-protect list: if a plist's bundle ID matches one of these patterns AND + # the associated app IS installed, skip removal even if the binary appears missing. + # Format: "bundle_id_glob:pipe-separated app paths" + # NOTE: This list is now purely protective. Generic binary-existence detection + # (below) handles discovery; this list prevents false positives for known apps. + local -a known_protect_patterns=( # Sogou Input Method "com.sogou.*:/Library/Input Methods/SogouInput.app" # ClashX @@ -421,19 +454,48 @@ clean_orphaned_system_services() { "com.nektony.AC*:/Applications/App Cleaner & Uninstaller.app" # i4tools (爱思助手) "cn.i4tools.*:/Applications/i4Tools.app" + # MacPaw CleanMyMac X / CleanMyMac (MAS and direct) + "com.macpaw.CleanMyMac*:/Applications/CleanMyMac X.app" + # Wireshark Foundation – ChmodBPF daemon + "org.wireshark.ChmodBPF:/Applications/Wireshark.app" + # Zoom Video Communications – daemon, updater agents, PrivilegedHelperTool + "us.zoom.*:/Applications/zoom.us.app" + # remot3.it / Remote.It – CLI daemon + "it.remote.cli:/Applications/Remote.It.app" + # Docker – system socket and vmnetd helpers (Docker.app manages these) + "com.docker.*:/Applications/Docker.app" + # NetBird / Wiretrustee – CLI-managed daemon (binary in /usr/local/bin) + "netbird:/usr/local/bin/netbird" + # Homebrew-managed services (managed by brew services, not .app bundles) + "homebrew.mxcl.*:" ) - local mdfind_cache_file="" + # Returns 0 (found/protected) when any app backing a system service is installed. + # app_path may be a pipe-separated list of candidate .app paths; any match = protected. + # An empty app_path always returns 0 (unconditionally protected). _system_service_app_exists() { local bundle_id="$1" - local app_path="$2" + local app_path_raw="$2" + + # Empty path = unconditionally protected (e.g. homebrew.mxcl.*) + [[ -z "$app_path_raw" ]] && return 0 - [[ -n "$app_path" && -d "$app_path" ]] && return 0 + # Split on '|' to support multi-app helpers (e.g. Cindori TEHelper). + local _IFS_save="$IFS" + IFS='|' + # shellcheck disable=SC2206 # intentional word-split on '|' delimiter + local -a app_paths=($app_path_raw) + IFS="$_IFS_save" + + local _path + for _path in "${app_paths[@]}"; do + [[ -n "$_path" ]] || continue + # Protect if the app path or binary exists + [[ -d "$_path" || -e "$_path" ]] && return 0 - if [[ -n "$app_path" ]]; then local app_name - app_name=$(basename "$app_path") - case "$app_path" in + app_name=$(basename "$_path") + case "$_path" in /Applications/*) [[ -d "$HOME/Applications/$app_name" ]] && return 0 [[ -d "/Applications/Setapp/$app_name" ]] && return 0 @@ -442,31 +504,121 @@ clean_orphaned_system_services() { [[ -d "$HOME/Library/Input Methods/$app_name" ]] && return 0 ;; esac - fi - - 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") - register_temp_file "$mdfind_cache_file" - fi + done - if grep -Fxq "FOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + local _cache_rc=0 + _mdfind_cache_check "$bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then return 0 - fi - if ! grep -Fxq "NOTFOUND:$bundle_id" "$mdfind_cache_file" 2> /dev/null; then + elif [[ $_cache_rc -eq 2 ]]; 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") if [[ -n "$app_found" ]]; then - echo "FOUND:$bundle_id" >> "$mdfind_cache_file" + _mdfind_cache_store "$bundle_id" "true" return 0 fi - echo "NOTFOUND:$bundle_id" >> "$mdfind_cache_file" + _mdfind_cache_store "$bundle_id" "false" fi fi return 1 } + # Read a launchd program path from a system plist. + # The plist itself was discovered with sudo, so read it with sudo too (the + # caller already cleared a `sudo -n true` probe, so keep it non-interactive): + # unreadable root-owned plists make PlistBuddy print a non-path "File Doesn't + # Exist, Will Create..." message on stdout, which must never be treated as a + # missing binary path. + _plist_program_value() { + local plist="$1" + local key="$2" + local value="" + value=$(sudo -n /usr/libexec/PlistBuddy -c "Print :$key" "$plist" 2> /dev/null || true) + + [[ -z "$value" ]] && return 1 + [[ "$value" != /* ]] && return 1 + + printf '%s\n' "$value" + } + + # Read the program binary from a plist (Program or ProgramArguments[0]). + # Prints the path; returns 1 if no usable absolute Program key found. + _plist_binary_path() { + local plist="$1" + local binary="" + binary=$(_plist_program_value "$plist" "ProgramArguments:0" || true) + [[ -z "$binary" ]] && binary=$(_plist_program_value "$plist" "Program" || true) + [[ -z "$binary" ]] && return 1 + printf '%s\n' "$binary" + } + + # Returns 0 if the binary path is managed by a package manager or lives in a + # system directory — these should never be treated as orphans even when missing. + _is_package_managed_binary() { + local binary="$1" + case "$binary" in + /usr/local/bin/* | /usr/local/sbin/* | \ + /opt/homebrew/bin/* | /opt/homebrew/sbin/* | \ + /opt/homebrew/opt/*/bin/* | /opt/homebrew/opt/*/sbin/* | \ + /usr/bin/* | /usr/sbin/* | /bin/* | /sbin/* | \ + /usr/libexec/*) + return 0 + ;; + esac + return 1 + } + + # Generic plist orphan check: returns 0 if the plist is orphaned. + # A plist is orphaned when: + # 1. Its Program binary path is known and missing from disk, AND + # 2. The binary is not in a package-manager / system directory, AND + # 3. No protect pattern covers this bundle ID. + _plist_is_orphaned() { + local plist="$1" + local bundle_id="$2" + + # Read the binary the plist points to. + local binary + binary=$(_plist_binary_path "$plist") || return 1 # no Program key → skip + + # If the binary still exists, check if it's in PrivilegedHelperTools. + # If so, verify the parent app is still installed. If the parent app + # is gone, the binary itself is orphaned, so this plist is too. See #1082. + if [[ -e "$binary" ]]; then + if [[ "$binary" == /Library/PrivilegedHelperTools/* ]]; then + local helper_bundle_id + helper_bundle_id=$(basename "$binary") + helper_bundle_id="${helper_bundle_id%.plist}" + if bundle_has_installed_app "$helper_bundle_id"; then + return 1 # Parent app still installed, plist is healthy + fi + # Parent app is gone, binary is orphaned, so plist is orphaned + return 0 + fi + return 1 # Binary exists and not in PrivilegedHelperTools, plist is healthy + fi + + # If the binary is in a package-manager / system path, skip. + _is_package_managed_binary "$binary" && return 1 + + # Check protect patterns: if any matching pattern declares the app as + # installed, this plist is protected. + local pattern_entry + for pattern_entry in "${known_protect_patterns[@]}"; do + local file_pattern="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" + # shellcheck disable=SC2053 + [[ "$bundle_id" == $file_pattern ]] || continue + _system_service_app_exists "$bundle_id" "$app_path" && return 1 + # Pattern matched and app is gone → don't protect (fall through). + break + done + + return 0 # orphaned + } + # Scan system LaunchDaemons if [[ -d /Library/LaunchDaemons ]]; then while IFS= read -r -d '' plist; do @@ -476,28 +628,14 @@ clean_orphaned_system_services() { # Skip Apple system files [[ "$filename" == com.apple.* ]] && continue - # Extract bundle ID from filename (remove .plist extension) local bundle_id="${filename%.plist}" - # Check against known orphan patterns - for pattern_entry in "${known_orphan_patterns[@]}"; do - local file_pattern="${pattern_entry%%:*}" - local app_path="${pattern_entry#*:}" - - # shellcheck disable=SC2053 - if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then - if _system_service_app_exists "$bundle_id" "$app_path"; then - continue - fi - orphaned_files+=("$plist") - local size_kb - size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - orphaned_count=$((orphaned_count + 1)) - break - fi - done - done < <(sudo find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + # Generic detection: binary-existence check. + if _plist_is_orphaned "$plist" "$bundle_id"; then + orphaned_files+=("$plist") + orphaned_count=$((orphaned_count + 1)) + fi + done < <(sudo -n find /Library/LaunchDaemons -maxdepth 1 -name "*.plist" -print0 2> /dev/null) fi # Scan system LaunchAgents @@ -511,24 +649,12 @@ clean_orphaned_system_services() { local bundle_id="${filename%.plist}" - for pattern_entry in "${known_orphan_patterns[@]}"; do - local file_pattern="${pattern_entry%%:*}" - local app_path="${pattern_entry#*:}" - - # shellcheck disable=SC2053 - if [[ "$bundle_id" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then - if _system_service_app_exists "$bundle_id" "$app_path"; then - continue - fi - orphaned_files+=("$plist") - local size_kb - size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0") - total_orphaned_kb=$((total_orphaned_kb + size_kb)) - orphaned_count=$((orphaned_count + 1)) - break - fi - done - done < <(sudo find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + # Generic detection: binary-existence check. + if _plist_is_orphaned "$plist" "$bundle_id"; then + orphaned_files+=("$plist") + orphaned_count=$((orphaned_count + 1)) + fi + done < <(sudo -n find /Library/LaunchAgents -maxdepth 1 -name "*.plist" -print0 2> /dev/null) fi # Scan PrivilegedHelperTools @@ -536,256 +662,283 @@ clean_orphaned_system_services() { while IFS= read -r -d '' helper; do local filename filename=$(basename "$helper") - local bundle_id="$filename" + + # Skip non-plist data files (configs, JSON, etc.) that are not + # bundle-ID-named helpers. Only .plist and extensionless files + # can be orphaned service registrations. See #808. + case "$filename" in + *.json | *.cfg | *.conf | *.me2me_enabled | *.log | *.dat | *.db | *.xml | *.yml | *.yaml | *.ini | *.txt | *.pid | *.sock | *.lock) + continue + ;; + esac + + local bundle_id="${filename%.plist}" # Skip Apple system files - [[ "$filename" == com.apple.* ]] && continue + [[ "$bundle_id" == com.apple.* ]] && continue - for pattern_entry in "${known_orphan_patterns[@]}"; do + # Check force-protect list first: if the helper's app is still installed, + # never flag it as orphaned regardless of what bundle_has_installed_app says. + local is_protected=false + local pattern_entry + for pattern_entry in "${known_protect_patterns[@]}"; do local file_pattern="${pattern_entry%%:*}" local app_path="${pattern_entry#*:}" - # shellcheck disable=SC2053 - if [[ "$filename" == $file_pattern ]] && [[ ! -d "$app_path" ]]; then - if _system_service_app_exists "$bundle_id" "$app_path"; then - continue - 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)) + [[ "$filename" == $file_pattern || "$bundle_id" == $file_pattern ]] || continue + if _system_service_app_exists "$bundle_id" "$app_path"; then + is_protected=true break fi + # Pattern matched but app is absent → not protected; stop searching. + break done - done < <(sudo find /Library/PrivilegedHelperTools -maxdepth 1 -type f -print0 2> /dev/null) + [[ "$is_protected" == "true" ]] && continue + + # Generic detection: bundle-ID-style helpers registered via SMJobBless + # ship inside the parent app bundle (Contents/Library/LaunchServices/), + # which Spotlight doesn't index directly. Use the shared resolver so we do + # not falsely flag Adobe / 1Password / Docker helpers when their parent app + # is installed. See #733. + if [[ "$bundle_id" =~ ^(com|org|net|io)\. ]]; then + if ! bundle_has_installed_app "$bundle_id"; then + orphaned_files+=("$helper") + orphaned_count=$((orphaned_count + 1)) + fi + fi + done < <(sudo -n 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[@]} + # Guard the empty-array expansion: macOS /bin/bash is 3.2, which treats + # "${empty[@]}" as an unbound variable under `set -u`. When every orphan + # is whitelisted kept_files is empty, so a bare expansion would abort the + # whole clean run. See #1127. + if ((orphaned_count > 0)); then + orphaned_files=("${kept_files[@]}") + else + orphaned_files=() + fi + fi + # Report and clean if [[ $orphaned_count -gt 0 ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Found $orphaned_count orphaned system services" - for orphan_file in "${orphaned_files[@]}"; do - local filename - filename=$(basename "$orphan_file") + local removed_count=0 + local skipped_protected_count=0 + local failed_count=0 + local removed_kb=0 - if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + for orphan_file in "${orphaned_files[@]}"; do + # Orphans were already verified to have no installed parent app, so + # bypass the data-protection filename check (which would otherwise block + # legitimately orphaned files like Docker helpers) for this single call. + # MOLE_UNINSTALL_MODE is scoped to the call and never leaks to later + # cleanup sections; SYSTEM_CRITICAL_BUNDLES stay protected. See #1082. + if MOLE_UNINSTALL_MODE=1 should_protect_path "$orphan_file"; then + debug_log "Skipping protected orphaned service: $orphan_file" + skipped_protected_count=$((skipped_protected_count + 1)) + continue + fi + if [[ "$DRY_RUN" == "true" ]]; then debug_log "[DRY RUN] Would remove orphaned service: $orphan_file" else + local file_size_kb + file_size_kb=$(sudo -n du -skP "$orphan_file" 2> /dev/null | awk '{print $1}' || echo "0") + # Unload if it's a LaunchDaemon/LaunchAgent if [[ "$orphan_file" == *.plist ]]; then - sudo launchctl unload "$orphan_file" 2> /dev/null || true + sudo -n launchctl unload "$orphan_file" 2> /dev/null || true fi if safe_sudo_remove "$orphan_file"; then debug_log "Removed orphaned service: $orphan_file" + removed_count=$((removed_count + 1)) + removed_kb=$((removed_kb + file_size_kb)) + else + debug_log "Failed to remove orphaned service: $orphan_file" + failed_count=$((failed_count + 1)) fi fi done local orphaned_kb_display - if [[ $total_orphaned_kb -gt 1024 ]]; then - orphaned_kb_display=$(echo "$total_orphaned_kb" | awk '{printf "%.1fMB", $1/1024}') + if [[ $removed_kb -gt 1024 ]]; then + orphaned_kb_display=$(echo "$removed_kb" | awk '{printf "%.1fMB", $1/1024}') else - orphaned_kb_display="${total_orphaned_kb}KB" + orphaned_kb_display="${removed_kb}KB" + fi + if [[ "${DRY_RUN:-false}" != "true" ]]; then + if [[ $removed_count -gt 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned services, about $orphaned_kb_display" + note_activity + fi + fi + # Surface protected/failed counts in BOTH dry-run and real-clean so the + # two modes agree on what gets touched. Before #886, dry-run silently + # reported protected files under "Would remove" and real-clean then + # skipped them, leaving the user confused about which files actually + # disappeared. + if [[ $skipped_protected_count -gt 0 || $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Orphaned services skipped $skipped_protected_count protected, failed $failed_count" fi - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Cleaned $orphaned_count orphaned services, about $orphaned_kb_display" - note_activity fi } +# Policy: mo clean does NOT touch user LaunchAgents (~/Library/LaunchAgents), +# they are user-owned automation and not generic cleanup targets. + # ============================================================================ -# Orphaned LaunchAgent/LaunchDaemon Cleanup (Generic Detection) +# Orphaned container stubs # ============================================================================ -# Extract program path from plist (supports both ProgramArguments and Program) -_extract_program_path() { - local plist="$1" - local program="" +# Remove stub-only ~/Library/Containers directories left by uninstalled apps. +# A stub container contains only .com.apple.containermanagerd.metadata.plist +# with no Data/ subdirectory — it holds no user data and is safe to remove. +# Only targets a hardcoded allowlist of apps known to leave such stubs. +_remove_verified_container_stub() { + local container_dir="$1" + local metadata_plist="$2" - 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" -} + [[ -d "$container_dir" ]] || return 1 + [[ ! -L "$container_dir" ]] || return 1 + [[ "$metadata_plist" == "$container_dir/.com.apple.containermanagerd.metadata.plist" ]] || return 1 + [[ -f "$metadata_plist" ]] || return 1 -# 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 + if find "$container_dir" -mindepth 1 -maxdepth 1 ! -name ".com.apple.containermanagerd.metadata.plist" -print -quit 2> /dev/null | grep -q .; then + return 1 fi - echo "$associated" + command rm -f -- "$metadata_plist" || return 1 + command rmdir -- "$container_dir" } -# 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 +clean_orphaned_container_stubs() { + local containers_dir="$HOME/Library/Containers" + [[ -d "$containers_dir" ]] || return 0 + + # Format: "bundle_id_glob:app_path_to_check" + # The app_path_to_check is the canonical .app location; the stub is removed + # only when no common install location nor mdfind can locate the app. + local -a stub_patterns=( + # MacPaw CleanMyMac X (direct and MAS variants, bare bundle ID) + "com.macpaw.CleanMyMac*:/Applications/CleanMyMac X.app" + # MacPaw CleanMyMac X TeamID-prefixed helpers (e.g. S8EX82NJP6.com.macpaw.*) + "*.com.macpaw.CleanMyMac*:/Applications/CleanMyMac X.app" + ) - # 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 + local removed_count=0 + local failed_count=0 + local _ng_state + _ng_state=$(shopt -p nullglob || true) + shopt -s nullglob - # 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 + _container_stub_app_exists() { + local bundle_id="$1" + local app_path="$2" - # 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 + [[ -d "$app_path" || -e "$app_path" ]] && return 0 + + local app_name + app_name=$(basename "$app_path") + case "$app_path" in + /Applications/*) + [[ -d "$HOME/Applications/$app_name" ]] && return 0 + [[ -d "/Applications/Setapp/$app_name" ]] && return 0 + [[ -d "$HOME/Library/Application Support/Setapp/Applications/$app_name" ]] && return 0 + ;; + esac + + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + local _cache_rc=0 + _mdfind_cache_check "$bundle_id" || _cache_rc=$? + if [[ $_cache_rc -eq 0 ]]; then + return 0 + elif [[ $_cache_rc -eq 2 ]]; then + local app_found + app_found=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 || echo "") + if [[ -n "$app_found" ]]; then + _mdfind_cache_store "$bundle_id" "true" + return 0 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 + _mdfind_cache_store "$bundle_id" "false" fi fi - fi - - # All checks failed -> likely orphaned - return 0 -} -# Clean orphaned user-level LaunchAgents -# Only processes ~/Library/LaunchAgents (safer than system-level) -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 + return 1 + } - local bundle_id="${filename%.plist}" + local pattern_entry + for pattern_entry in "${stub_patterns[@]}"; do + local bundle_glob="${pattern_entry%%:*}" + local app_path="${pattern_entry#*:}" - # 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) + local container_dir + for container_dir in "$containers_dir"/$bundle_glob; do + [[ -d "$container_dir" ]] || continue + [[ -L "$container_dir" ]] && continue - stop_section_spinner + local metadata_plist="$container_dir/.com.apple.containermanagerd.metadata.plist" + [[ -f "$metadata_plist" ]] || continue + if find "$container_dir" -mindepth 1 -maxdepth 1 ! -name ".com.apple.containermanagerd.metadata.plist" -print -quit 2> /dev/null | grep -q .; then + continue + fi - local orphaned_count=${#orphaned_items[@]} + local bundle_id="${container_dir##*/}" - if [[ $orphaned_count -eq 0 ]]; then - return 0 - fi + _container_stub_app_exists "$bundle_id" "$app_path" && continue - # 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_path_whitelisted "$container_dir" 2> /dev/null; then + debug_log "Skipping whitelisted stub container: $container_dir" + continue + fi - 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 + if [[ "$DRY_RUN" != "true" ]]; then + # These directories have already passed the narrow stub-only + # checks above. Remove only the exact metadata file, then rmdir, + # so any new content that appears before deletion is preserved. + if _remove_verified_container_stub "$container_dir" "$metadata_plist" > /dev/null 2>&1; then + removed_count=$((removed_count + 1)) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$container_dir" "stub-container" + else + debug_log "Failed to remove stub container: $container_dir" + failed_count=$((failed_count + 1)) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$container_dir" "stub-container" + fi + else + removed_count=$((removed_count + 1)) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "SKIPPED" "$container_dir" "dry-run stub-container" + fi + done + done - # Try to unload first (if currently loaded) - launchctl unload "$plist_path" 2> /dev/null || true + # eval: restore shopt state captured by $(shopt -p) + eval "$_ng_state" - # 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" + if [[ $removed_count -gt 0 ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Orphaned app container stubs, ${YELLOW}${removed_count} stubs dry${NC}" 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" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Orphaned app container stubs, ${GREEN}${removed_count} removed${NC}" note_activity fi + files_cleaned=$((files_cleaned + removed_count)) + total_items=$((total_items + 1)) + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Orphaned container stubs: $failed_count could not be removed" fi } diff --git a/Resources/mole/lib/clean/brew.sh b/Resources/mole/lib/clean/brew.sh index 202c45a..61e014a 100644 --- a/Resources/mole/lib/clean/brew.sh +++ b/Resources/mole/lib/clean/brew.sh @@ -1,15 +1,46 @@ #!/bin/bash -# Clean Homebrew caches and remove orphaned dependencies +# Clean Homebrew caches and report orphaned dependencies # Env: DRY_RUN -# Skips if run within 7 days, runs cleanup/autoremove in parallel with 120s timeout +# Skips if run within 7 days, runs cleanup with package-manager timeouts +brew_autoremove_preview_has_items() { + local preview_file="$1" + [[ -s "$preview_file" ]] || return 1 + grep -Eq '^(==> )?Would autoremove [0-9]+ unneeded formula' "$preview_file" +} + +show_brew_autoremove_preview() { + local preview_file="$1" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew autoremove would remove:" + sed 's/^/ /' "$preview_file" +} + +run_brew_autoremove_preview() { + local timeout_seconds="$1" + local preview_file="$2" + + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_COLOR=1 NONINTERACTIVE=1 \ + run_with_timeout "$timeout_seconds" brew autoremove --dry-run > "$preview_file" 2>&1 +} + clean_homebrew() { command -v brew > /dev/null 2>&1 || return 0 + local cleanup_timeout="${MOLE_TIMEOUT_PKG_CLEANUP_SEC:-20}" + local autoremove_preview_timeout="${MOLE_TIMEOUT_PKG_LIST_SEC:-10}" if [[ "${DRY_RUN:-false}" == "true" ]]; then # Check if Homebrew cache is whitelisted if is_path_whitelisted "$HOME/Library/Caches/Homebrew"; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Homebrew · skipped whitelist" else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup and autoremove" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Homebrew · would cleanup" + local dry_run_autoremove_file + dry_run_autoremove_file=$(create_temp_file) + local dry_run_autoremove_exit=0 + run_brew_autoremove_preview "$autoremove_preview_timeout" "$dry_run_autoremove_file" || dry_run_autoremove_exit=$? + if [[ $dry_run_autoremove_exit -eq 0 ]] && brew_autoremove_preview_has_items "$dry_run_autoremove_file"; then + show_brew_autoremove_preview "$dry_run_autoremove_file" + elif [[ $dry_run_autoremove_exit -eq 124 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove preview timed out · run ${GRAY}brew autoremove --dry-run${NC} manually" + fi fi return 0 fi @@ -35,53 +66,31 @@ clean_homebrew() { fi fi [[ "$should_skip" == "true" ]] && return 0 - # Skip cleanup if cache is small; still run autoremove. + # Skip cleanup if cache is small; autoremove is previewed separately. local skip_cleanup=false local brew_cache_size=0 if [[ -d ~/Library/Caches/Homebrew ]]; then - brew_cache_size=$(run_with_timeout 3 du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') + brew_cache_size=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}') local du_exit=$? if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then skip_cleanup=true fi fi - # Spinner reflects whether cleanup is skipped. - if [[ -t 1 ]]; then - if [[ "$skip_cleanup" == "true" ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew autoremove (cleanup skipped)..." - else - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup and autoremove..." - fi - fi - # Run cleanup/autoremove in parallel with timeout guard per command. - local timeout_seconds=120 - local brew_tmp_file autoremove_tmp_file - local brew_pid autoremove_pid + local brew_tmp_file local brew_exit=0 - 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 & - brew_pid=$! - fi - autoremove_tmp_file=$(create_temp_file) - run_with_timeout "$timeout_seconds" brew autoremove > "$autoremove_tmp_file" 2>&1 & - autoremove_pid=$! - - if [[ -n "$brew_pid" ]]; then - wait "$brew_pid" 2> /dev/null || brew_exit=$? + if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Homebrew cleanup..."; fi + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_AUTOREMOVE=1 NONINTERACTIVE=1 \ + run_with_timeout "$cleanup_timeout" brew cleanup --prune=30 > "$brew_tmp_file" 2>&1 || brew_exit=$? + if [[ -t 1 ]]; then stop_inline_spinner; fi fi - wait "$autoremove_pid" 2> /dev/null || autoremove_exit=$? local brew_success=false if [[ "$skip_cleanup" == "false" && $brew_exit -eq 0 ]]; then brew_success=true fi - local autoremove_success=false - if [[ $autoremove_exit -eq 0 ]]; then - autoremove_success=true - fi - if [[ -t 1 ]]; then stop_inline_spinner; fi + # Process cleanup output and extract metrics # Summarize cleanup results. if [[ "$skip_cleanup" == "true" ]]; then @@ -104,23 +113,22 @@ clean_homebrew() { elif [[ $brew_exit -eq 124 ]]; then echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew cleanup timed out · run ${GRAY}brew cleanup${NC} manually" fi - # Process autoremove output - only show if packages were removed - # Only surface autoremove output when packages were removed. - if [[ "$autoremove_success" == "true" && -f "$autoremove_tmp_file" ]]; then - local autoremove_output - autoremove_output=$(cat "$autoremove_tmp_file" 2> /dev/null || echo "") - local removed_packages - removed_packages=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" 2> /dev/null || true) - if [[ $removed_packages -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed orphaned dependencies, ${removed_packages} packages" - fi - elif [[ $autoremove_exit -eq 124 ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove timed out · run ${GRAY}brew autoremove${NC} manually" + local autoremove_preview_file + autoremove_preview_file=$(create_temp_file) + local autoremove_preview_exit=0 + run_brew_autoremove_preview "$autoremove_preview_timeout" "$autoremove_preview_file" || autoremove_preview_exit=$? + if [[ $autoremove_preview_exit -eq 124 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove preview timed out · run ${GRAY}brew autoremove --dry-run${NC} manually" + elif [[ $autoremove_preview_exit -ne 0 ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Autoremove preview failed · run ${GRAY}brew autoremove --dry-run${NC} manually" + elif brew_autoremove_preview_has_items "$autoremove_preview_file"; then + show_brew_autoremove_preview "$autoremove_preview_file" + echo -e " ${GRAY}${ICON_WARNING}${NC} Homebrew autoremove skipped · run ${GRAY}brew autoremove${NC} manually" fi # Update cache timestamp on successful completion or when cleanup was intelligently skipped # This prevents repeated cache size checks within the 7-day window # Update cache timestamp when any work succeeded or was intentionally skipped. - if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]] || [[ "$autoremove_success" == "true" ]]; then + if [[ "$skip_cleanup" == "true" ]] || [[ "$brew_success" == "true" ]]; then ensure_user_file "$brew_cache_file" get_epoch_seconds > "$brew_cache_file" fi diff --git a/Resources/mole/lib/clean/caches.sh b/Resources/mole/lib/clean/caches.sh index 72892ce..e61266c 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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 "$MOLE_TIMEOUT_PKG_LIST_SEC" 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,14 +90,16 @@ 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" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} $browser_name Service Worker, would clean $(colorize_human_size "${cleaned_mb}MB"), ${protected_count} protected" fi note_activity if [[ "$spinner_was_running" == "true" ]]; then @@ -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,23 @@ 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) + if ! [[ "$_max_jobs" =~ ^[0-9]+$ ]] || [[ "$_max_jobs" -lt 1 ]]; then + _max_jobs=1 + elif [[ "$_max_jobs" -gt 8 ]]; then + _max_jobs=8 + fi + 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 +169,55 @@ 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[@]}" +} + +pycache_has_bytecode() { + local pycache_dir="$1" + [[ -d "$pycache_dir" ]] || return 1 + + local nullglob_was_set=0 + if shopt -q nullglob; then + nullglob_was_set=1 + fi + shopt -s nullglob + local -a bytecode_files=("$pycache_dir"/*.pyc "$pycache_dir"/*.pyo) + if [[ $nullglob_was_set -eq 0 ]]; then + shopt -u nullglob + fi + + [[ ${#bytecode_files[@]} -gt 0 ]] } # Scan a project root for supported build caches while pruning heavy subtrees. @@ -159,7 +229,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 +237,24 @@ 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 + pycache_has_bytecode "$match_path" || 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 +265,205 @@ 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: indirect array length by name; bash 3.2 has no nameref + eval 'group_count=${#'"$array_name"'[@]}' + [[ -z "$group_root" || "$group_count" -eq 0 ]] && return 0 + # eval: indirect array copy by name; bash 3.2 has no nameref + 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, $(colorize_human_size "$size_human") ${YELLOW}dry, ${skipped_count} skipped${NC}" + else + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Python bytecode cache · ${display_root}${NC}, ${YELLOW}${removed_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}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 +478,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..1818a41 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 @@ -25,22 +40,101 @@ clean_tool_cache() { fi return 0 } + +clean_corepack_cache() { + local corepack_home="${COREPACK_HOME:-$HOME/.cache/node/corepack}" + [[ -n "$corepack_home" && "$corepack_home" == /* ]] || return 0 + case "$corepack_home" in + / | "$HOME" | "$HOME/" | "$HOME/Library" | "$HOME/Library/") + debug_log "Skipping unsafe Corepack cache path: $corepack_home" + return 0 + ;; + esac + if command -v corepack > /dev/null 2>&1 && run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" corepack --version > /dev/null 2>&1; then + clean_tool_cache "Corepack cache" "$corepack_home" run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" corepack cache clean + else + safe_clean "$corepack_home"/* "Corepack cache" + fi +} + +clean_uv_cache() { + local uv_cache_path="$HOME/.cache/uv" + if command -v uv > /dev/null 2>&1 && run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" uv --version > /dev/null 2>&1; then + local detected_cache + detected_cache=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" uv cache dir 2> /dev/null || true) + if [[ -n "$detected_cache" && "$detected_cache" == /* ]]; then + uv_cache_path="$detected_cache" + fi + clean_tool_cache "uv cache" "$uv_cache_path" run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" uv cache prune + else + safe_clean "$uv_cache_path"/* "uv cache" + fi +} + +conda_cache_whitelisted() { + local root + for root in "$@"; do + [[ -n "$root" ]] || continue + if is_path_whitelisted "$root" 2> /dev/null || is_path_whitelisted "$root/.mole-cache-guard" 2> /dev/null; then + return 0 + fi + done + return 1 +} + +clean_conda_metadata_caches() { + local -a conda_pkg_roots=( + "$HOME/.conda/pkgs" + "$HOME/anaconda3/pkgs" + "$HOME/miniconda3/pkgs" + "$HOME/miniforge3/pkgs" + "$HOME/mambaforge/pkgs" + ) + if conda_cache_whitelisted "${conda_pkg_roots[@]}"; then + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} conda index/tarball/log caches · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} conda index/tarball/log caches · skipped (whitelist)" + fi + return 0 + fi + + local conda_cache_hint="$HOME/.conda/pkgs" + if command -v conda > /dev/null 2>&1 && run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" conda --version > /dev/null 2>&1; then + clean_tool_cache "conda index/tarball/log caches" "$conda_cache_hint" \ + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" conda clean --yes --index-cache --tarballs --logfiles + note_activity + return 0 + fi + + local root + for root in "${conda_pkg_roots[@]}"; do + [[ -d "$root" ]] || continue + debug_log "Conda package cache present but conda is unavailable, leaving for manual review: $root" + done +} + +gradle_daemon_running() { + pgrep -f "org.gradle.launcher.daemon" > /dev/null 2>&1 && return 0 + pgrep -f "GradleDaemon" > /dev/null 2>&1 && return 0 + return 1 +} + # npm/pnpm/yarn/bun caches. clean_dev_npm() { local npm_default_cache="$HOME/.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="" + npm_cache_path=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" npm config get cache 2> /dev/null) || npm_cache_path="" stop_section_spinner if [[ -z "$npm_cache_path" || "$npm_cache_path" != /* ]]; then npm_cache_path="$npm_default_cache" fi + clean_tool_cache "npm cache" "$npm_cache_path" npm cache clean --force note_activity fi @@ -75,34 +169,103 @@ 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="" + pnpm_store_path=$(COREPACK_ENABLE_DOWNLOAD_PROMPT=0 run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" pnpm store path 2> /dev/null) || pnpm_store_path="" stop_section_spinner - if [[ -n "$pnpm_store_path" && "$pnpm_store_path" != "$pnpm_default_store" ]]; then - safe_clean "$pnpm_default_store"/* "Orphaned pnpm store" + + 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" run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" pnpm store prune else - # pnpm not installed or not usable, just clean the default store directory - safe_clean "$pnpm_default_store"/* "pnpm store" + debug_log "pnpm is unavailable, leaving global pnpm store for manual review: $pnpm_default_store" fi + clean_corepack_cache + 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 "$MOLE_TIMEOUT_QUICK_DETECT_SEC" 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 "$MOLE_TIMEOUT_PKG_LIST_SEC" 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" + safe_clean ~/Library/Caches/Yarn/* "Yarn v1 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 "$MOLE_TIMEOUT_QUICK_DETECT_SEC" 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" safe_clean ~/.cache/poetry/* "Poetry cache" - safe_clean ~/.cache/uv/* "uv cache" + clean_uv_cache safe_clean ~/.cache/ruff/* "Ruff cache" safe_clean ~/.cache/mypy/* "MyPy cache" safe_clean ~/.pytest_cache/* "Pytest cache" @@ -110,8 +273,7 @@ clean_dev_python() { safe_clean ~/.cache/huggingface/* "Hugging Face cache" safe_clean ~/.cache/torch/* "PyTorch cache" safe_clean ~/.cache/tensorflow/* "TensorFlow cache" - safe_clean ~/.conda/pkgs/* "Conda packages cache" - safe_clean ~/anaconda3/pkgs/* "Anaconda packages cache" + clean_conda_metadata_caches safe_clean ~/.cache/wandb/* "Weights & Biases cache" } # Go build/module caches. @@ -136,22 +298,72 @@ 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 "$MOLE_TIMEOUT_QUICK_DETECT_SEC" 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:-false}" != "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" safe_clean ~/.cargo/git/* "Cargo git cache" safe_clean ~/.rustup/downloads/* "Rust downloads cache" } +# Ruby/gem ecosystem caches (not installed versions). +clean_dev_ruby() { + safe_clean ~/.rbenv/cache/* "rbenv download cache" + safe_clean ~/.gem/specs/* "gem spec cache" + safe_clean ~/.gem/ruby/*/cache/*.gem "gem package cache" + safe_clean ~/.bundle/cache/* "Ruby Bundler cache" +} +# Perl ecosystem caches (not installed modules). +clean_dev_perl() { + safe_clean ~/.cpan/build/* "CPAN build artifacts" + safe_clean ~/.cpan/sources/* "CPAN source cache" +} # Helper: Check for multiple versions in a directory. # Args: $1=directory, $2=tool_name, $3=list_command, $4=remove_command @@ -188,24 +400,38 @@ check_rust_toolchains() { "rustup toolchain list" } # Docker caches (guarded by daemon check). +find_orbstack_data_dir() { + local candidate + for candidate in "$HOME"/Library/Group\ Containers/*dev.orbstack/data; do + [[ -d "$candidate" ]] || continue + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + 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" + 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 + + local orb_data="" + orb_data=$(find_orbstack_data_dir 2> /dev/null || true) + if command -v orb > /dev/null 2>&1 || command -v orbctl > /dev/null 2>&1 || [[ -d "$HOME/.orbstack" || -n "$orb_data" ]]; then + local orb_size=0 + if [[ -n "$orb_data" ]]; then + orb_size=$(get_path_size_kb "$orb_data" 2> /dev/null || echo 0) + [[ "$orb_size" =~ ^[0-9]+$ ]] || orb_size=0 fi + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} OrbStack container data · skipped by default ($(bytes_to_human $((orb_size * 1024))))" + 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 "OrbStack daemon-managed data left for manual prune ($orb_size KB)" fi safe_clean ~/.docker/buildx/cache/* "Docker BuildX cache" } @@ -213,7 +439,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 @@ -339,6 +567,115 @@ clean_xcode_documentation_cache() { fi } +_coresimulator_cache_process_running() { + pgrep -x "Xcode" > /dev/null 2>&1 || + pgrep -x "Simulator" > /dev/null 2>&1 || + pgrep -x "CoreSimulatorService" > /dev/null 2>&1 || + pgrep -x "simdiskimaged" > /dev/null 2>&1 || + pgrep -f "com.apple.CoreSimulator" > /dev/null 2>&1 +} + +_xcode_xctest_devices_process_running() { + _coresimulator_cache_process_running || + pgrep -x "xcodebuild" > /dev/null 2>&1 || + pgrep -x "xctest" > /dev/null 2>&1 || + pgrep -x "XCTRunner" > /dev/null 2>&1 || + pgrep -f "com.apple.dt.XCTest" > /dev/null 2>&1 || + pgrep -f "XCTest" > /dev/null 2>&1 +} + +clean_xcode_xctest_devices() { + local xctest_devices_dir="${MOLE_XCODE_XCTEST_DEVICES_DIR:-$HOME/Library/Developer/XCTestDevices}" + [[ -d "$xctest_devices_dir" ]] || return 0 + + if _xcode_xctest_devices_process_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode or XCTest is running, skipping XCTestDevices cleanup" + note_activity + return 0 + fi + + safe_clean "$xctest_devices_dir" "Xcode XCTestDevices test data" +} + +clean_xcode_system_coresimulator_caches() { + local cache_root="${MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR:-/Library/Developer/CoreSimulator/Caches}" + [[ -d "$cache_root" ]] || return 0 + + if _coresimulator_cache_process_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} CoreSimulator is running, skipping system Simulator cache cleanup" + note_activity + return 0 + fi + + local -a cache_entries=() + while IFS= read -r -d '' entry; do + cache_entries+=("$entry") + done < <(command find "$cache_root" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) + + [[ ${#cache_entries[@]} -gt 0 ]] || return 0 + + local total_size_kb=0 + local entry + for entry in "${cache_entries[@]}"; do + local entry_size_kb + entry_size_kb=$(get_path_size_kb "$entry" 2> /dev/null || echo 0) + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + total_size_kb=$((total_size_kb + entry_size_kb)) + done + + if [[ "${DRY_RUN:-false}" == "true" ]]; then + local total_size_human + total_size_human=$(bytes_to_human "$((total_size_kb * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode Simulator system cache · would remove ${#cache_entries[@]} entries (${total_size_human})" + note_activity + return 0 + fi + + if ! has_sudo_session; then + if ! ensure_sudo_session "Cleaning Xcode Simulator system cache requires admin access"; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped (sudo denied)" + note_activity + return 0 + fi + fi + + local removed_count=0 + local removed_size_kb=0 + local skipped_count=0 + for entry in "${cache_entries[@]}"; do + if should_protect_path "$entry" || is_path_whitelisted "$entry"; then + skipped_count=$((skipped_count + 1)) + continue + fi + local entry_size_kb + entry_size_kb=$(get_path_size_kb "$entry" 2> /dev/null || echo 0) + [[ "$entry_size_kb" =~ ^[0-9]+$ ]] || entry_size_kb=0 + if safe_sudo_remove "$entry"; then + removed_count=$((removed_count + 1)) + removed_size_kb=$((removed_size_kb + entry_size_kb)) + fi + done + + 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_count -gt 0 ]]; then + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode Simulator system cache · removed ${removed_count} (${line_color}${removed_human}${NC}), skipped ${skipped_count} protected" + else + echo -e " ${line_color}${ICON_SUCCESS}${NC} Xcode Simulator system cache · removed ${removed_count} (${line_color}${removed_human}${NC})" + fi + note_activity + elif [[ $skipped_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode Simulator system cache · skipped ${skipped_count} protected, none removed" + note_activity + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode Simulator system cache · already clean" + note_activity + fi +} + # Clean old Xcode DeviceSupport versions, keeping the most recent ones. # Each version holds debug symbols (1-3 GB) for a specific iOS/watchOS/tvOS version. # Symbols regenerate automatically when a device running that version is connected. @@ -399,7 +736,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 @@ -436,7 +775,7 @@ _sim_runtime_size_kb() { local target_path="$1" local size_kb=0 if has_sudo_session; then - size_kb=$(sudo du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") + size_kb=$(sudo -n du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") else size_kb=$(du -skP "$target_path" 2> /dev/null | command awk 'NR==1 {print $1; exit}' || echo "0") fi @@ -615,10 +954,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 @@ -634,7 +975,9 @@ clean_xcode_simulator_runtime_volumes() { clean_dev_mobile() { check_android_ndk clean_xcode_documentation_cache + clean_xcode_system_coresimulator_caches clean_xcode_simulator_runtime_volumes + clean_xcode_xctest_devices if command -v xcrun > /dev/null 2>&1; then debug_log "Checking for unavailable Xcode simulators" @@ -646,9 +989,29 @@ 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. + # CoreSimulatorService may need >2s to warm up on cold boot, so we retry once + # with a longer timeout. See #890. 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" xcrun simctl list devices > /dev/null 2>&1; then + simctl_probe_ok=true + else + sleep 1 + if run_with_timeout 8 xcrun simctl list devices > /dev/null 2>&1; then # 8s: simctl retry after warmup, see lib/core/timeouts.sh + simctl_probe_ok=true + debug_log "simctl probe succeeded on retry (CoreSimulatorService warmup)" + else + debug_log "simctl probe failed after retry (5s + 8s timeouts)" + fi + 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 +1067,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 +1118,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 +1153,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" @@ -804,10 +1172,17 @@ clean_dev_jvm() { if declare -f clean_maven_repository > /dev/null 2>&1; then clean_maven_repository fi - safe_clean ~/.sbt/* "SBT cache" + safe_clean ~/.sbt/boot/* "SBT boot cache" + safe_clean ~/.sbt/launchers/* "SBT launcher cache" safe_clean ~/.ivy2/cache/* "Ivy cache" - safe_clean ~/.gradle/caches/* "Gradle cache" - safe_clean ~/.gradle/daemon/* "Gradle daemon" + safe_clean ~/.gradle/caches/build-cache-*/* "Gradle build cache" + safe_clean ~/.gradle/notifications/* "Gradle notifications cache" + if gradle_daemon_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Gradle daemon is running · daemon/workers cleanup skipped" + else + safe_clean ~/.gradle/daemon/* "Gradle daemon" + safe_clean ~/.gradle/workers/* "Gradle workers" + fi } # JetBrains Toolbox old IDE versions (keep current + recent backup). clean_dev_jetbrains_toolbox() { @@ -914,10 +1289,242 @@ 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 +# plus the version pointed at by the active CLI symlink (mtime alone is +# unreliable: Claude Code pre-downloads the next version before flipping +# the symlink, so newest mtime is not always the active version). +clean_versioned_agent_root() { + local versions_root="$1" + local label="$2" + local keep_previous="$3" + local active_path="${4:-}" + + [[ -d "$versions_root" ]] || return 0 + + local -a entries=() + local entry + 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" ]] && return 0 + + local -a sorted=() + local line + while IFS= read -r line; do + sorted+=("${line#* }") + done < <( + local version_entry + for version_entry in "${entries[@]}"; do + local mtime + mtime=$(stat -f%m "$version_entry" 2> /dev/null || echo "0") + printf '%s %s\n' "$mtime" "$version_entry" + done | sort -rn + ) + + local idx=0 + local target + for target in "${sorted[@]}"; do + if [[ -n "$active_path" && "$target" == "$active_path" ]]; then + continue + fi + if [[ $idx -lt $keep_previous ]]; then + idx=$((idx + 1)) + continue + fi + safe_clean "$target" "$label" + note_activity + idx=$((idx + 1)) + done +} + +count_versioned_agent_entries() { + local versions_root="$1" + local count=0 + local entry + + [[ -d "$versions_root" ]] || { + echo 0 + return 0 + } + + while IFS= read -r -d '' entry; do + local name + name=$(basename "$entry") + [[ "$name" == .* ]] && continue + [[ ! "$name" =~ ^[0-9] ]] && continue + count=$((count + 1)) + done < <(command find "$versions_root" -mindepth 1 -maxdepth 1 \( -type f -o -type d \) -print0 2> /dev/null) + + echo "$count" +} + +claude_desktop_sdk_version_is_safe() { + local sdk_version="${1:-}" + + [[ -n "$sdk_version" ]] || return 1 + [[ "$sdk_version" == .* ]] && return 1 + [[ "$sdk_version" == *"/"* ]] && return 1 + [[ "$sdk_version" == *".."* ]] && return 1 + [[ "$sdk_version" =~ ^[0-9] ]] || return 1 + return 0 +} + +claude_desktop_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Claude" > /dev/null 2>&1 && return 0 + pgrep -f "/Claude.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +claude_desktop_sdk_version() { + local claude_support="$1" + local sdk_file="$claude_support/claude-code-vm/.sdk-version" + [[ -f "$sdk_file" ]] || return 1 + + local sdk_version + sdk_version=$(head -n 1 "$sdk_file" 2> /dev/null | LC_ALL=C tr -d '[:space:]' || true) + claude_desktop_sdk_version_is_safe "$sdk_version" || return 1 + + printf '%s\n' "$sdk_version" +} + +clean_claude_desktop_bundled_versions() { + local keep_previous="$1" + local claude_support="$HOME/Library/Application Support/Claude" + [[ -d "$claude_support" ]] || return 0 + + local -a desktop_specs=( + "$claude_support/claude-code|Claude Desktop bundled Claude Code old version" + "$claude_support/claude-code-vm|Claude Desktop bundled Claude Code VM old version" + ) + + local has_multiple_versions=false + local spec + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + [[ -d "$versions_root" ]] || continue + + local version_count + version_count=$(count_versioned_agent_entries "$versions_root") + [[ "$version_count" =~ ^[0-9]+$ ]] || version_count=0 + if [[ "$version_count" -gt 1 ]]; then + has_multiple_versions=true + break + fi + done + + [[ "$has_multiple_versions" == "true" ]] || return 0 + + if claude_desktop_running; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} Claude Desktop bundled Claude Code cleanup skipped · Claude Desktop is running" + return 0 + fi + + local sdk_version="" + sdk_version=$(claude_desktop_sdk_version "$claude_support" || true) + if [[ -z "$sdk_version" ]]; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} Claude Desktop bundled Claude Code active version unknown · skipping cleanup" + return 0 + fi + + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + + if [[ ! -e "$versions_root/$sdk_version" ]]; then + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} $label active version unknown · skipping cleanup" + return 0 + fi + done + + for spec in "${desktop_specs[@]}"; do + local versions_root="${spec%%|*}" + local label="${spec#*|}" + [[ -d "$versions_root" ]] || continue + + local version_count + version_count=$(count_versioned_agent_entries "$versions_root") + [[ "$version_count" =~ ^[0-9]+$ ]] || version_count=0 + [[ "$version_count" -le 1 ]] && continue + + clean_versioned_agent_root "$versions_root" "$label" "$keep_previous" "$versions_root/$sdk_version" + done +} + +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/bin/claude" + "$HOME/.local/share/cursor-agent/versions|Cursor Agent old version|$HOME/.local/bin/cursor-agent" + "$HOME/.copilot/pkg/universal|GitHub Copilot CLI old version|" + ) + + local spec + for spec in "${agent_specs[@]}"; do + local versions_root="${spec%%|*}" + local rest="${spec#*|}" + local label="${rest%%|*}" + local active_symlink="${rest#*|}" + [[ "$active_symlink" == "$rest" ]] && active_symlink="" + [[ -d "$versions_root" ]] || continue + + local active_path="" + if [[ -n "$active_symlink" && -L "$active_symlink" ]]; then + if [[ ! -e "$active_symlink" ]]; then + echo -e " ${GRAY}${ICON_WARNING}${NC} $label active symlink is broken · skipping cleanup" + continue + fi + local target + target=$(readlink "$active_symlink" 2> /dev/null || true) + if [[ -n "$target" ]]; then + case "$target" in + /*) ;; + *) target="$(cd "$(dirname "$active_symlink")" 2> /dev/null && pwd -P)/$target" ;; + esac + local entry + for entry in "$versions_root"/*; do + [[ -e "$entry" ]] || continue + case "$target/" in + "$entry"/*) + active_path="$entry" + break + ;; + esac + done + fi + fi + + clean_versioned_agent_root "$versions_root" "$label" "$keep_previous" "$active_path" + done + + clean_claude_desktop_bundled_versions "$keep_previous" +} + # 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" @@ -953,6 +1560,320 @@ clean_dev_api_tools() { safe_clean ~/Library/Caches/com.charlesproxy.charles/* "Charles Proxy cache" safe_clean ~/Library/Caches/com.proxyman.NSProxy/* "Proxyman cache" } + +codex_desktop_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Codex" > /dev/null 2>&1 && return 0 + pgrep -f "/Codex.app/" > /dev/null 2>&1 && return 0 + return 1 +} + +# True when the Codex CLI or the Codex Desktop app is running. +codex_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + pgrep -x "codex" > /dev/null 2>&1 && return 0 + codex_desktop_running +} + +antigravity_or_gemini_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -x "Antigravity" > /dev/null 2>&1 && return 0 + pgrep -f "/Antigravity.app/" > /dev/null 2>&1 && return 0 + pgrep -x "gemini" > /dev/null 2>&1 && return 0 + pgrep -f "antigravity-browser-profile" > /dev/null 2>&1 && return 0 + return 1 +} + +chrome_devtools_mcp_running() { + command -v pgrep > /dev/null 2>&1 || return 1 + + pgrep -f "chrome-devtools-mcp" > /dev/null 2>&1 && return 0 + return 1 +} + +is_codex_runtime_active() { + local runtime_dir="$1" + [[ -d "$runtime_dir" ]] || return 1 + [[ -f "$runtime_dir/runtime.json" ]] || return 1 + [[ -d "$runtime_dir/dependencies/node" || -d "$runtime_dir/dependencies/python" ]] || return 1 + return 0 +} + +is_codex_runtime_stale() { + local runtime_dir="$1" + [[ -d "$runtime_dir" ]] || return 1 + + local runtime_name + runtime_name="$(basename "$runtime_dir")" + case "$runtime_name" in + tmp* | temp* | *.tmp | incomplete* | *.incomplete | *-incomplete | partial* | *.partial) + return 0 + ;; + esac + + if [[ ! -e "$runtime_dir/runtime.json" && ! -e "$runtime_dir/dependencies" ]]; then + return 0 + fi + + return 1 +} + +_codex_runtime_size_human() { + local target="$1" + local size_kb=0 + + if declare -f get_path_size_kb > /dev/null 2>&1; then + size_kb=$(get_path_size_kb "$target" 2> /dev/null || echo 0) + fi + + if declare -f bytes_to_human > /dev/null 2>&1; then + bytes_to_human "$((size_kb * 1024))" + else + printf '%s KB' "$size_kb" + fi +} + +clean_codex_runtimes() { + local runtime_root="$HOME/.cache/codex-runtimes" + [[ -d "$runtime_root" ]] || return 0 + + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$runtime_root"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Codex runtimes · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Codex runtimes · skipped (whitelist)" + fi + note_activity + return 0 + fi + + if codex_desktop_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex runtimes · skipped (Codex running)" + note_activity + return 0 + fi + + local size_human + size_human=$(_codex_runtime_size_human "$runtime_root") + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex runtimes · manual review (${size_human})" + note_activity + + local runtime_dir + while IFS= read -r -d '' runtime_dir; do + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$runtime_dir"; then + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Codex runtimes · would skip (whitelist)" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Codex runtimes · skipped (whitelist)" + fi + note_activity + continue + fi + + if is_codex_runtime_active "$runtime_dir"; then + debug_log "Codex runtime left for manual review: $runtime_dir" + continue + fi + + if is_codex_runtime_stale "$runtime_dir"; then + safe_clean "$runtime_dir" "Codex CLI runtimes" + else + debug_log "Codex runtime left for manual review: $runtime_dir" + fi + done < <(command find "$runtime_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) +} + +# Codex CLI and Desktop share state under ~/.codex. Keep it out of default +# cleanup so app indexes, sessions, credentials, and local thread state survive. +clean_codex_cli() { + local codex_root="$HOME/.codex" + [[ -d "$codex_root" ]] || return 0 + + if codex_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex CLI state · skipped (Codex running)" + note_activity + return 0 + fi + + echo -e " ${GRAY}${ICON_WARNING}${NC} Codex CLI state · skipped by default" + note_activity + debug_log "Codex CLI state left intact by default: $codex_root" +} + +# Shared Chromium Default profile caches that are safe to regenerate. +clean_chromium_default_caches() { + local profile_root="$1" + local label="$2" + + [[ -d "$profile_root" ]] || return 0 + + safe_clean "$profile_root/Default/Cache"/* "$label browser cache" + safe_clean "$profile_root/Default/Code Cache"/* "$label code cache" + safe_clean "$profile_root/Default/GPUCache"/* "$label GPU cache" + safe_clean "$profile_root/Default/DawnGraphiteCache"/* "$label Dawn cache" + safe_clean "$profile_root/Default/DawnWebGPUCache"/* "$label WebGPU cache" +} + +# Antigravity (Gemini) keeps a full Chromium profile under +# ~/.gemini/antigravity-browser-profile. Clean its regenerable browser +# caches, mirroring the Antigravity Electron cache cleanup in clean_dev_misc. +clean_antigravity_caches() { + local ag_profile="$HOME/.gemini/antigravity-browser-profile" + + if antigravity_or_gemini_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Antigravity/Gemini caches · skipped (Antigravity or Gemini running)" + note_activity + return 0 + fi + + if [[ -d "$ag_profile" ]]; then + clean_chromium_default_caches "$ag_profile" "Antigravity" + safe_clean "$ag_profile/GraphiteDawnCache"/* "Antigravity Graphite cache" + safe_clean "$ag_profile/component_crx_cache"/* "Antigravity component cache" + safe_clean "$ag_profile/extensions_crx_cache"/* "Antigravity extension cache" + clean_service_worker_cache "Antigravity" "$ag_profile/Default/Service Worker/CacheStorage" + fi + safe_clean "$HOME/.gemini/tmp"/* "Gemini CLI temp files" +} + +clean_chrome_devtools_mcp_caches() { + local mcp_profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + + if chrome_devtools_mcp_running; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome DevTools MCP caches · skipped (server running)" + note_activity + return 0 + fi + + [[ -d "$mcp_profile" ]] || return 0 + + clean_chromium_default_caches "$mcp_profile" "Chrome DevTools MCP" + safe_clean "$mcp_profile/Default/DawnCache"/* "Chrome DevTools MCP Dawn cache" + safe_clean "$mcp_profile/Default/GrShaderCache"/* "Chrome DevTools MCP shader cache" + safe_clean "$mcp_profile/Default/GraphiteDawnCache"/* "Chrome DevTools MCP Graphite cache" + safe_clean "$mcp_profile/GraphiteDawnCache"/* "Chrome DevTools MCP Graphite cache" + safe_clean "$mcp_profile/component_crx_cache"/* "Chrome DevTools MCP component cache" + safe_clean "$mcp_profile/extensions_crx_cache"/* "Chrome DevTools MCP extension cache" + + if declare -f clean_service_worker_cache > /dev/null 2>&1; then + clean_service_worker_cache "Chrome DevTools MCP" "$mcp_profile/Default/Service Worker/CacheStorage" + fi +} + +# Project roots scanned for agent worktrees. Override with a colon-separated +# MOLE_AGENT_WORKTREE_PATHS list (e.g. "$HOME/work:$HOME/oss"). +agent_worktree_search_roots() { + if [[ -n "${MOLE_AGENT_WORKTREE_PATHS:-}" ]]; then + local saved_ifs="$IFS" + IFS=':' + # shellcheck disable=SC2206 + local -a custom=($MOLE_AGENT_WORKTREE_PATHS) + IFS="$saved_ifs" + [[ ${#custom[@]} -gt 0 ]] && printf '%s\n' "${custom[@]}" + return 0 + fi + printf '%s\n' \ + "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/Projects" \ + "$HOME/GitHub" "$HOME/Workspace" "$HOME/Repos" \ + "$HOME/Development" "$HOME/www" "$HOME/src" +} + +# Returns 0 only when a worktree is safe to remove: no uncommitted or untracked +# changes, and no commits reachable from HEAD that are missing from every +# remote-tracking ref. A repo with no remotes therefore keeps every worktree, +# which is the intended conservative behavior. +agent_worktree_is_disposable() { + local wt="$1" + run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" git -C "$wt" rev-parse --is-inside-work-tree > /dev/null 2>&1 || return 1 + if [[ -n "$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" git -C "$wt" status --porcelain 2> /dev/null)" ]]; then + return 1 + fi + # A clean, pushed worktree can still hold stashed work; keep it if so. + if [[ -n "$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" git -C "$wt" stash list 2> /dev/null)" ]]; then + return 1 + fi + local local_only + local_only=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" git -C "$wt" rev-list --count HEAD --not --remotes 2> /dev/null || echo 1) + [[ "$local_only" =~ ^[0-9]+$ ]] || return 1 + [[ "$local_only" -eq 0 ]] || return 1 + return 0 +} + +# AI coding agents (Claude Code and similar) create isolated git worktrees under +# /.claude/worktrees/ for background or parallel runs. Each is a full +# checkout (node_modules, build output, and so on) that is never cleaned up +# automatically, so they accumulate across projects. They may also hold +# uncommitted or unpushed work, so this is skipped by default. Set +# MOLE_AGENT_WORKTREES=1 to remove only the worktrees that are fully clean. +clean_dev_agent_worktrees() { + local scan_timeout=20 + + local -a containers=() + local root container + while IFS= read -r root; do + [[ -d "$root" ]] || continue + while IFS= read -r -d '' container; do + containers+=("$container") + done < <(run_with_timeout "$scan_timeout" command find "$root" -maxdepth 6 -type d -path "*/.claude/worktrees" -prune -print0 2> /dev/null) + done < <(agent_worktree_search_roots) + + [[ ${#containers[@]} -gt 0 ]] || return 0 + + local force="${MOLE_AGENT_WORKTREES:-}" + + # Default: report reclaimable size only, never delete (may hold agent work). + if [[ -z "$force" || "$force" == "0" ]]; then + local total_kb=0 size_kb=0 count=0 wt + for container in "${containers[@]}"; do + while IFS= read -r -d '' wt; do + size_kb=$(get_path_size_kb "$wt" 2> /dev/null || echo 0) + [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 + total_kb=$((total_kb + size_kb)) + count=$((count + 1)) + done < <(command find "$container" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + done + [[ "$count" -gt 0 ]] || return 0 + note_activity + echo -e " ${GRAY}${ICON_WARNING}${NC} AI agent worktrees · skipped by default ($count in .claude/worktrees, $(bytes_to_human $((total_kb * 1024))))" + echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Remove clean worktrees: MOLE_AGENT_WORKTREES=1 mo clean${NC}" + debug_log "AI agent worktrees left intact by default ($count dirs, $total_kb KB)" + return 0 + fi + + # Opt-in: remove only fully clean worktrees; keep anything with unsaved work. + local parent_repo kept=0 + local wt + for container in "${containers[@]}"; do + parent_repo="${container%/.claude/worktrees}" + while IFS= read -r -d '' wt; do + if should_protect_path "$wt"; then + continue + fi + if agent_worktree_is_disposable "$wt"; then + safe_clean "$wt" "AI agent worktree" + # Tidy the parent repo's worktree registry once the dir is gone. + # In dry-run the directory remains, so prune is skipped. Agent + # worktrees are usually git-locked, and a plain prune skips a + # locked entry even when its directory is missing, so unlock it + # first and prune with --expire=now to drop the stale entry. + if [[ ! -d "$wt" && -e "$parent_repo/.git" ]]; then + run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" git -C "$parent_repo" worktree unlock "$wt" > /dev/null 2>&1 || true + run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" git -C "$parent_repo" worktree prune --expire=now > /dev/null 2>&1 || true + fi + else + kept=$((kept + 1)) + note_activity + echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Kept agent worktree (unsaved work): ${wt/#$HOME/~}${NC}" + fi + done < <(command find "$container" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null) + done + if [[ "$kept" -gt 0 ]]; then + debug_log "Kept $kept AI agent worktree(s) with unsaved work" + fi +} + # Misc dev tool caches. clean_dev_misc() { safe_clean ~/Library/Caches/com.unity3d.*/* "Unity cache" @@ -962,23 +1883,68 @@ clean_dev_misc() { safe_clean ~/Library/Caches/SentryCrash/* "Sentry crash reports" safe_clean ~/Library/Caches/KSCrash/* "KSCrash reports" safe_clean ~/Library/Caches/com.crashlytics.data/* "Crashlytics data" - safe_clean ~/Library/Application\ Support/Antigravity/Cache/* "Antigravity cache" - safe_clean ~/Library/Application\ Support/Antigravity/Code\ Cache/* "Antigravity code cache" - safe_clean ~/Library/Application\ Support/Antigravity/GPUCache/* "Antigravity GPU cache" - safe_clean ~/Library/Application\ Support/Antigravity/DawnGraphiteCache/* "Antigravity Dawn cache" - safe_clean ~/Library/Application\ Support/Antigravity/DawnWebGPUCache/* "Antigravity WebGPU cache" + if [[ -d ~/Library/Application\ Support/Antigravity ]]; then + safe_clean ~/Library/Application\ Support/Antigravity/Cache/* "Antigravity cache" + safe_clean ~/Library/Application\ Support/Antigravity/Code\ Cache/* "Antigravity code cache" + safe_clean ~/Library/Application\ Support/Antigravity/GPUCache/* "Antigravity GPU cache" + safe_clean ~/Library/Application\ Support/Antigravity/DawnGraphiteCache/* "Antigravity Dawn cache" + safe_clean ~/Library/Application\ Support/Antigravity/DawnWebGPUCache/* "Antigravity WebGPU cache" + fi + # Antigravity browser profile caches (~/.gemini) + clean_antigravity_caches # Filo (Electron) - safe_clean ~/Library/Application\ Support/Filo/production/Cache/* "Filo cache" - safe_clean ~/Library/Application\ Support/Filo/production/Code\ Cache/* "Filo code cache" - safe_clean ~/Library/Application\ Support/Filo/production/GPUCache/* "Filo GPU cache" - safe_clean ~/Library/Application\ Support/Filo/production/DawnGraphiteCache/* "Filo Dawn cache" - safe_clean ~/Library/Application\ Support/Filo/production/DawnWebGPUCache/* "Filo WebGPU cache" + if [[ -d ~/Library/Application\ Support/Filo ]]; then + safe_clean ~/Library/Application\ Support/Filo/production/Cache/* "Filo cache" + safe_clean ~/Library/Application\ Support/Filo/production/Code\ Cache/* "Filo code cache" + safe_clean ~/Library/Application\ Support/Filo/production/GPUCache/* "Filo GPU cache" + safe_clean ~/Library/Application\ Support/Filo/production/DawnGraphiteCache/* "Filo Dawn cache" + safe_clean ~/Library/Application\ Support/Filo/production/DawnWebGPUCache/* "Filo WebGPU cache" + fi # Claude (Electron) - safe_clean ~/Library/Application\ Support/Claude/Cache/* "Claude cache" - safe_clean ~/Library/Application\ Support/Claude/Code\ Cache/* "Claude code cache" - 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" + if [[ -d ~/Library/Application\ Support/Claude ]]; then + safe_clean ~/Library/Application\ Support/Claude/Cache/* "Claude cache" + safe_clean ~/Library/Application\ Support/Claude/Code\ Cache/* "Claude code cache" + 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" + fi + # Qoder (VS Code fork, Electron) + if [[ -d ~/Library/Application\ Support/Qoder ]]; then + 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" + fi + # Prisma ORM engine binaries cache + safe_clean ~/.cache/prisma/* "Prisma cache" + # OpenCode AI tool cache + safe_clean ~/.cache/opencode/* "OpenCode cache" + # OpenCode CLI session state (~/.cache side above covers Electron cache) + if [[ -d ~/.local/share/opencode ]]; then + safe_clean ~/.local/share/opencode/snapshot/* "OpenCode snapshots" + safe_clean ~/.local/share/opencode/log/* "OpenCode logs" + fi + # Codex Desktop runtimes contain active Node/Python dependencies. + clean_codex_runtimes + # Codex CLI working-directory caches (~/.codex) + clean_codex_cli + # Cursor Agent session logs (versions cleaned separately in clean_dev_ai_agents) + [[ -d "$HOME/.local/share/cursor-agent" ]] && safe_find_delete "$HOME/.local/share/cursor-agent" "*.log" "$MOLE_LOG_AGE_DAYS" "f" + # Playwright cached browser binaries + safe_clean ~/Library/Caches/ms-playwright/* "Playwright browsers" + # Chrome DevTools MCP keeps a Chromium profile; clean only rebuildable caches. + clean_chrome_devtools_mcp_caches + # Claude Code state under ~/.claude can include persistent memory, + # plugin registry data, hooks, and session context. Do not clean it + # automatically; users can remove specific paths manually if needed. + # Wondershare orphan installer payload (bundle ID differs from live app) + safe_clean ~/Library/Application\ Support/com.wondershare.Installer/* "Wondershare installer payload" } # Shell and VCS leftovers. clean_dev_shell() { @@ -997,10 +1963,6 @@ clean_dev_network() { safe_clean ~/Library/Caches/curl/* "macOS curl cache" safe_clean ~/Library/Caches/wget/* "macOS wget cache" } -# Orphaned SQLite temp files (-shm/-wal). Disabled due to low ROI. -clean_sqlite_temp_files() { - return 0 -} # Elixir/Erlang ecosystem. # Note: ~/.mix/archives contains installed Mix tools - excluded from cleanup clean_dev_elixir() { @@ -1015,28 +1977,19 @@ clean_dev_haskell() { clean_dev_ocaml() { safe_clean ~/.opam/download-cache/* "Opam cache" } -# Editor caches. -# Note: ~/Library/Application Support/Code/User/workspaceStorage contains workspace settings - excluded from cleanup -clean_dev_editors() { - safe_clean ~/Library/Caches/com.microsoft.VSCode/Cache/* "VS Code cached data" - safe_clean ~/Library/Application\ Support/Code/CachedData/* "VS Code cached data" - safe_clean ~/Library/Application\ Support/Code/DawnGraphiteCache/* "VS Code Dawn cache" - 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" - safe_clean ~/Library/Caches/Zed/* "Zed cache" -} # Main developer tools cleanup sequence. clean_developer_tools() { stop_section_spinner # CLI tools and languages - clean_sqlite_temp_files clean_dev_npm clean_dev_python clean_dev_go + clean_dev_mise clean_dev_rust check_rust_toolchains + clean_dev_ruby + clean_dev_perl clean_dev_docker clean_dev_cloud clean_dev_nix @@ -1046,6 +1999,9 @@ clean_developer_tools() { clean_dev_mobile clean_dev_jvm clean_dev_jetbrains_toolbox + clean_dev_jetbrains_logs + clean_dev_ai_agents + clean_dev_agent_worktrees 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..a323ba4 100644 --- a/Resources/mole/lib/clean/hints.sh +++ b/Resources/mole/lib/clean/hints.sh @@ -54,6 +54,198 @@ hint_get_path_size_kb_with_timeout() { printf '%s\n' "$size_kb" } +# shellcheck disable=SC2329 +hint_collect_child_dirs_with_timeout() { + local parent="$1" + local output_file="$2" + local timeout_seconds="${3:-1}" + + [[ -d "$parent" ]] || return 1 + : > "$output_file" || return 1 + + # 1s: shallow directory listing should be near-instant on healthy local + # paths. Slow/cloud-backed roots are skipped so `mo clean` never appears + # stuck while rendering this non-destructive hint. + run_with_timeout "$timeout_seconds" find "$parent" -mindepth 1 -maxdepth 1 -type d -print0 > "$output_file" 2> /dev/null +} + +# shellcheck disable=SC2329 +hint_extract_launch_agent_program_path() { + local plist="$1" + local program="" + + if ! program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null); then + program="" + fi + if [[ -z "$program" ]]; then + if ! program=$(plutil -extract Program raw "$plist" 2> /dev/null); then + program="" + fi + fi + + printf '%s\n' "$program" +} + +# shellcheck disable=SC2329 +hint_launch_agent_has_mach_services() { + local plist="$1" + plutil -extract MachServices raw "$plist" > /dev/null 2>&1 +} + +# shellcheck disable=SC2329 +hint_extract_launch_agent_associated_bundle() { + local plist="$1" + local associated="" + + if ! associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null); then + associated="" + fi + if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then + if ! associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null); then + associated="" + fi + 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 +hint_normalize_app_match_text() { + printf '%s' "${1:-}" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C tr -cd '[:alnum:]' +} + +# shellcheck disable=SC2329 +hint_dotdir_candidate_matches_text() { + local text="$1" + shift || true + [[ $# -gt 0 ]] || return 1 + + local normalized_text + normalized_text=$(hint_normalize_app_match_text "$text") + [[ -n "$normalized_text" ]] || return 1 + + local candidate normalized_candidate + for candidate in "$@"; do + normalized_candidate=$(hint_normalize_app_match_text "$candidate") + [[ ${#normalized_candidate} -ge 4 ]] || continue + if [[ "$normalized_text" == "$normalized_candidate" || "$normalized_text" == *"$normalized_candidate"* ]]; then + return 0 + fi + done + + return 1 +} + +# shellcheck disable=SC2329 +hint_collect_installed_gui_app_match_texts() { + local -a app_roots=( + "/Applications" + "/Applications/Setapp" + "/Applications/Utilities" + "$HOME/Applications" + "/Library/Input Methods" + "$HOME/Library/Input Methods" + "$HOME/Library/Application Support/Setapp/Applications" + ) + + local app_root app_path app_name info value + for app_root in "${app_roots[@]}"; do + [[ -d "$app_root" ]] || continue + while IFS= read -r -d '' app_path; do + [[ -n "$app_path" ]] || continue + + app_name="${app_path##*/}" + app_name="${app_name%.app}" + printf '%s\n' "$app_name" + + info="$app_path/Contents/Info.plist" + [[ -f "$info" ]] || continue + for value in \ + "$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || echo "")" \ + "$(plutil -extract CFBundleName raw "$info" 2> /dev/null || echo "")" \ + "$(plutil -extract CFBundleDisplayName raw "$info" 2> /dev/null || echo "")"; do + [[ -n "$value" && "$value" != "(null)" ]] && printf '%s\n' "$value" + done + done < <(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" find "$app_root" -maxdepth 2 -name "*.app" -print0 2> /dev/null || true) + done + + local -a cask_roots=( + "/opt/homebrew/Caskroom" + "/usr/local/Caskroom" + ) + + local cask_root cask_dir cask_name + for cask_root in "${cask_roots[@]}"; do + [[ -d "$cask_root" ]] || continue + while IFS= read -r -d '' cask_dir; do + [[ -n "$cask_dir" ]] || continue + cask_name="${cask_dir##*/}" + printf '%s\n' "$cask_name" + done < <(run_with_timeout 1 find "$cask_root" -mindepth 1 -maxdepth 1 -type d -print0 2> /dev/null || true) # 1s: shallow brew cask dir list, see lib/core/timeouts.sh + done +} + +# shellcheck disable=SC2329 +hint_dotdir_owned_by_installed_gui_app() { + local installed_app_texts="$1" + shift || true + [[ -n "$installed_app_texts" && $# -gt 0 ]] || return 1 + + local value + while IFS= read -r value; do + [[ -n "$value" ]] || continue + if hint_dotdir_candidate_matches_text "$value" "$@"; then + return 0 + fi + done <<< "$installed_app_texts" + + return 1 +} + # shellcheck disable=SC2329 record_project_artifact_hint() { local path="$1" @@ -100,11 +292,21 @@ probe_project_artifact_hints() { PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0 PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=false local max_projects=200 local max_projects_per_root=0 local max_nested_per_project=120 local max_matches=12 + local list_timeout_seconds=1 + + # Wall-clock ceiling for the whole walk. Per-listing finds are already + # capped at 1s, but with up to max_projects roots the cumulative scan can + # stretch into minutes on busy machines and look hung (#1053). Checked + # between iterations so the section degrades gracefully instead of stalling. + local hint_budget_seconds="${MOLE_TIMEOUT_HINT_SCAN_SEC:-15}" + [[ "$hint_budget_seconds" =~ ^[0-9]+$ ]] || hint_budget_seconds=15 + local scan_deadline=$((SECONDS + hint_budget_seconds)) local -a target_names=() while IFS= read -r target_name; do @@ -125,17 +327,17 @@ probe_project_artifact_hints() { fi [[ $max_projects_per_root -gt $max_projects ]] && max_projects_per_root=$max_projects - local nullglob_was_set=0 - if shopt -q nullglob; then - nullglob_was_set=1 - fi - shopt -s nullglob - local scanned_projects=0 local stop_scan=false local root project_dir nested_dir target_name candidate + local project_dirs_file nested_dirs_file for root in "${scan_roots[@]}"; do + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + break + fi [[ -d "$root" ]] || continue local root_projects_scanned=0 @@ -162,9 +364,26 @@ probe_project_artifact_hints() { continue fi - for project_dir in "$root"/*/; do + project_dirs_file=$(mktemp_file "project_artifact_dirs") || { + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + continue + } + if ! hint_collect_child_dirs_with_timeout "$root" "$project_dirs_file" "$list_timeout_seconds"; then + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + rm -f "$project_dirs_file" + continue + fi + + while IFS= read -r -d '' project_dir; do + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + stop_scan=true + break + fi [[ -d "$project_dir" ]] || continue - project_dir="${project_dir%/}" local project_name project_name=$(basename "$project_dir") @@ -191,10 +410,34 @@ probe_project_artifact_hints() { done [[ "$stop_scan" == "true" ]] && break + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + stop_scan=true + break + fi + local nested_count=0 - for nested_dir in "$project_dir"/*/; do + nested_dirs_file=$(mktemp_file "project_artifact_nested") || { + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + continue + } + if ! hint_collect_child_dirs_with_timeout "$project_dir" "$nested_dirs_file" "$list_timeout_seconds"; then + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + PROJECT_ARTIFACT_HINT_TRUNCATED=true + rm -f "$nested_dirs_file" + continue + fi + + while IFS= read -r -d '' nested_dir; do + if [[ $SECONDS -ge $scan_deadline ]]; then + PROJECT_ARTIFACT_HINT_TRUNCATED=true + PROJECT_ARTIFACT_HINT_SCAN_SKIPPED=true + stop_scan=true + break + fi [[ -d "$nested_dir" ]] || continue - nested_dir="${nested_dir%/}" local nested_name nested_name=$(basename "$nested_dir") @@ -217,20 +460,16 @@ probe_project_artifact_hints() { record_project_artifact_hint "$candidate" fi done - - [[ "$stop_scan" == "true" ]] && break - done + done < "$nested_dirs_file" + rm -f "$nested_dirs_file" [[ "$stop_scan" == "true" ]] && break - done + done < "$project_dirs_file" + rm -f "$project_dirs_file" [[ "$stop_scan" == "true" ]] && break done - if [[ $nullglob_was_set -eq 0 ]]; then - shopt -u nullglob - fi - if [[ $PROJECT_ARTIFACT_HINT_COUNT -gt 0 ]]; then PROJECT_ARTIFACT_HINT_DETECTED=true fi @@ -272,6 +511,14 @@ show_system_data_hint_notice() { "$HOME/Library/Mail" ) + local orbstack_data + for orbstack_data in "$HOME"/Library/Group\ Containers/*dev.orbstack/data; do + [[ -d "$orbstack_data" ]] || continue + labels+=("OrbStack data") + paths+=("$orbstack_data") + break + done + local i for i in "${!paths[@]}"; do local path="${paths[$i]}" @@ -312,6 +559,11 @@ show_project_artifact_hint_notice() { probe_project_artifact_hints if [[ "$PROJECT_ARTIFACT_HINT_DETECTED" != "true" ]]; then + if [[ "${PROJECT_ARTIFACT_HINT_SCAN_SKIPPED:-false}" == "true" ]]; then + note_activity + echo -e " ${YELLOW}${ICON_WARNING}${NC} Skipped slow project artifact scan" + echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge" + fi return 0 fi @@ -349,5 +601,348 @@ show_project_artifact_hint_notice() { if [[ -n "$example_text" ]]; then echo -e " ${GRAY}${ICON_SUBLIST}${NC} Examples: ${GRAY}${example_text}${NC}" fi - echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge" + if [[ "${PROJECT_ARTIFACT_HINT_SCAN_SKIPPED:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Some slow locations were skipped" + fi + local review_command="mo purge" + if [[ $PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES -gt 0 && $PROJECT_ARTIFACT_HINT_ESTIMATED_KB -eq 0 ]]; then + review_command="mo purge --include-empty" + fi + echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: ${review_command}" +} + +# 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 [[ -z "$program" ]] && hint_launch_agent_has_mach_services "$plist"; then + continue + fi + 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" +} + +readonly ORPHAN_DOTDIR_KNOWN_SAFE=( + # Shell + ".bash_history" ".bash_profile" ".bash_sessions" ".bashrc" + ".zshrc" ".zsh_history" ".zsh_sessions" ".zprofile" ".zshenv" ".zlogout" ".zcompdump" + ".profile" ".inputrc" ".hushlogin" + ".oh-my-zsh" ".zinit" ".zplug" ".antigen" ".p10k.zsh" + ".config" ".local" ".cache" + # Security + ".ssh" ".gnupg" ".gpg" ".pass""word-store" + # Git + ".gitconfig" ".gitignore_global" ".git-credentials" ".gitattributes_global" + # Language tools (Mole handles their caches separately) + ".pyenv" ".rbenv" ".nvm" ".nodenv" ".goenv" ".jenv" + ".rustup" ".cargo" ".ghcup" ".stack" ".cabal" + ".sdkman" ".jabba" ".asdf" ".mise" ".rtx" ".volta" ".fnm" + ".deno" ".bun" + # Package managers + ".npm" ".yarn" ".pnpm" ".bundle" ".gem" + ".composer" ".nuget" ".pub-cache" + ".m2" ".gradle" ".sbt" ".ivy2" ".lein" + ".hex" ".mix" ".opam" ".cpan" ".cpanm" + ".conda" ".virtualenvs" ".pipx" + # Cloud / devops + ".docker" ".kube" ".minikube" ".helm" + ".aws" ".azure" ".terraform" ".vagrant" + # Editors / IDEs + ".vim" ".vimrc" ".viminfo" ".emacs" ".emacs.d" ".doom.d" ".nano" ".nanorc" + ".vscode" ".cursor" ".atom" + # AI tools + ".claude" ".copilot" ".ollama" + # macOS system + ".Trash" ".Trashes" ".CFUserTextEncoding" ".DS_Store" ".cups" ".dropbox" + # Mobile / native dev + ".android" ".cocoapods" ".fastlane" ".expo" ".react-native" ".swiftpm" + # Terminal / misc + ".tmux" ".screen" ".wget-hsts" ".curlrc" ".netrc" ".wgetrc" + ".putty" + ".lesshst" ".python_history" ".node_repl_history" + ".irb_history" ".pry_history" + ".jupyter" ".ipython" ".matplotlib" ".keras" ".torch" + ".psql_history" ".mysql_history" ".sqlite_history" ".rediscli_history" ".mongo" ".dbshell" + # Homebrew / VCS + ".homebrew" ".hg" ".hgrc" ".svn" ".bazaar" + # Fly.io / Gemini (Tang uses these) + ".fly" ".gemini" +) + +# Standard locations for installed GUI apps. Overridable from tests. +_MOLE_DOTDIR_OWNER_APP_ROOTS=( + "/Applications" + "/Applications/Setapp" + "$HOME/Applications" +) + +# Emit every alnum token found in installed .app filenames + brew casks, +# lowercased, one per line. Caller filters/dedups. +# shellcheck disable=SC2329 +_dotdir_owner_collect_tokens() { + local root entry name + for root in "${_MOLE_DOTDIR_OWNER_APP_ROOTS[@]}"; do + [[ -d "$root" ]] || continue + for entry in "$root"/*.app; do + [[ -e "$entry" ]] || continue + name=$(basename "$entry") + name="${name%.app}" + printf '%s\n' "$name" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C tr -cs 'a-z0-9' '\n' + done + done + + if command -v brew > /dev/null 2>&1; then + local cask_list="" + cask_list=$(HOMEBREW_NO_ENV_HINTS=1 run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" brew list --cask 2> /dev/null) || true + if [[ -n "$cask_list" ]]; then + printf '%s\n' "$cask_list" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C tr -cs 'a-z0-9' '\n' + fi + fi +} + +# Return 0 if any ≥4-char token from `name` matches a token harvested from +# installed `.app` bundles or Homebrew casks. Cached for 5 minutes. Short +# tokens (<4 chars) on either side are ignored to avoid false matches like +# `.ai-old` vs `AI.app`. See issue #872. +# shellcheck disable=SC2329 +dotdir_has_owning_gui_app() { + local name="$1" + [[ -z "$name" ]] && return 1 + [[ ${#name} -lt 4 ]] && return 1 + + local cache_dir="$HOME/.cache/mole" + local cache_file="$cache_dir/installed_app_tokens_cache" + local cache_ttl=300 + local now + now=$(date +%s) + + local rebuild=1 + if [[ -f "$cache_file" ]]; then + local mtime + mtime=$(get_file_mtime "$cache_file" 2> /dev/null || echo 0) + if [[ -n "$mtime" ]] && [[ $((now - mtime)) -lt $cache_ttl ]]; then + rebuild=0 + fi + fi + if [[ $rebuild -eq 1 ]]; then + ensure_user_dir "$cache_dir" 2> /dev/null || true + _dotdir_owner_collect_tokens 2> /dev/null | + LC_ALL=C awk 'length($0) >= 4' | + LC_ALL=C sort -u > "$cache_file" 2> /dev/null || return 1 + fi + [[ -s "$cache_file" ]] || return 1 + + local name_lower + name_lower=$(printf '%s' "$name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + local tok + while IFS= read -r tok; do + [[ -z "$tok" ]] && continue + [[ ${#tok} -ge 4 ]] || continue + if LC_ALL=C grep -Fxq "$tok" "$cache_file" 2> /dev/null; then + return 0 + fi + done < <(printf '%s\n' "$name_lower" | LC_ALL=C tr -cs 'a-z0-9' '\n') + + return 1 +} + +# Collect Claude Code plugin name tokens (the segment before '@' in a +# "plugin@marketplace" identifier) from the user's plugin config. Plugins own +# state directories such as ~/.cc-safety-net without installing a matching PATH +# binary or GUI app, so their dotdirs must not be flagged as orphans. +hint_collect_claude_plugin_tokens() { + local settings="$HOME/.claude/settings.json" + local installed="$HOME/.claude/plugins/installed_plugins.json" + # `|| true` keeps a no-match grep (the common case: Claude Code installed + # but no plugins) from aborting under `set -euo pipefail`. + { + if [[ -f "$settings" ]]; then + plutil -extract enabledPlugins json -o - "$settings" 2> /dev/null | + LC_ALL=C grep -oE '"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+"' || true + fi + if [[ -f "$installed" ]]; then + LC_ALL=C grep -oE '"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+"' "$installed" 2> /dev/null || true + fi + } | sed -E 's/^"//; s/@.*$//' | LC_ALL=C sort -u +} + +# Return 0 when a dotdir name embeds an enabled Claude Code plugin token. +# e.g. dotdir "cc-safety-net" embeds plugin token "safety-net". +hint_dotdir_owned_by_claude_plugin() { + local dotdir_name="$1" + local tokens="$2" + [[ -n "$tokens" ]] || return 1 + local token + while IFS= read -r token; do + [[ ${#token} -ge 4 ]] || continue + if [[ "$dotdir_name" == *"$token"* ]]; then + return 0 + fi + done <<< "$tokens" + return 1 +} + +# Detect ~/. directories that may belong to uninstalled CLI tools. +# shellcheck disable=SC2329 +show_orphan_dotdir_hint_notice() { + local max_hits=5 + local age_days="${MOLE_DOTDIR_ORPHAN_AGE_DAYS:-60}" + local now + now=$(date +%s) + + local -a labels=() + local -a details=() + local installed_gui_app_texts="" + local installed_gui_app_texts_loaded=false + local claude_plugin_tokens="" + local claude_plugin_tokens_loaded=false + + while IFS= read -r dotdir; do + [[ -d "$dotdir" ]] || continue + local basename + basename=$(basename "$dotdir") + + local is_safe=false + local safe_name + for safe_name in "${ORPHAN_DOTDIR_KNOWN_SAFE[@]}"; do + if [[ "$basename" == "$safe_name" ]]; then + is_safe=true + break + fi + done + [[ "$is_safe" == "true" ]] && continue + + if declare -f is_path_whitelisted > /dev/null && is_path_whitelisted "$dotdir"; then + continue + fi + + local mtime + mtime=$(get_file_mtime "$dotdir" 2> /dev/null) || continue + local age_d=$(((now - mtime) / 86400)) + [[ $age_d -lt $age_days ]] && continue + + local name="${basename#.}" + local -a candidates=("$name") + local dehyphen="${name//-/_}" + [[ "$dehyphen" != "$name" ]] && candidates+=("$dehyphen") + local stripped="${name//-/}" + [[ "$stripped" != "$name" && "$stripped" != "$dehyphen" ]] && candidates+=("$stripped") + local no_suffix="${name%-cli}" + [[ "$no_suffix" != "$name" ]] && candidates+=("$no_suffix") + no_suffix="${name%-temp}" + [[ "$no_suffix" != "$name" ]] && candidates+=("$no_suffix") + no_suffix="${name%-data}" + [[ "$no_suffix" != "$name" ]] && candidates+=("$no_suffix") + + local has_binary=false + local c + for c in "${candidates[@]}"; do + if command -v "$c" > /dev/null 2>&1; then + has_binary=true + break + fi + done + [[ "$has_binary" == "true" ]] && continue + + if [[ "$claude_plugin_tokens_loaded" != "true" ]]; then + claude_plugin_tokens=$(hint_collect_claude_plugin_tokens) + claude_plugin_tokens_loaded=true + fi + if hint_dotdir_owned_by_claude_plugin "$name" "$claude_plugin_tokens"; then + continue + fi + + if [[ "$installed_gui_app_texts_loaded" != "true" ]]; then + installed_gui_app_texts=$(hint_collect_installed_gui_app_match_texts) + installed_gui_app_texts_loaded=true + fi + if hint_dotdir_owned_by_installed_gui_app "$installed_gui_app_texts" "${candidates[@]}"; then + continue + fi + + if [[ -d "$HOME/Library/LaunchAgents" ]]; then + if run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" grep -rlq "$basename" "$HOME/Library/LaunchAgents/" 2> /dev/null; then + continue + fi + fi + + if dotdir_has_owning_gui_app "$name"; then + continue + fi + + local size_human="" + local size_kb + if size_kb=$(hint_get_path_size_kb_with_timeout "$dotdir" 0.8); then + size_human=" ($(bytes_to_human $((size_kb * 1024))))" + fi + + # shellcheck disable=SC2088 + labels+=("~/${basename}${size_human}") + details+=("No matching binary in PATH, last modified ${age_d} days ago") + + if [[ ${#labels[@]} -ge $max_hits ]]; then + break + fi + done < <(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" find "$HOME" -maxdepth 1 -mindepth 1 -type d -name '.*' 2> /dev/null | LC_ALL=C sort) + + [[ ${#labels[@]} -eq 0 ]] && return 0 + + note_activity + + local i + for i in "${!labels[@]}"; do + echo -e " ${GREEN}${ICON_LIST}${NC} Potential orphan dotfile: ${labels[$i]}" + echo -e " ${GRAY}${ICON_SUBLIST}${NC} ${details[$i]}" + done + echo -e " ${GRAY}${ICON_REVIEW}${NC} Review manually before removing any ~/. directory" } diff --git a/Resources/mole/lib/clean/launch_services.sh b/Resources/mole/lib/clean/launch_services.sh new file mode 100644 index 0000000..7969b68 --- /dev/null +++ b/Resources/mole/lib/clean/launch_services.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# LaunchServices cleanup helpers for `mo clean`. + +set -euo pipefail + +# shellcheck disable=SC2329 +launch_services_extract_app_path_from_line() { + local line="$1" + [[ "$line" == */*".app"* ]] || return 1 + + local path="/${line#*/}" + path="${path%%.app*}.app" + path="${path%/}" + + [[ "$path" == /* && "$path" == *.app ]] || return 1 + printf '%s\n' "$path" +} + +# shellcheck disable=SC2329 +launch_services_stale_app_path_is_safe() { + local path="$1" + + [[ -n "$path" ]] || return 1 + [[ "$path" == /* ]] || return 1 + [[ "$path" == *.app ]] || return 1 + [[ "$path" != *$'\n'* && "$path" != *$'\r'* ]] || return 1 + + case "$path" in + *"/../"* | *"/.." | "../"* | "/System/"* | "/Library/Apple/"*) + return 1 + ;; + esac + + [[ ! -e "$path" ]] +} + +# shellcheck disable=SC2329 +launch_services_emit_missing_record_paths() { + local -a record_paths=() + local missing_record=false + local line app_path + + _flush_launch_services_record() { + if [[ "$missing_record" == "true" && ${#record_paths[@]} -gt 0 ]]; then + local record_path + for record_path in "${record_paths[@]}"; do + if launch_services_stale_app_path_is_safe "$record_path"; then + printf '%s\n' "$record_path" + fi + done + fi + record_paths=() + missing_record=false + } + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -z "$line" ]]; then + _flush_launch_services_record + continue + fi + if [[ "$line" =~ ^[[:space:]]*bundle[[:space:]] ]]; then + _flush_launch_services_record + fi + + if [[ "$line" == *"Bundle node not found on disk"* ]]; then + missing_record=true + fi + + if app_path=$(launch_services_extract_app_path_from_line "$line"); then + record_paths+=("$app_path") + fi + done + + _flush_launch_services_record +} + +# shellcheck disable=SC2329 +collect_stale_launch_services_app_paths() { + local lsregister="$1" + + [[ -x "$lsregister" ]] || return 0 + + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -dump 2> /dev/null | + launch_services_emit_missing_record_paths | + LC_ALL=C sort -u +} + +# shellcheck disable=SC2329 +clean_stale_launch_services_registrations() { + local lsregister + lsregister=$(get_lsregister_path) + [[ -x "$lsregister" ]] || return 0 + + local candidates_file + candidates_file=$(mktemp_file "launch_services_stale_apps") || return 0 + + if ! collect_stale_launch_services_app_paths "$lsregister" > "$candidates_file"; then + debug_log "LaunchServices stale app scan failed" + return 0 + fi + + local max_items="${MOLE_LAUNCH_SERVICES_STALE_LIMIT:-50}" + [[ "$max_items" =~ ^[0-9]+$ ]] || max_items=50 + [[ "$max_items" -gt 0 ]] || max_items=50 + + local -a stale_apps=() + local app_path + while IFS= read -r app_path; do + [[ -n "$app_path" ]] || continue + if launch_services_stale_app_path_is_safe "$app_path"; then + stale_apps+=("$app_path") + if [[ ${#stale_apps[@]} -ge "$max_items" ]]; then + break + fi + fi + done < "$candidates_file" + + [[ ${#stale_apps[@]} -gt 0 ]] || return 0 + + note_activity + + local count="${#stale_apps[@]}" + local count_label="$count" + local total_candidates + total_candidates=$(wc -l < "$candidates_file" | tr -d '[:space:]') + if [[ "$total_candidates" =~ ^[0-9]+$ && "$total_candidates" -gt "$count" ]]; then + count_label="${count}+" + fi + + if [[ "${DRY_RUN:-false}" == "true" || "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} LaunchServices stale app registrations · would unregister ${count_label}" + echo -e " ${GRAY}${ICON_SUBLIST}${NC} Example: ${GRAY}${stale_apps[0]/#$HOME/~}${NC}" + return 0 + fi + + local success_count=0 + local failed_count=0 + for app_path in "${stale_apps[@]}"; do + debug_log "Unregistering stale LaunchServices app: $app_path" + if run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" "$lsregister" -u "$app_path" > /dev/null 2>&1; then + success_count=$((success_count + 1)) + else + failed_count=$((failed_count + 1)) + debug_log "Failed to unregister stale LaunchServices app: $app_path" + fi + done + + if [[ $success_count -gt 0 ]]; then + log_success "LaunchServices stale app registrations, $success_count removed" + fi + if [[ $failed_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} LaunchServices stale app registrations, ${failed_count} failed" + fi +} diff --git a/Resources/mole/lib/clean/project.sh b/Resources/mole/lib/clean/project.sh index c1a9ee7..886e6a5 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). @@ -326,10 +421,23 @@ scan_purge_targets() { return fi + local cachedir_tag_min_depth=$((min_depth + 1)) + local cachedir_tag_max_depth=$((max_depth + 1)) + # Update current scanning path local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" echo "$search_path" > "$stats_dir/purge_scanning" 2> /dev/null || true + emit_valid_cachedir_tag_dirs() { + while IFS= read -r tag_file; do + [[ -n "$tag_file" ]] || continue + local cache_dir="${tag_file%/*}" + if [[ -n "$cache_dir" ]] && mole_dir_has_cachedir_tag "$cache_dir"; then + printf '%s\n' "$cache_dir" + fi + done + } + # Helper to process raw results process_scan_results() { local input_file="$1" @@ -343,7 +451,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 +468,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" @@ -382,19 +486,30 @@ scan_purge_targets() { "--exclude" ".Trash" "--exclude" "Applications" ) + local fd_tag_args=( + "--absolute-path" + "--hidden" + "--no-ignore" + "--type" "f" + "--min-depth" "$cachedir_tag_min_depth" + "--max-depth" "$cachedir_tag_max_depth" + "--threads" "8" + "--exclude" ".git" + "--exclude" "Library" + "--exclude" ".Trash" + "--exclude" "Applications" + ) - # Try running fd. If it succeeds (exit code 0), use it. - # If it fails (e.g. bad flag, permissions, binary issue), fallback to find. - if fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then - # 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" - else - debug_log "fd returned empty results, falling back to find" - rm -f "$output_file.raw" - fi + # Trust fd when it exits successfully, including an empty result set. + # Empty scans are common in healthy project trees; falling back to find + # doubles the scan cost and can make "nothing to clean" feel slow. + local _scan_timeout="${MO_PURGE_SCAN_TIMEOUT_SEC:-60}" + if run_with_timeout "$_scan_timeout" fd "${fd_args[@]}" "$pattern" "$search_path" 2> /dev/null > "$output_file.raw"; then + run_with_timeout "$_scan_timeout" fd "${fd_tag_args[@]}" "^${MOLE_CACHEDIR_TAG_NAME}$" "$search_path" \ + 2> /dev/null | emit_valid_cachedir_tag_dirs >> "$output_file.raw" || true + debug_log "Using fd for scanning" + process_scan_results "$output_file.raw" + use_find=false else debug_log "fd command failed, falling back to find" fi @@ -420,11 +535,17 @@ scan_purge_targets() { # Use plain `find` here for compatibility with environments where # `command find` behaves inconsistently in this complex expression. - find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ + local _scan_timeout="${MO_PURGE_SCAN_TIMEOUT_SEC:-60}" + run_with_timeout "$_scan_timeout" find "$search_path" -mindepth "$min_depth" -maxdepth "$max_depth" -type d \ \( "${prune_expr[@]}" \) -prune -o \ \( "${target_expr[@]}" \) -print -prune \ 2> /dev/null > "$output_file.raw" || true + run_with_timeout "$_scan_timeout" find "$search_path" -mindepth "$cachedir_tag_min_depth" -maxdepth "$cachedir_tag_max_depth" \ + \( "${prune_expr[@]}" \) -prune -o \ + -type f -name "$MOLE_CACHEDIR_TAG_NAME" -print \ + 2> /dev/null | emit_valid_cachedir_tag_dirs >> "$output_file.raw" || true + process_scan_results "$output_file.raw" fi } @@ -460,14 +581,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 @@ -508,7 +631,8 @@ get_dir_size_kb() { fi if [[ $du_exit -ne 0 ]]; then - echo "0" + debug_log "Size calculation failed (exit $du_exit): $path" + echo "ERROR" return fi @@ -517,7 +641,8 @@ get_dir_size_kb() { if [[ "$size_kb" =~ ^[0-9]+$ ]]; then echo "$size_kb" else - echo "0" + debug_log "Size calculation returned invalid output: $path" + echo "ERROR" fi } # Purge category selector. @@ -542,7 +667,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 @@ -587,20 +712,28 @@ select_purge_categories() { fi terminal_restored=true + # Clear traps first to prevent re-entrant firing during eval below. trap - EXIT INT TERM + + # Restore terminal state before re-installing caller traps, so the + # terminal is always usable even if a restored trap handler exits. show_cursor if [[ -n "${original_stty:-}" ]]; then stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || true fi - if [[ -n "$previous_exit_trap" ]]; then - eval "$previous_exit_trap" - fi - if [[ -n "$previous_int_trap" ]]; then - eval "$previous_int_trap" - fi - if [[ -n "$previous_term_trap" ]]; then - eval "$previous_term_trap" - fi + + # Snapshot and clear saved traps before eval to prevent infinite + # recursion if the restored handler triggers another signal. + local _prev_exit="$previous_exit_trap" + local _prev_int="$previous_int_trap" + local _prev_term="$previous_term_trap" + previous_exit_trap="" + previous_int_trap="" + previous_term_trap="" + # eval: restore caller traps captured by $(trap -p) + [[ -n "$_prev_exit" ]] && eval "$_prev_exit" + [[ -n "$_prev_int" ]] && eval "$_prev_int" + [[ -n "$_prev_term" ]] && eval "$_prev_term" } # shellcheck disable=SC2329 handle_interrupt() { @@ -659,6 +792,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 +802,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 +815,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 +967,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 +986,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="" @@ -861,7 +1017,7 @@ confirm_purge_cleanup() { clean_project_artifacts() { local -a all_found_items=() local -a safe_to_clean=() - local -a recently_modified=() + local -a safe_recent_flags=() local previous_int_trap="" local previous_term_trap="" local trap_installed_by_this_call=false @@ -869,8 +1025,11 @@ clean_project_artifacts() { # Note: Declared without 'local' so cleanup_scan trap can access them scan_pids=() scan_temps=() + _cleanup_scan_done=false # shellcheck disable=SC2329 cleanup_scan() { + [[ "$_cleanup_scan_done" == "true" ]] && return + _cleanup_scan_done=true # Kill all background scans for pid in "${scan_pids[@]+"${scan_pids[@]}"}"; do kill "$pid" 2> /dev/null || true @@ -917,20 +1076,26 @@ clean_project_artifacts() { sleep 0.2 fi - # Collect all results + # Collect all results and deduplicate once. This avoids an O(N²) shell loop + # when overlapping search roots produce the same artifact many times. + local dedupe_output + dedupe_output=$(mktemp_file "mole-purge-dedupe") || return 1 for scan_output in "${scan_temps[@]+"${scan_temps[@]}"}"; do if [[ -f "$scan_output" ]]; then - while IFS= read -r item; do - if [[ -n "$item" ]]; then - all_found_items+=("$item") - fi - done < "$scan_output" + cat "$scan_output" >> "$dedupe_output" rm -f "$scan_output" fi done + if [[ -s "$dedupe_output" ]]; then + while IFS= read -r item; do + [[ -n "$item" ]] && all_found_items+=("$item") + done < <(LC_COLLATE=C sort -u "$dedupe_output") + fi + rm -f "$dedupe_output" # Restore caller traps after this function completes. if [[ "$trap_installed_by_this_call" == "true" ]]; then trap - INT TERM + # eval: restore caller traps captured by $(trap -p) [[ -n "$previous_int_trap" ]] && eval "$previous_int_trap" [[ -n "$previous_term_trap" ]] && eval "$previous_term_trap" fi @@ -941,37 +1106,91 @@ 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 - recently_modified+=("$item") + local is_recent=false + if is_recently_modified "$item" "$_now_epoch"; then + is_recent=true fi # Add all items to safe_to_clean, let user choose safe_to_clean+=("$item") + safe_recent_flags+=("$is_recent") done # Build menu options - one per artifact 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) + if ! [[ "$_max_size_jobs" =~ ^[0-9]+$ ]] || [[ "$_max_size_jobs" -lt 1 ]]; then + _max_size_jobs=1 + elif [[ "$_max_size_jobs" -gt 8 ]]; then + _max_size_jobs=8 + fi + + # Reap any finished PID from the sliding window. Uses `wait -n` when + # available (bash 4.3+) to avoid blocking on the slowest job; falls + # back to first-PID wait on macOS default bash 3.2. + local _has_wait_n=false + if [[ "${BASH_VERSINFO[0]:-0}" -gt 4 ]] || + { [[ "${BASH_VERSINFO[0]:-0}" -eq 4 ]] && [[ "${BASH_VERSINFO[1]:-0}" -ge 3 ]]; }; then + _has_wait_n=true + fi + _reap_one_size_pid() { + if [[ "$_has_wait_n" == "true" ]]; then + wait -n "${_size_pids[@]}" 2> /dev/null || true + local -a _remaining=() + for _p in "${_size_pids[@]}"; do + if kill -0 "$_p" 2> /dev/null; then + _remaining+=("$_p") + fi + done + _size_pids=("${_remaining[@]}") + else + wait "${_size_pids[0]}" 2> /dev/null || true + _size_pids=("${_size_pids[@]:1}") + fi + } + + 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 + _reap_one_size_pid + 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=() - # Helper to get project name from path - # For ~/www/pake/src-tauri/target -> returns "pake" - # For ~/work/code/MyProject/node_modules -> returns "MyProject" - # Strategy: Find the nearest ancestor directory containing a project indicator file - get_project_name() { + local -a item_age_labels=() + # Find the best project root for an artifact once; callers decide how to + # display it. Monorepo indicators win over plain project indicators. + find_purge_project_root_for_artifact() { 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="" - # Single pass: check both monorepo and project indicators while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do - # First check for monorepo indicators (higher priority) if [[ -z "$monorepo_root" ]]; then for indicator in "${MONOREPO_INDICATORS[@]}"; do if [[ -e "$current_dir/$indicator" ]]; then @@ -981,7 +1200,6 @@ clean_project_artifacts() { done fi - # Then check for project indicators (save first match) if [[ -z "$project_root" ]]; then for indicator in "${PROJECT_INDICATORS[@]}"; do if [[ -e "$current_dir/$indicator" ]]; then @@ -991,136 +1209,123 @@ clean_project_artifacts() { done fi - # If we found monorepo, we can stop (monorepo always wins) if [[ -n "$monorepo_root" ]]; then break fi - # 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") - elif [[ -n "$project_root" ]]; then - result=$(basename "$project_root") - else - # Fallback: first directory under search root - local search_roots=() - if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then - search_roots=("${PURGE_SEARCH_PATHS[@]}") - else - search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") - fi - for root in "${search_roots[@]}"; do - root="${root%/}" - if [[ -n "$root" && "$path" == "$root/"* ]]; then - local relative_path="${path#"$root"/}" - result=$(echo "$relative_path" | cut -d'/' -f1) - break - fi - done + echo "$monorepo_root" + return 0 + fi - # Final fallback: use grandparent directory - if [[ -z "$result" ]]; then - result=$(dirname "$(dirname "$path")" | xargs basename) - fi + if [[ -n "$project_root" ]]; then + echo "$project_root" + return 0 fi - echo "$result" + return 1 } - # Helper to get project path (more complete than just project name) - # For ~/www/pake/src-tauri/target -> returns "~/www/pake" - # For ~/work/code/MyProject/node_modules -> returns "~/work/code/MyProject" - # Shows the full path relative to HOME with ~ prefix for better clarity - get_project_path() { + # Helper to get project name from path. + get_project_name() { local path="$1" - - local current_dir - current_dir=$(dirname "$path") - local monorepo_root="" local project_root="" - # Single pass: check both monorepo and project indicators - while [[ "$current_dir" != "/" && "$current_dir" != "$HOME" && -n "$current_dir" ]]; do - # First check for monorepo indicators (higher priority) - if [[ -z "$monorepo_root" ]]; then - for indicator in "${MONOREPO_INDICATORS[@]}"; do - if [[ -e "$current_dir/$indicator" ]]; then - monorepo_root="$current_dir" - break - fi - done - fi - - # Then check for project indicators (save first match) - if [[ -z "$project_root" ]]; then - for indicator in "${PROJECT_INDICATORS[@]}"; do - if [[ -e "$current_dir/$indicator" ]]; then - project_root="$current_dir" - break - fi - done - fi - - # If we found monorepo, we can stop (monorepo always wins) - if [[ -n "$monorepo_root" ]]; then - break - fi + if project_root=$(find_purge_project_root_for_artifact "$path"); then + echo "${project_root##*/}" + return + 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 ' ') - if [[ -n "$project_root" && $depth -lt 2 ]]; then + local result="" + local search_roots=() + if [[ ${#PURGE_SEARCH_PATHS[@]} -gt 0 ]]; then + search_roots=("${PURGE_SEARCH_PATHS[@]}") + else + search_roots=("$HOME/www" "$HOME/dev" "$HOME/Projects") + fi + for root in "${search_roots[@]}"; do + root="${root%/}" + if [[ -n "$root" && "$path" == "$root/"* ]]; then + local relative_path="${path#"$root"/}" + result="${relative_path%%/*}" break fi - - current_dir=$(dirname "$current_dir") done - # Determine result: monorepo > project > fallback - local result="" - if [[ -n "$monorepo_root" ]]; then - result="$monorepo_root" - elif [[ -n "$project_root" ]]; then - result="$project_root" - else - # Fallback: use parent directory of artifact - result=$(dirname "$path") + if [[ -z "$result" ]]; then + local _gp="${path%/*}" + _gp="${_gp%/*}" + result="${_gp##*/}" fi - # Convert to ~ format for cleaner display - result="${result/#$HOME/~}" echo "$result" } + # Helper to get project path (more complete than just project name). + get_project_path() { + local path="$1" + local project_root="" + if ! project_root=$(find_purge_project_root_for_artifact "$path"); then + project_root="${path%/*}" + fi + echo "${project_root/#$HOME/~}" + } + # 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 +1362,57 @@ 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 item_index=$_sz_idx + local project_path="${_cached_project_paths[$item_index]}" 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[$item_index]}" 2> /dev/null || echo "0") + rm -f "${_size_tmpfiles[$item_index]}" 2> /dev/null || true + _sz_idx=$((_sz_idx + 1)) local size_kb=0 local size_human="" local size_unknown=false @@ -1189,31 +1420,42 @@ clean_project_artifacts() { if [[ "$size_raw" == "TIMEOUT" ]]; then size_unknown=true size_human="unknown" + elif [[ "$size_raw" == "ERROR" ]]; then + debug_log "Skipping purge target with unknown size: $item" + continue elif [[ "$size_raw" =~ ^[0-9]+$ ]]; then size_kb="$size_raw" - # Skip empty directories (0 bytes) - if [[ $size_kb -eq 0 ]]; then + if [[ $size_kb -eq 0 && "${MOLE_PURGE_INCLUDE_EMPTY:-0}" != "1" ]]; then continue fi size_human=$(bytes_to_human "$((size_kb * 1024))") else + debug_log "Skipping purge target with invalid size result '$size_raw': $item" continue fi - # Check if recent - local is_recent=false - for recent_item in "${recently_modified[@]+"${recently_modified[@]}"}"; do - if [[ "$item" == "$recent_item" ]]; then - is_recent=true - break - fi - done + local is_recent="${safe_recent_flags[$item_index]:-false}" 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 +1478,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 +1493,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 +1532,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 +1541,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 +1551,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 +1574,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 +1600,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 +1615,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 +1636,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 +1651,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 +1669,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..2534cd2 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=( @@ -53,6 +54,7 @@ readonly MOLE_PURGE_DEFAULT_SEARCH_PATHS=( "$HOME/Workspace" "$HOME/Repos" "$HOME/Development" + "$HOME/Library/CloudStorage" ) readonly MOLE_PURGE_MONOREPO_INDICATORS=( @@ -73,12 +75,16 @@ readonly MOLE_PURGE_PROJECT_INDICATORS=( "Gemfile" "composer.json" "pubspec.yaml" + "Package.swift" # Swift Package Manager "Makefile" "build.zig" "build.zig.zon" ".git" ) +readonly MOLE_CACHEDIR_TAG_NAME="CACHEDIR.TAG" +readonly MOLE_CACHEDIR_TAG_SIGNATURE="Signature: 8a477f597d28d172789f06886806bc55" + # High-noise targets intentionally excluded from quick hint scans in mo clean. readonly MOLE_PURGE_QUICK_HINT_EXCLUDED_TARGETS=( "bin" @@ -104,6 +110,16 @@ mole_purge_is_project_root() { return 1 } +mole_dir_has_cachedir_tag() { + local dir="$1" + local tag="$dir/$MOLE_CACHEDIR_TAG_NAME" + [[ -f "$tag" && ! -L "$tag" ]] || return 1 + + local signature + signature=$(LC_ALL=C dd bs=${#MOLE_CACHEDIR_TAG_SIGNATURE} count=1 < "$tag" 2> /dev/null || true) + [[ "$signature" == "$MOLE_CACHEDIR_TAG_SIGNATURE" ]] +} + mole_purge_quick_hint_target_names() { local target local excluded @@ -122,6 +138,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 +161,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..e28005f 100644 --- a/Resources/mole/lib/clean/system.sh +++ b/Resources/mole/lib/clean/system.sh @@ -1,13 +1,52 @@ #!/bin/bash # System-Level Cleanup Module (requires sudo). set -euo pipefail + +is_rebuildable_gpu_cache_dir() { + local cache_dir="$1" + + # Only match current-user-accessible Darwin cache shards under C/. Do not + # match T/ temp folders, generic /private/var/folders entries, or arbitrary + # system paths: these Metal/GPU caches are rebuildable, but deleting active + # caches can force live apps to recompile shaders and momentarily stutter. + case "$cache_dir" in + /private/var/folders/*/*/C/*/com.apple.gpuarchiver | \ + /private/var/folders/*/*/C/*/com.apple.metal | \ + /private/var/folders/*/*/C/*/com.apple.metalfe | \ + /var/folders/*/*/C/*/com.apple.gpuarchiver | \ + /var/folders/*/*/C/*/com.apple.metal | \ + /var/folders/*/*/C/*/com.apple.metalfe) + return 0 + ;; + esac + + return 1 +} + +gpu_cache_dir_is_stale() { + local cache_dir="$1" + local age_days="${2:-${MOLE_GPU_CACHE_AGE_DAYS:-1}}" + + [[ "$age_days" =~ ^[0-9]+$ ]] || age_days=1 + [[ -d "$cache_dir" ]] || return 1 + [[ -L "$cache_dir" ]] && return 1 + + # Directory mtime only changes when entries are added/removed/renamed. + # Treat a cache as stale only when no contained file was modified inside + # the retention window, so live apps that rewrite existing Metal cache + # files do not lose their active shader/GPU cache on every cleanup run. + local recent_file="" + recent_file=$(command find "$cache_dir" -type f -mtime "-$age_days" -print -quit 2> /dev/null) || return 1 + [[ -z "$recent_file" ]] +} + # System caches, logs, and temp files. clean_deep_system() { stop_section_spinner local cache_cleaned=0 start_section_spinner "Cleaning system caches..." # Optimized: Single pass for /Library/Caches (3 patterns in 1 scan) - if sudo test -d "/Library/Caches" 2> /dev/null; then + if sudo -n test -d "/Library/Caches" 2> /dev/null; then while IFS= read -r -d '' file; do if should_protect_path "$file"; then continue @@ -15,7 +54,7 @@ clean_deep_system() { if safe_sudo_remove "$file"; then cache_cleaned=1 fi - done < <(sudo find "/Library/Caches" -maxdepth 5 -type f \( \ + done < <(sudo -n find "/Library/Caches" -maxdepth 5 -type f \( \ \( -name "*.cache" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" \) -o \ \( -name "*.tmp" -mtime "+$MOLE_TEMP_FILE_AGE_DAYS" \) -o \ \( -name "*.log" -mtime "+$MOLE_LOG_AGE_DAYS" \) \ @@ -27,7 +66,7 @@ clean_deep_system() { local tmp_cleaned=0 local -a sys_temp_dirs=("/private/tmp" "/private/var/tmp") for tmp_dir in "${sys_temp_dirs[@]}"; do - if sudo find "$tmp_dir" -maxdepth 1 -type f -mtime "+${MOLE_TEMP_FILE_AGE_DAYS}" -print -quit 2> /dev/null | grep -q .; then + if sudo -n find "$tmp_dir" -maxdepth 1 -type f -mtime "+${MOLE_TEMP_FILE_AGE_DAYS}" -print -quit 2> /dev/null | grep -q .; then if safe_sudo_find_delete "$tmp_dir" "*" "${MOLE_TEMP_FILE_AGE_DAYS}" "f"; then tmp_cleaned=1 fi @@ -36,13 +75,13 @@ clean_deep_system() { stop_section_spinner [[ $tmp_cleaned -eq 1 ]] && log_success "System temp files" start_section_spinner "Cleaning system crash reports..." - if sudo find "/Library/Logs/DiagnosticReports" -maxdepth 1 -type f -mtime "+$MOLE_CRASH_REPORT_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then + if sudo -n find "/Library/Logs/DiagnosticReports" -maxdepth 1 -type f -mtime "+$MOLE_CRASH_REPORT_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then safe_sudo_find_delete "/Library/Logs/DiagnosticReports" "*" "$MOLE_CRASH_REPORT_AGE_DAYS" "f" || true fi stop_section_spinner log_success "System crash reports" start_section_spinner "Cleaning system logs..." - if sudo find "/private/var/log" -maxdepth 3 -type f \( -name "*.log" -o -name "*.gz" -o -name "*.asl" \) -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then + if sudo -n find "/private/var/log" -maxdepth 3 -type f \( -name "*.log" -o -name "*.gz" -o -name "*.asl" \) -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then safe_sudo_find_delete "/private/var/log" "*.log" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/log" "*.gz" "$MOLE_LOG_AGE_DAYS" "f" || true safe_sudo_find_delete "/private/var/log" "*.asl" "$MOLE_LOG_AGE_DAYS" "f" || true @@ -57,15 +96,15 @@ clean_deep_system() { local third_party_logs_cleaned=0 local third_party_log_dir="" for third_party_log_dir in "${third_party_log_dirs[@]}"; do - if sudo test -d "$third_party_log_dir" 2> /dev/null; then - if sudo find "$third_party_log_dir" -maxdepth 5 -type f -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then + if sudo -n test -d "$third_party_log_dir" 2> /dev/null; then + if sudo -n find "$third_party_log_dir" -maxdepth 5 -type f -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then if safe_sudo_find_delete "$third_party_log_dir" "*" "$MOLE_LOG_AGE_DAYS" "f"; then third_party_logs_cleaned=1 fi fi fi done - if sudo find "/Library/Logs" -maxdepth 1 -type f -name "adobegc.log" -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then + if sudo -n find "/Library/Logs" -maxdepth 1 -type f -name "adobegc.log" -mtime "+$MOLE_LOG_AGE_DAYS" -print -quit 2> /dev/null | grep -q .; then if safe_sudo_remove "/Library/Logs/adobegc.log"; then third_party_logs_cleaned=1 fi @@ -116,8 +155,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 +169,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,10 +210,51 @@ 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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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" + start_section_spinner "Cleaning rebuildable system service caches..." + local rebuildable_cache_cleaned=0 + local -a rebuildable_cache_dirs=( + "/Library/Caches/com.apple.iconservices.store" + ) + local rebuildable_cache_dir="" + for rebuildable_cache_dir in "${rebuildable_cache_dirs[@]}"; do + if sudo -n test -e "$rebuildable_cache_dir" 2> /dev/null; then + if safe_sudo_remove "$rebuildable_cache_dir"; then + rebuildable_cache_cleaned=$((rebuildable_cache_cleaned + 1)) + fi + fi + done + stop_section_spinner + if [[ $rebuildable_cache_cleaned -gt 0 ]]; then + local rebuildable_cache_label="items" + [[ $rebuildable_cache_cleaned -eq 1 ]] && rebuildable_cache_label="item" + log_success "Rebuildable system caches, $rebuildable_cache_cleaned $rebuildable_cache_label" + fi + + start_section_spinner "Scanning accessible rebuildable GPU caches..." + local gpu_cache_cleaned=0 + local gpu_cache_dir="" + while IFS= read -r -d '' gpu_cache_dir; do + is_rebuildable_gpu_cache_dir "$gpu_cache_dir" || continue + gpu_cache_dir_is_stale "$gpu_cache_dir" "$MOLE_GPU_CACHE_AGE_DAYS" || continue + if safe_sudo_remove "$gpu_cache_dir"; then + gpu_cache_cleaned=$((gpu_cache_cleaned + 1)) + fi + done < <(run_with_timeout 8 command find /private/var/folders -maxdepth 8 -type d \( \ + -name "com.apple.gpuarchiver" -o \ + -name "com.apple.metal" -o \ + -name "com.apple.metalfe" \ + \) -path "*/C/*" -print0 2> /dev/null || true) # 8s: deep /private/var/folders walk, see lib/core/timeouts.sh + stop_section_spinner + if [[ $gpu_cache_cleaned -gt 0 ]]; then + local gpu_cache_label="items" + [[ $gpu_cache_cleaned -eq 1 ]] && gpu_cache_label="item" + log_success "Accessible rebuildable GPU caches, $gpu_cache_cleaned $gpu_cache_label" + fi + local diag_base="/private/var/db/diagnostics" start_section_spinner "Cleaning system diagnostic logs..." safe_sudo_find_delete "$diag_base" "*" "$MOLE_LOG_AGE_DAYS" "f" || true @@ -174,13 +270,13 @@ clean_deep_system() { start_section_spinner "Cleaning memory exception reports..." local mem_reports_dir="/private/var/db/reportmemoryexception/MemoryLimitViolations" local mem_cleaned=0 - if sudo test -d "$mem_reports_dir" 2> /dev/null; then + if sudo -n test -d "$mem_reports_dir" 2> /dev/null; then # Count and size old files before deletion local file_count=0 local total_size_kb=0 local total_bytes=0 local stats_out - stats_out=$(sudo find "$mem_reports_dir" -type f -mtime +30 -exec stat -f "%z" {} + 2> /dev/null | awk '{c++; s+=$1} END {print c+0, s+0}' || true) + stats_out=$(sudo -n find "$mem_reports_dir" -type f -mtime +30 -exec stat -f "%z" {} + 2> /dev/null | awk '{c++; s+=$1} END {print c+0, s+0}' || true) if [[ -n "$stats_out" ]]; then read -r file_count total_bytes <<< "$stats_out" total_size_kb=$((total_bytes / 1024)) @@ -223,7 +319,7 @@ clean_time_machine_failed_backups() { start_section_spinner "Checking Time Machine configuration..." local spinner_active=true local tm_info - tm_info=$(run_with_timeout 2 tmutil destinationinfo 2>&1 || echo "failed") + tm_info=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" tmutil destinationinfo 2>&1 || echo "failed") if [[ "$tm_info" == *"No destinations configured"* || "$tm_info" == "failed" ]]; then if [[ "$spinner_active" == "true" ]]; then stop_section_spinner @@ -270,7 +366,7 @@ clean_time_machine_failed_backups() { fi for volume in "${backup_volumes[@]}"; do local fs_type - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") + fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "unknown") # 1s: volume FS-type probe, see lib/core/timeouts.sh case "$fs_type" in nfs | smbfs | afpfs | cifs | webdav | unknown) continue ;; esac @@ -299,7 +395,7 @@ clean_time_machine_failed_backups() { local size_human size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete backup: $backup_name${NC}, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" tm_cleaned=$((tm_cleaned + 1)) note_activity continue @@ -309,7 +405,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)) @@ -318,7 +416,7 @@ clean_time_machine_failed_backups() { else echo -e " ${YELLOW}!${NC} Could not delete: $backup_name · try manually with sudo" fi - done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < <(run_with_timeout 15 find "$backupdb_dir" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) # 15s: Time Machine backupdb find, see lib/core/timeouts.sh fi # APFS bundles. for bundle in "$volume"/*.backupbundle "$volume"/*.sparsebundle; do @@ -351,7 +449,7 @@ clean_time_machine_failed_backups() { local size_human size_human=$(bytes_to_human "$((size_kb * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, ${YELLOW}$size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Incomplete APFS backup in $bundle_name: $backup_name${NC}, $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" tm_cleaned=$((tm_cleaned + 1)) note_activity continue @@ -360,7 +458,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)) @@ -369,7 +469,7 @@ clean_time_machine_failed_backups() { else echo -e " ${YELLOW}!${NC} Could not delete from bundle: $backup_name" fi - done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) + done < <(run_with_timeout 15 find "$mounted_path" -maxdepth 3 -type d \( -name "*.inProgress" -o -name "*.inprogress" \) 2> /dev/null || true) # 15s: TM sparsebundle inner find, see lib/core/timeouts.sh fi done done @@ -424,7 +524,7 @@ clean_local_snapshots() { start_section_spinner "Checking local snapshots..." local snapshot_list - snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) + snapshot_list=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" tmutil listlocalsnapshots / 2> /dev/null || true) stop_section_spinner [[ -z "$snapshot_list" ]] && return 0 diff --git a/Resources/mole/lib/clean/user.sh b/Resources/mole/lib/clean/user.sh index 60e2af9..4f135e1 100644 --- a/Resources/mole/lib/clean/user.sh +++ b/Resources/mole/lib/clean/user.sh @@ -1,6 +1,56 @@ #!/bin/bash # User Data Cleanup Module set -euo pipefail + +clean_trash() { + if is_path_whitelisted "$HOME/.Trash"; then + return 0 + fi + stop_section_spinner + + # Always count and delete directly. The previous Finder AppleScript path + # triggered macOS's "Show warning before emptying the Trash" dialog and + # blocked mo clean on user confirmation. Volume Trashes + # (/Volumes/*/.Trashes//) are not handled here; mo clean only manages + # the user's home Trash. + local trash_count + trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null | + tr -dc '\0' | wc -c | tr -d ' ' || echo "0") + [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" + + if [[ "$DRY_RUN" == "true" ]]; then + if [[ $trash_count -gt 0 ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" + else + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" + fi + return 0 + fi + + if [[ $trash_count -eq 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" + return 0 + fi + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Emptying trash, ${trash_count} items..." + fi + + local cleaned_count=0 + while IFS= read -r -d '' item; do + if safe_remove "$item" true; then + cleaned_count=$((cleaned_count + 1)) + fi + done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + + [[ -t 1 ]] && stop_inline_spinner + + if [[ $cleaned_count -gt 0 ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $cleaned_count items" + note_activity + fi +} + clean_user_essentials() { start_section_spinner "Scanning caches..." safe_clean ~/Library/Caches/* "User app cache" @@ -8,40 +58,13 @@ clean_user_essentials() { safe_clean ~/Library/Logs/* "User app logs" - 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=$? - 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") - fi - [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" + start_section_spinner "Cleaning runtime files..." + _clean_darwin_user_runtime_dirs - 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 - else - 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 - if safe_remove "$item" true; then - cleaned_count=$((cleaned_count + 1)) - fi - done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - if [[ $cleaned_count -gt 0 ]]; then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $cleaned_count items" - note_activity - fi - fi - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" - fi + if [[ "${MOLE_SKIP_TRASH_CLEANUP:-0}" != "1" ]]; then + clean_trash fi + stop_section_spinner # Recent items _clean_recent_items @@ -71,12 +94,41 @@ _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:-} if ! [[ "$mail_age_days" =~ ^[0-9]+$ ]]; then mail_age_days=30 fi + + if pgrep -x "Mail" > /dev/null 2>&1; then + debug_log "Mail is running, skipping Mail Downloads cleanup" + return 0 + fi + local -a mail_dirs=( "$HOME/Library/Mail Downloads" "$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads" @@ -120,20 +172,177 @@ _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 +} + +_darwin_user_runtime_realpath() { + local runtime_dir="$1" + [[ -n "$runtime_dir" && -d "$runtime_dir" && ! -L "$runtime_dir" ]] || return 1 + (cd "$runtime_dir" 2> /dev/null && pwd -P) +} + +_darwin_user_runtime_dir_is_safe() { + local runtime_dir="$1" + local kind="$2" + local resolved="" + resolved=$(_darwin_user_runtime_realpath "$runtime_dir") || return 1 + + case "$kind:$resolved" in + temp:/private/var/folders/*/*/T | cache:/private/var/folders/*/*/C) ;; + *) + debug_log "Skipping unexpected Darwin user runtime dir: $runtime_dir -> $resolved" + return 1 + ;; + esac + + local owner_uid current_uid + owner_uid=$(stat -f%u "$resolved" 2> /dev/null || echo "") + current_uid=$(id -u 2> /dev/null || echo "") + [[ -n "$owner_uid" && "$owner_uid" == "$current_uid" ]] +} + +_clean_darwin_user_runtime_dir() { + local runtime_dir="$1" + local kind="$2" + local label="$3" + local age_days="${MOLE_DARWIN_USER_RUNTIME_AGE_DAYS:-7}" + local max_items="${MOLE_DARWIN_USER_RUNTIME_MAX_ITEMS:-1500}" + local scan_timeout="${MOLE_DARWIN_USER_RUNTIME_SCAN_TIMEOUT:-8}" + + [[ "$age_days" =~ ^[0-9]+$ ]] || age_days=7 + [[ "$max_items" =~ ^[0-9]+$ ]] || max_items=1500 + [[ "$scan_timeout" =~ ^[0-9]+$ ]] || scan_timeout=8 + [[ -d "$runtime_dir" ]] || return 0 + _darwin_user_runtime_dir_is_safe "$runtime_dir" "$kind" || return 0 + + local current_uid + current_uid=$(id -u 2> /dev/null || echo "") + [[ -n "$current_uid" ]] || return 0 + + local count=0 + local total_size_kb=0 + local hit_cap=false + local found_any=false + local item + + # Per-item should_protect_path / is_path_whitelisted are intentionally + # skipped here. _darwin_user_runtime_dir_is_safe has already vetted the + # parent (must be DARWIN_USER_TEMP_DIR or DARWIN_USER_CACHE_DIR, owned by + # the current UID), find narrows to -user "$current_uid" -mtime +N and + # excludes state files (sqlite/db/plist), and safe_remove still routes + # through validate_path_for_deletion. For 1500 capped items that drops + # ~3000 per-item subshells; on a 20k-item TMPDIR this is the difference + # between a 30s stall and an under-3s pass. + while IFS= read -r -d '' item; do + [[ -e "$item" && ! -L "$item" ]] || continue + case "$item" in + *.sqlite | *.sqlite-shm | *.sqlite-wal | *.db | *.plist) + continue + ;; + esac + + local item_size_kb=0 + item_size_kb=$(get_path_size_kb "$item" 2> /dev/null || echo "0") + [[ "$item_size_kb" =~ ^[0-9]+$ ]] || item_size_kb=0 + + if [[ "${DRY_RUN:-false}" == "true" ]] || safe_remove "$item" true "$item_size_kb" > /dev/null 2>&1; then + found_any=true + count=$((count + 1)) + total_size_kb=$((total_size_kb + item_size_kb)) + fi + + if [[ "$count" -ge "$max_items" ]]; then + hit_cap=true + break + fi + done < <( + run_with_timeout "$scan_timeout" \ + find -P "$runtime_dir" -xdev -mindepth 1 -user "$current_uid" -type f -mtime +"$age_days" \ + ! -name "*.sqlite" ! -name "*.sqlite-shm" ! -name "*.sqlite-wal" ! -name "*.db" ! -name "*.plist" \ + -print0 2> /dev/null || true + ) + + if [[ "$count" -lt "$max_items" ]]; then + # Same safety contract as the file loop above: parent vetted, + # find narrowed to current UID + age + -type d -empty, and safe_remove + # still validates. Do not re-add per-item should_protect_path here. + while IFS= read -r -d '' item; do + [[ -d "$item" && ! -L "$item" ]] || continue + if [[ "${DRY_RUN:-false}" == "true" ]] || safe_remove "$item" true "0" > /dev/null 2>&1; then + found_any=true + count=$((count + 1)) + fi + if [[ "$count" -ge "$max_items" ]]; then + hit_cap=true + break + fi + done < <( + run_with_timeout "$scan_timeout" \ + find -P "$runtime_dir" -xdev -mindepth 1 -user "$current_uid" -type d -empty -mtime +"$age_days" -print0 2> /dev/null || true + ) + fi + + if [[ "$found_any" == "true" ]]; then + stop_section_spinner + local size_human + size_human=$(bytes_to_human "$((total_size_kb * 1024))") + local cap_note="" + [[ "$hit_cap" == "true" ]] && cap_note=", capped" + if [[ "${DRY_RUN:-false}" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} ${label}${NC}, ${YELLOW}${count} old items, $(colorize_human_size "$size_human") ${YELLOW}dry${cap_note}${NC}" + else + local line_color + line_color=$(cleanup_result_color_kb "$total_size_kb") + echo -e " ${line_color}${ICON_SUCCESS}${NC} ${label}${NC}, ${line_color}${count} old items, ${size_human}${cap_note}${NC}" + fi + files_cleaned=$((files_cleaned + count)) + total_size_cleaned=$((total_size_cleaned + total_size_kb)) + total_items=$((total_items + 1)) note_activity fi } +_clean_darwin_user_runtime_dirs() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + [[ "${MOLE_ENABLE_DARWIN_RUNTIME_CLEANUP_IN_TESTS:-0}" == "1" ]] || return 0 + fi + + local temp_dir="" + local cache_dir="" + temp_dir=$(getconf DARWIN_USER_TEMP_DIR 2> /dev/null || true) + cache_dir=$(getconf DARWIN_USER_CACHE_DIR 2> /dev/null || true) + + _clean_darwin_user_runtime_dir "$temp_dir" "temp" "Darwin user temp files" + # _clean_darwin_user_runtime_dir stops the section spinner before printing + # its result line; restart it so the user does not see a silent gap while + # the cache scan and subsequent trash empty are running. + start_section_spinner "Cleaning runtime files..." + _clean_darwin_user_runtime_dir "$cache_dir" "cache" "Darwin user cache files" + start_section_spinner "Cleaning runtime files..." +} + # Remove old Google Chrome versions while keeping Current. +is_google_chrome_running() { + pgrep -x "Google Chrome" > /dev/null 2>&1 && return 0 + pgrep -x "Google Chrome Helper" > /dev/null 2>&1 && return 0 + pgrep -f "/Google Chrome.app/" > /dev/null 2>&1 && return 0 + return 1 +} + clean_chrome_old_versions() { - local -a app_paths=( - "/Applications/Google Chrome.app" - "$HOME/Applications/Google Chrome.app" - ) + local -a app_paths + if [[ -n "${MOLE_CHROME_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_CHROME_APP_PATHS" + else + app_paths=( + "/Applications/Google Chrome.app" + "$HOME/Applications/Google Chrome.app" + ) + fi - # Match the exact Chrome process name to avoid false positives - if pgrep -x "Google Chrome" > /dev/null 2>&1; then + if is_google_chrome_running; then echo -e " ${GRAY}${ICON_WARNING}${NC} Google Chrome running · old versions cleanup skipped" return 0 fi @@ -156,13 +365,42 @@ 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 newest_version="" + local newest_mtime=0 + local current_mtime + current_mtime=$(stat -f%m "$versions_dir/$current_version" 2> /dev/null || echo "0") + [[ "$current_mtime" =~ ^[0-9]+$ ]] || current_mtime=0 + local -a old_versions=() local dir name + for dir in "$versions_dir"/*; do + [[ -d "$dir" ]] || continue + name=$(basename "$dir") + [[ "$name" == "Current" ]] && continue + local mtime + mtime=$(stat -f%m "$dir" 2> /dev/null || echo "0") + if [[ "$mtime" =~ ^[0-9]+$ ]] && [[ "$mtime" -gt "$newest_mtime" ]]; then + newest_mtime="$mtime" + newest_version="$name" + fi + done + if [[ "$newest_mtime" -le "$current_mtime" ]]; then + newest_version="" + fi + for dir in "$versions_dir"/*; do [[ -d "$dir" ]] || continue name=$(basename "$dir") [[ "$name" == "Current" ]] && continue [[ "$name" == "$current_version" ]] && continue + [[ -n "$newest_version" && "$name" == "$newest_version" ]] && continue if is_path_whitelisted "$dir"; then continue fi @@ -194,9 +432,11 @@ clean_chrome_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Chrome old versions${NC}, ${YELLOW}${cleaned_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}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 +482,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 @@ -280,9 +527,11 @@ clean_edge_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions${NC}, ${YELLOW}${cleaned_count} dirs, $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge old versions${NC}, ${YELLOW}${cleaned_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}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)) @@ -342,9 +591,11 @@ clean_edge_updater_old_versions() { local size_human size_human=$(bytes_to_human "$((total_size * 1024))") 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}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Edge updater old versions${NC}, ${YELLOW}${cleaned_count} dirs, $(colorize_human_size "$size_human") ${YELLOW}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)) @@ -353,54 +604,96 @@ clean_edge_updater_old_versions() { fi } -scan_external_volumes() { - [[ -d "/Volumes" ]] || return 0 - local -a candidate_volumes=() - local -a network_volumes=() - for volume in /Volumes/*; do - [[ -d "$volume" && -w "$volume" && ! -L "$volume" ]] || continue - [[ "$volume" == "/" || "$volume" == "/Volumes/Macintosh HD" ]] && continue - local protocol="" - protocol=$(run_with_timeout 1 command diskutil info "$volume" 2> /dev/null | grep -i "Protocol:" | awk '{print $2}' || echo "") - case "$protocol" in - SMB | NFS | AFP | CIFS | WebDAV) - network_volumes+=("$volume") - continue - ;; - esac - local fs_type="" - fs_type=$(run_with_timeout 1 command df -T "$volume" 2> /dev/null | tail -1 | awk '{print $2}' || echo "") - case "$fs_type" in - nfs | smbfs | afpfs | cifs | webdav) - network_volumes+=("$volume") - continue - ;; - esac - candidate_volumes+=("$volume") - done - local volume_count=${#candidate_volumes[@]} - local network_count=${#network_volumes[@]} - if [[ $volume_count -eq 0 ]]; then - if [[ $network_count -gt 0 ]]; then - echo -e " ${GRAY}${ICON_LIST}${NC} External volumes, ${network_count} network volumes skipped" - note_activity - fi +# Remove old Brave Browser versions while keeping Current. +clean_brave_old_versions() { + local -a app_paths + if [[ -n "${MOLE_BRAVE_APP_PATHS:-}" ]]; then + IFS=':' read -ra app_paths <<< "$MOLE_BRAVE_APP_PATHS" + else + app_paths=( + "/Applications/Brave Browser.app" + "$HOME/Applications/Brave Browser.app" + ) + fi + + # 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 - start_section_spinner "Scanning $volume_count external volumes..." - for volume in "${candidate_volumes[@]}"; do - [[ -d "$volume" && -r "$volume" ]] || continue - local volume_trash="$volume/.Trashes" - if [[ -d "$volume_trash" && "$DRY_RUN" != "true" ]] && ! is_path_whitelisted "$volume_trash"; then - while IFS= read -r -d '' item; do - safe_remove "$item" true || true - done < <(command find "$volume_trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) + + 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 - if [[ "$PROTECT_FINDER_METADATA" != "true" ]]; then - clean_ds_store_tree "$volume" "$(basename "$volume") volume, .DS_Store" + + 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 - stop_section_spinner + + 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, $(colorize_human_size "$size_human") ${YELLOW}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)) + total_items=$((total_items + 1)) + note_activity + fi } # Finder metadata (.DS_Store). @@ -427,20 +720,75 @@ 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 -n 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: restore shopt state captured by $(shopt -p) + 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: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + return 0 + fi + done + + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" + return 1 +} + clean_app_caches() { start_section_spinner "Scanning app caches..." @@ -453,10 +801,9 @@ 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 - safe_clean ~/Library/Autosave\ Information/* "Autosave information" || true + _clean_incomplete_downloads + # Do not clean ~/Library/Autosave Information by default: it can contain + # recoverable user documents, not only disposable cache data. safe_clean ~/Library/IdentityCaches/* "Identity caches" || true safe_clean ~/Library/Suggestions/* "Siri suggestions cache" || true safe_clean ~/Library/Calendars/Calendar\ Cache "Calendar cache" || true @@ -469,31 +816,70 @@ 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" + # Do NOT clean ~/Library/Application Support/com.apple.wallpaper/aerials/ + # thumbnails: those ~50KB PNGs are the wallpaper "cover" previews shown in + # System Settings > Wallpaper. Deleting them reclaims almost nothing yet + # blanks every cover into a cloud-download placeholder and forces a + # re-download on the next open (issue #1118). + 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" + safe_clean ~/Library/Caches/com.apple.AppleMediaServices/* "Apple Media Services cache" + safe_clean ~/Library/Caches/com.apple.duetexpertd/* "Duet Expert cache" + safe_clean ~/Library/Caches/com.apple.parsecd/* "Parsecd cache" + safe_clean ~/Library/Caches/com.apple.python/* "Apple Python cache" + safe_clean ~/Library/Caches/com.apple.e5rt.e5bundlecache/* "Apple Intelligence runtime cache" 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) shopt -s nullglob for container_dir in "$containers_dir"/*; do + [[ -d "$container_dir/Data/Library/Caches" ]] || continue process_container_cache "$container_dir" done + # eval: restore shopt state captured by $(shopt -p) eval "$_ng_state" 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}, $(colorize_human_size "$size_human") ${YELLOW}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 +895,54 @@ 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 + [[ -L "$item" ]] && continue + # Re-check each item, not just the parent bundle: a user may have + # whitelisted a specific cache path, and should_protect_path may + # cover a nested entry. Mirrors clean_group_container_caches. + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi + safe_remove "$item" true || true + done + # eval: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" fi } @@ -541,23 +950,27 @@ 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") + # Skip containers we cannot read (avoids repeated TCC/privacy prompts on macOS). + [[ -r "$container_dir" ]] || continue + local container_id="${container_dir##*/}" # Skip Apple-owned shared containers entirely. case "$container_id" in @@ -565,13 +978,23 @@ 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") + # Skip Safari Web Extension containers: cleaning their caches triggers + # extension reinitialization and can launch Safari unexpectedly. + if [[ -d "$HOME/Library/Containers/$container_id" ]]; then + local _ext_match=false + local _ext_entry + for _ext_entry in "$HOME/Library/Containers/$container_id/"*Safari* \ + "$HOME/Library/Containers/$container_id/"*safari*; do + if [[ -e "$_ext_entry" ]]; then + _ext_match=true + break + fi + done + if [[ "$_ext_match" == "true" ]]; then + continue + fi + fi local normalized_id="$container_id" [[ "$normalized_id" == group.* ]] && normalized_id="${normalized_id#group.}" @@ -601,42 +1024,57 @@ 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: restore shopt state captured by $(shopt -p) + eval "$_nullglob_state" + eval "$_dotglob_state" if [[ "$candidate_changed" == "true" ]]; then total_size=$((total_size + candidate_size_kb)) @@ -645,6 +1083,186 @@ clean_group_container_caches() { fi done done + # eval: restore shopt state captured by $(shopt -p) + 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}, $(colorize_human_size "$size_human") ${YELLOW}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 "$MOLE_TIMEOUT_QUICK_DETECT_SEC" 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" + ) + 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 + + local metadata_scan_timeout="${MOLE_EXTERNAL_VOLUME_SCAN_TIMEOUT:-15}" + [[ "$metadata_scan_timeout" =~ ^[0-9]+$ ]] || metadata_scan_timeout=15 + 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 < <(run_with_timeout "$metadata_scan_timeout" find -P "$volume" -xdev -type f -name "._*" -print0 2> /dev/null || true) stop_section_spinner @@ -652,15 +1270,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}, $(colorize_human_size "$size_human") ${YELLOW}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). @@ -668,32 +1290,114 @@ clean_browsers() { safe_clean ~/Library/Caches/com.apple.Safari/* "Safari cache" # Chrome/Chromium. safe_clean ~/Library/Caches/Google/Chrome/* "Chrome cache" - safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" - safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" - safe_clean ~/Library/Application\ Support/Google/Chrome/component_crx_cache/* "Chrome component CRX cache" + # Do not clean Chromium Service Worker ScriptCache. Even when the browser is + # closed, removing MV3 extension bytecode can break extension service + # workers and trigger security warnings during dry-run scans. See #785, + # #964, and #968. + local _chrome_running=false + pgrep -x "Google Chrome" > /dev/null 2>&1 && _chrome_running=true + if [[ "$_chrome_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/Google/Chrome/*/Application\ Cache/* "Chrome app cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/Code\ Cache/* "Chrome code cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/GPUCache/* "Chrome GPU cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/DawnCache/* "Chrome Dawn cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/GrShaderCache/* "Chrome GR shader cache" + safe_clean ~/Library/Application\ Support/Google/Chrome/*/GraphiteDawnCache/* "Chrome Graphite Dawn 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" + safe_clean ~/Library/Application\ Support/Google/Chrome/Crashpad/completed/* "Chrome crash reports" + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Chrome is running · Application Support cache cleanup skipped" + fi + local _chrome_profile + for _chrome_profile in "$HOME/Library/Application Support/Google/Chrome"/*/; do + clean_service_worker_cache "Chrome" "$_chrome_profile/Service Worker/CacheStorage" + 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" - safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" + # Arc Browser. + if [[ -d ~/Library/Application\ Support/Arc ]]; then + safe_clean ~/Library/Caches/company.thebrowser.Browser/* "Arc cache" + local _arc_profile + local _arc_running=false + pgrep -x "Arc" > /dev/null 2>&1 && _arc_running=true + if [[ "$_arc_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/Arc/*/Code\ Cache/* "Arc code cache" + safe_clean ~/Library/Application\ Support/Arc/*/GPUCache/* "Arc GPU cache" + safe_clean ~/Library/Application\ Support/Arc/*/DawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/*/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/*/GraphiteDawnCache/* "Arc Graphite Dawn 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" + safe_clean ~/Library/Application\ Support/Arc/Crashpad/completed/* "Arc crash reports" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/Code\ Cache/* "Arc code cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/GPUCache/* "Arc GPU cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/DawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/*/GraphiteDawnCache/* "Arc Graphite Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/ShaderCache/* "Arc shader cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/GrShaderCache/* "Arc GR shader cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/GraphiteDawnCache/* "Arc Dawn cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/component_crx_cache/* "Arc component CRX cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/extensions_crx_cache/* "Arc extensions CRX cache" + safe_clean ~/Library/Application\ Support/Arc/User\ Data/Crashpad/completed/* "Arc crash reports" + fi + for _arc_profile in "$HOME/Library/Application Support/Arc"/*/; do + clean_service_worker_cache "Arc" "$_arc_profile/Service Worker/CacheStorage" + done + for _arc_profile in "$HOME/Library/Application Support/Arc/User Data"/*/; do + [[ -d "$_arc_profile" ]] || continue + clean_service_worker_cache "Arc" "$_arc_profile/Service Worker/CacheStorage" + done + fi safe_clean ~/Library/Caches/company.thebrowser.dia/* "Dia cache" - safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" + if [[ -d ~/Library/Application\ Support/BraveSoftware ]]; then + safe_clean ~/Library/Caches/BraveSoftware/Brave-Browser/* "Brave cache" + local _brave_profile + local _brave_running=false + pgrep -x "Brave Browser" > /dev/null 2>&1 && _brave_running=true + if [[ "$_brave_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/Application\ Cache/* "Brave app cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/Code\ Cache/* "Brave code cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GPUCache/* "Brave GPU cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/DawnCache/* "Brave Dawn cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GrShaderCache/* "Brave GR shader cache" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/*/GraphiteDawnCache/* "Brave Graphite Dawn 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" + safe_clean ~/Library/Application\ Support/BraveSoftware/Brave-Browser/Crashpad/completed/* "Brave crash reports" + fi + for _brave_profile in "$HOME/Library/Application Support/BraveSoftware/Brave-Browser"/*/; do + clean_service_worker_cache "Brave" "$_brave_profile/Service Worker/CacheStorage" + done + fi # Helium Browser. - safe_clean ~/Library/Caches/net.imput.helium/* "Helium cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/*/GPUCache/* "Helium GPU cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/component_crx_cache/* "Helium component cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/extensions_crx_cache/* "Helium extensions cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/GrShaderCache/* "Helium shader cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/GraphiteDawnCache/* "Helium Dawn cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/ShaderCache/* "Helium shader cache" - safe_clean ~/Library/Application\ Support/net.imput.helium/*/Application\ Cache/* "Helium app cache" + if [[ -d ~/Library/Application\ Support/net.imput.helium ]]; then + safe_clean ~/Library/Caches/net.imput.helium/* "Helium cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/*/GPUCache/* "Helium GPU cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/component_crx_cache/* "Helium component cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/extensions_crx_cache/* "Helium extensions cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/GrShaderCache/* "Helium shader cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/GraphiteDawnCache/* "Helium Dawn cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/ShaderCache/* "Helium shader cache" + safe_clean ~/Library/Application\ Support/net.imput.helium/*/Application\ Cache/* "Helium app cache" + fi # Yandex Browser. - safe_clean ~/Library/Caches/Yandex/YandexBrowser/* "Yandex cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/ShaderCache/* "Yandex shader cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GrShaderCache/* "Yandex GR shader cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GraphiteDawnCache/* "Yandex Dawn cache" - safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/*/GPUCache/* "Yandex GPU cache" + if [[ -d ~/Library/Application\ Support/Yandex ]]; then + safe_clean ~/Library/Caches/Yandex/YandexBrowser/* "Yandex cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/ShaderCache/* "Yandex shader cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GrShaderCache/* "Yandex GR shader cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/GraphiteDawnCache/* "Yandex Dawn cache" + safe_clean ~/Library/Application\ Support/Yandex/YandexBrowser/*/GPUCache/* "Yandex GPU cache" + fi local firefox_running=false if pgrep -x "Firefox" > /dev/null 2>&1; then firefox_running=true @@ -704,7 +1408,27 @@ clean_browsers() { safe_clean ~/Library/Caches/Firefox/* "Firefox cache" fi safe_clean ~/Library/Caches/com.operasoftware.Opera/* "Opera cache" - safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache" + # Vivaldi Browser. + if [[ -d ~/Library/Application\ Support/Vivaldi ]]; then + safe_clean ~/Library/Caches/com.vivaldi.Vivaldi/* "Vivaldi cache" + local _vivaldi_profile + local _vivaldi_running=false + pgrep -x "Vivaldi" > /dev/null 2>&1 && _vivaldi_running=true + if [[ "$_vivaldi_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/Vivaldi/*/Code\ Cache/* "Vivaldi code cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GPUCache/* "Vivaldi GPU cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/DawnCache/* "Vivaldi Dawn cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GrShaderCache/* "Vivaldi GR shader cache" + safe_clean ~/Library/Application\ Support/Vivaldi/*/GraphiteDawnCache/* "Vivaldi Graphite Dawn 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" + safe_clean ~/Library/Application\ Support/Vivaldi/Crashpad/completed/* "Vivaldi crash reports" + fi + for _vivaldi_profile in "$HOME/Library/Application Support/Vivaldi"/*/; do + clean_service_worker_cache "Vivaldi" "$_vivaldi_profile/Service Worker/CacheStorage" + done + fi safe_clean ~/Library/Caches/Comet/* "Comet cache" safe_clean ~/Library/Caches/com.kagi.kagimacOS/* "Orion cache" safe_clean ~/Library/Caches/zen/* "Zen cache" @@ -716,23 +1440,69 @@ clean_browsers() { clean_chrome_old_versions clean_edge_old_versions clean_edge_updater_old_versions + clean_brave_old_versions + # QQ Browser 3 (Chromium-based). + if [[ -d ~/Library/Application\ Support/QQBrowser3 ]]; then + safe_clean ~/Library/Caches/com.tencent.QQBrowser3/* "QQ Browser cache" + local _qqbrowser_running=false + pgrep -x "QQBrowser3" > /dev/null 2>&1 && _qqbrowser_running=true + if [[ "$_qqbrowser_running" != "true" ]]; then + safe_clean ~/Library/Application\ Support/QQBrowser3/*/Code\ Cache/* "QQ Browser code cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/*/GPUCache/* "QQ Browser GPU cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/ShaderCache/* "QQ Browser shader cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/GrShaderCache/* "QQ Browser GR shader cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/GraphiteDawnCache/* "QQ Browser Dawn cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/component_crx_cache/* "QQ Browser component cache" + safe_clean ~/Library/Application\ Support/QQBrowser3/Crashpad/completed/* "QQ Browser crash reports" + fi + fi } # Cloud storage caches. clean_cloud_storage() { - safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" - safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" - safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning cloud storage caches..." >&2 + fi + if pgrep -x "Dropbox" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Dropbox is running · cache cleanup skipped" + else + safe_clean ~/Library/Caches/com.dropbox.* "Dropbox cache" + safe_clean ~/Library/Caches/com.getdropbox.dropbox "Dropbox cache" + fi + if pgrep -x "Google Drive" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} Google Drive is running · cache cleanup skipped" + else + safe_clean ~/Library/Caches/com.google.GoogleDrive "Google Drive cache" + fi safe_clean ~/Library/Caches/com.baidu.netdisk "Baidu Netdisk cache" safe_clean ~/Library/Caches/com.alibaba.teambitiondisk "Alibaba Cloud cache" safe_clean ~/Library/Caches/com.box.desktop "Box cache" - safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" + if pgrep -x "OneDrive" > /dev/null 2>&1; then + echo -e " ${GRAY}${ICON_WARNING}${NC} OneDrive is running · cache cleanup skipped" + else + safe_clean ~/Library/Caches/com.microsoft.OneDrive "OneDrive cache" + fi } # Office app caches. clean_office_applications() { + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning office application caches..." >&2 + fi safe_clean ~/Library/Caches/com.microsoft.Word "Microsoft Word cache" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning Word container cache..." >&2 + fi + 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" + if [[ "${MO_DEBUG:-0}" == "1" ]]; then + echo "[DEBUG] Cleaning Excel container cache..." >&2 + fi + 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" @@ -742,11 +1512,24 @@ clean_office_applications() { } # Virtualization caches. +clean_utm_caches() { + if pgrep -x "UTM" > /dev/null 2>&1; then + debug_log "Skipping UTM caches while UTM is running" + return 0 + fi + + safe_clean ~/Library/Caches/com.utmapp.UTM/* "UTM app cache" + safe_clean ~/Library/Containers/com.utmapp.UTM/Data/Library/Caches/* "UTM sandbox cache" + safe_clean ~/Library/Containers/com.utmapp.UTM/Data/tmp/* "UTM temporary files" +} + clean_virtualization_tools() { stop_section_spinner safe_clean ~/Library/Caches/com.vmware.fusion "VMware Fusion cache" safe_clean ~/Library/Caches/com.parallels.* "Parallels cache" + clean_utm_caches safe_clean ~/VirtualBox\ VMs/.cache "VirtualBox cache" + safe_clean ~/Library/Caches/lima/download/by-url-sha256/* "Lima download cache" safe_clean ~/.vagrant.d/tmp/* "Vagrant temporary files" } @@ -791,24 +1574,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 @@ -817,6 +1589,25 @@ app_support_item_size_bytes() { return 1 } +app_support_dir_has_regenerable_cache_markers() { + local app_dir="$1" + local marker + + for marker in \ + "$app_dir/Code Cache" \ + "$app_dir/GPUCache" \ + "$app_dir/DawnCache" \ + "$app_dir/GrShaderCache" \ + "$app_dir/GraphiteDawnCache" \ + "$app_dir/DawnGraphiteCache" \ + "$app_dir/DawnWebGPUCache" \ + "$app_dir/Crashpad"; do + [[ -e "$marker" ]] && return 0 + done + + return 1 +} + # Application Support logs/caches. clean_application_support_logs() { if [[ ! -d "$HOME/Library/Application Support" ]] || ! ls "$HOME/Library/Application Support" > /dev/null 2>&1; then @@ -853,17 +1644,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 @@ -871,9 +1667,31 @@ clean_application_support_logs() { if is_critical_system_component "$app_name"; then continue fi - local -a start_candidates=("$app_dir/log" "$app_dir/logs" "$app_dir/activitylog" "$app_dir/Cache/Cache_Data" "$app_dir/Crashpad/completed") + # Application Support can hold licenses, databases, offline assets and + # session state. Keep this generic pass to explicit, regenerable cache + # subtrees only; app-specific log/cache cleanup belongs in allowlisted + # app modules above. + local -a start_candidates=( + "$app_dir/Code Cache" + "$app_dir/GPUCache" + "$app_dir/DawnCache" + "$app_dir/GrShaderCache" + "$app_dir/GraphiteDawnCache" + "$app_dir/DawnGraphiteCache" + "$app_dir/DawnWebGPUCache" + "$app_dir/Crashpad/completed" + ) + if app_support_dir_has_regenerable_cache_markers "$app_dir"; then + start_candidates+=( + "$app_dir/Cache" + "$app_dir/CachedData" + ) + fi 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 +1719,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 @@ -1019,6 +1840,7 @@ clean_application_support_logs() { if [[ "$pipefail_was_set" == "true" ]]; then set -o pipefail fi + # eval: restore shopt state captured by $(shopt -p) eval "$_ng_state" stop_section_spinner if [[ "$found_any" == "true" ]]; then @@ -1027,15 +1849,17 @@ clean_application_support_logs() { local total_size_kb=$(((total_size_bytes + 1023) / 1024)) if [[ "$DRY_RUN" == "true" ]]; then if [[ "$total_size_partial" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}at least $size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}at least $(colorize_human_size "$size_human") ${YELLOW}dry${NC}" else - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Application Support logs/caches${NC}, $(colorize_human_size "$size_human") ${YELLOW}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 +1868,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, $(colorize_human_size "$size_human") ${YELLOW}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" @@ -1068,6 +1973,30 @@ check_large_file_candidates() { local threshold_kb=$((1024 * 1024)) # 1GB local found_any=false + _large_candidate_size_kb() { + local path="$1" + local timeout_seconds="${MOLE_LARGE_CANDIDATE_SIZE_TIMEOUT:-3}" + [[ "$timeout_seconds" =~ ^[0-9]+$ ]] || timeout_seconds=3 + local du_output="" + du_output=$(run_with_timeout "$timeout_seconds" du -skP "$path" 2> /dev/null || true) + local size_kb="${du_output%%[^0-9]*}" + [[ "$size_kb" =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$size_kb" + } + + _report_large_review_dir() { + local label="$1" + local path="$2" + [[ -d "$path" ]] || return 0 + local size_kb="" + size_kb=$(_large_candidate_size_kb "$path") || return 0 + [[ "$size_kb" -ge "$threshold_kb" ]] || return 0 + local size_human + size_human=$(bytes_to_human "$((size_kb * 1024))") + echo -e " ${YELLOW}${ICON_WARNING}${NC} ${label}: ${GREEN}${size_human}${NC}${GRAY}, Path: $path${NC}" + found_any=true + } + local mail_dir="$HOME/Library/Mail" if [[ -d "$mail_dir" ]]; then local mail_kb @@ -1121,7 +2050,7 @@ check_large_file_candidates() { if [[ "${SYSTEM_CLEAN:-false}" != "true" ]] && command -v tmutil > /dev/null 2>&1 && defaults read /Library/Preferences/com.apple.TimeMachine AutoBackup 2> /dev/null | grep -qE '^[01]$'; then local snapshot_list snapshot_count - snapshot_list=$(run_with_timeout 3 tmutil listlocalsnapshots / 2> /dev/null || true) + snapshot_list=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" tmutil listlocalsnapshots / 2> /dev/null || true) if [[ -n "$snapshot_list" ]]; then snapshot_count=$(echo "$snapshot_list" | { grep -Eo 'com\.apple\.TimeMachine\.[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}' || true; } | wc -l | awk '{print $1}') if [[ "$snapshot_count" =~ ^[0-9]+$ && "$snapshot_count" -gt 0 ]]; then @@ -1134,7 +2063,7 @@ check_large_file_candidates() { if command -v docker > /dev/null 2>&1; then local docker_output - docker_output=$(run_with_timeout 3 docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2> /dev/null || true) + docker_output=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" docker system df --format '{{.Type}}\t{{.Size}}\t{{.Reclaimable}}' 2> /dev/null || true) if [[ -n "$docker_output" ]]; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" while IFS=$'\t' read -r dtype dsize dreclaim; do @@ -1143,7 +2072,7 @@ check_large_file_candidates() { done <<< "$docker_output" found_any=true else - docker_output=$(run_with_timeout 3 docker system df 2> /dev/null || true) + docker_output=$(run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" docker system df 2> /dev/null || true) if [[ -n "$docker_output" ]]; then echo -e " ${YELLOW}${ICON_WARNING}${NC} Docker storage:" echo -e " ${GRAY}${ICON_REVIEW}${NC} ${GRAY}Run: docker system df${NC}" @@ -1152,10 +2081,25 @@ check_large_file_candidates() { fi fi + _report_large_review_dir "Xcode archives (review only)" "$HOME/Library/Developer/Xcode/Archives" + _report_large_review_dir "iOS backups (review only)" "$HOME/Library/Application Support/MobileSync/Backup" + _report_large_review_dir "LM Studio models (review only)" "$HOME/.lmstudio/models" + local orbstack_data + for orbstack_data in "$HOME"/Library/Group\ Containers/*dev.orbstack/data "$HOME/OrbStack"; do + _report_large_review_dir "OrbStack data (review only)" "$orbstack_data" + done + _report_large_review_dir "Lima data (review only)" "$HOME/.lima" + _report_large_review_dir "Maven local repository (review only)" "$HOME/.m2/repository" + _report_large_review_dir "pnpm store (review only)" "$HOME/Library/pnpm/store" + _report_large_review_dir "Conda packages (review only)" "$HOME/.conda/pkgs" + _report_large_review_dir "Anaconda packages (review only)" "$HOME/anaconda3/pkgs" + if [[ "$found_any" == "false" ]]; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} No large items detected in common locations" fi + unset -f _large_candidate_size_kb _report_large_review_dir + note_activity return 0 } diff --git a/Resources/mole/lib/core/app_protection.sh b/Resources/mole/lib/core/app_protection.sh old mode 100755 new mode 100644 index 144aac4..8490ef9 --- a/Resources/mole/lib/core/app_protection.sh +++ b/Resources/mole/lib/core/app_protection.sh @@ -17,545 +17,10 @@ if ! declare -p WHITELIST_PATTERNS &> /dev/null; then declare -a WHITELIST_PATTERNS=() fi -# Application Management - -# ============================================================================ -# Performance Note: -# - SYSTEM_CRITICAL_BUNDLES_FAST: Fast wildcard patterns for cleanup operations -# - SYSTEM_CRITICAL_BUNDLES: Detailed list for uninstall protection (lazy-loaded) -# ============================================================================ - -# Fast patterns for cleanup operations (used by should_protect_data) -# These wildcards provide adequate protection with minimal performance impact -readonly SYSTEM_CRITICAL_BUNDLES_FAST=( - "com.apple.*" - "loginwindow" - "dock" - "systempreferences" - "finder" - "safari" - "backgroundtaskmanagement*" - "keychain*" - "security*" - "bluetooth*" - "wifi*" - "network*" - "tcc" - "notification*" - "accessibility*" - "universalaccess*" - "HIToolbox*" - "textinput*" - "TextInput*" - "keyboard*" - "Keyboard*" - "inputsource*" - "InputSource*" - "keylayout*" - "KeyLayout*" - "GlobalPreferences" - ".GlobalPreferences" - "org.pqrs.Karabiner*" -) - -# Detailed list for uninstall protection -# Critical system components protected from uninstallation -# Note: We explicitly list system components instead of using "com.apple.*" wildcard -# to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.) -readonly SYSTEM_CRITICAL_BUNDLES=( - # Core system applications (in /System/Applications/) - "com.apple.finder" - "com.apple.dock" - "com.apple.Safari" - "com.apple.mail" - "com.apple.systempreferences" - "com.apple.SystemSettings" - "com.apple.Settings*" - "com.apple.controlcenter*" - "com.apple.Spotlight" - "com.apple.notificationcenterui" - "com.apple.loginwindow" - "com.apple.Preview" - "com.apple.TextEdit" - "com.apple.Notes" - "com.apple.reminders" - "com.apple.iCal" - "com.apple.AddressBook" - "com.apple.Photos" - "com.apple.AppStore" - "com.apple.calculator" - "com.apple.Dictionary" - "com.apple.ScreenSharing" - "com.apple.ActivityMonitor" - "com.apple.Console" - "com.apple.DiskUtility" - "com.apple.KeychainAccess" - "com.apple.DigitalColorMeter" - "com.apple.grapher" - "com.apple.Terminal" - "com.apple.ScriptEditor2" - "com.apple.VoiceOverUtility" - "com.apple.BluetoothFileExchange" - "com.apple.print.PrinterProxy" - "com.apple.systempreferences*" - "com.apple.SystemProfiler" - "com.apple.FontBook" - "com.apple.ColorSyncUtility" - "com.apple.audio.AudioMIDISetup" - "com.apple.DirectoryUtility" - "com.apple.NetworkUtility" - "com.apple.exposelauncher" - "com.apple.MigrateAssistant" - "com.apple.RAIDUtility" - "com.apple.BootCampAssistant" - - # System services and daemons - "com.apple.SecurityAgent" - "com.apple.CoreServices*" - "com.apple.SystemUIServer" - "com.apple.backgroundtaskmanagement*" - "com.apple.loginitems*" - "com.apple.sharedfilelist*" - "com.apple.sfl*" - "com.apple.coreservices*" - "com.apple.metadata*" - "com.apple.MobileSoftwareUpdate*" - "com.apple.SoftwareUpdate*" - "com.apple.installer*" - "com.apple.frameworks*" - "com.apple.security*" - "com.apple.keychain*" - "com.apple.trustd*" - "com.apple.securityd*" - "com.apple.cloudd*" - "com.apple.iCloud*" - "com.apple.WiFi*" - "com.apple.airport*" - "com.apple.Bluetooth*" - - # Input methods (system built-in) - "com.apple.inputmethod.*" - "com.apple.inputsource*" - "com.apple.TextInput*" - "com.apple.CharacterPicker*" - "com.apple.PressAndHold*" - - # Legacy pattern-based entries (non com.apple.*) - "loginwindow" - "dock" - "systempreferences" - "finder" - "safari" - "backgroundtaskmanagementagent" - "keychain*" - "security*" - "bluetooth*" - "wifi*" - "network*" - "tcc" - "notification*" - "accessibility*" - "universalaccess*" - "HIToolbox*" - "textinput*" - "TextInput*" - "keyboard*" - "Keyboard*" - "inputsource*" - "InputSource*" - "keylayout*" - "KeyLayout*" - "GlobalPreferences" - ".GlobalPreferences" - "org.pqrs.Karabiner*" -) - -# Apple apps that CAN be uninstalled (from App Store or developer.apple.com) -readonly APPLE_UNINSTALLABLE_APPS=( - "com.apple.dt.*" # Xcode, Instruments, FileMerge - "com.apple.FinalCut*" # Final Cut Pro - "com.apple.Motion" - "com.apple.Compressor" - "com.apple.logic*" # Logic Pro - "com.apple.garageband*" # GarageBand - "com.apple.iMovie" - "com.apple.iWork.*" # Pages, Numbers, Keynote - "com.apple.MainStage*" - "com.apple.server.*" # macOS Server - "com.apple.Playgrounds" # Swift Playgrounds -) - -# Applications with sensitive data; protected during cleanup but removable -readonly DATA_PROTECTED_BUNDLES=( - # Input Methods (protected during cleanup, uninstall allowed) - "com.tencent.inputmethod.QQInput" - "com.sogou.inputmethod.*" - "com.baidu.inputmethod.*" - "com.googlecode.rimeime.*" - "im.rime.*" - "*.inputmethod" - "*.InputMethod" - "*IME" - - # System Utilities & Cleanup - "com.nektony.*" - "com.macpaw.*" - "com.freemacsoft.AppCleaner" - "com.omnigroup.omnidisksweeper" - "com.daisydiskapp.*" - "com.tunabellysoftware.*" - "com.grandperspectiv.*" - "com.binaryfruit.*" - - # Password Managers - "com.1password.*" - "com.agilebits.*" - "com.lastpass.*" - "com.dashlane.*" - "com.bitwarden.*" - "com.keepassx.*" - "org.keepassx.*" - "org.keepassxc.*" - "com.authy.*" - "com.yubico.*" - - # IDEs & Editors - "com.jetbrains.*" - "JetBrains*" - "com.microsoft.VSCode" - "com.visualstudio.code.*" - "com.sublimetext.*" - "com.sublimehq.*" - "com.microsoft.VSCodeInsiders" - "com.apple.dt.Xcode" - "com.coteditor.CotEditor" - "com.macromates.TextMate" - "com.panic.Nova" - "abnerworks.Typora" - "com.uranusjr.macdown" - - # AI & LLM Tools - "com.todesktop.*" - "Cursor" - "com.anthropic.claude*" - "Claude" - "com.openai.chat*" - "ChatGPT" - "com.ollama.ollama" - "Ollama" - "com.lmstudio.lmstudio" - "LM Studio" - "co.supertool.chatbox" - "page.jan.jan" - "com.huggingface.huggingchat" - "Gemini" - "com.perplexity.Perplexity" - "com.drawthings.DrawThings" - "com.divamgupta.diffusionbee" - "com.exafunction.windsurf" - "com.quora.poe.electron" - "chat.openai.com.*" - - # Database Clients - "com.sequelpro.*" - "com.sequel-ace.*" - "com.tinyapp.*" - "com.dbeaver.*" - "com.navicat.*" - "com.mongodb.compass" - "com.redis.RedisInsight" - "com.pgadmin.pgadmin4" - "com.eggerapps.Sequel-Pro" - "com.valentina-db.Valentina-Studio" - "com.dbvis.DbVisualizer" - - # API & Network Tools - "com.postmanlabs.mac" - "com.konghq.insomnia" - "com.CharlesProxy.*" - "com.proxyman.*" - "com.getpaw.*" - "com.luckymarmot.Paw" - "com.charlesproxy.charles" - "com.telerik.Fiddler" - "com.usebruno.app" - - # Network Proxy & VPN Tools (Clash variants - use specific patterns to avoid false positives) - "com.clash.*" - "ClashX*" - "clash-*" - "Clash-*" - "*-clash" - "*-Clash" - "clash.*" - "Clash.*" - "clash_*" - "clashverge*" - "ClashVerge*" - "com.nssurge.surge-mac" - "*surge*" - "*Surge*" - "mihomo*" - "*openvpn*" - "*OpenVPN*" - "net.openvpn.*" - - # Proxy Clients - "*ShadowsocksX-NG*" - "com.qiuyuzhou.*" - "*v2ray*" - "*V2Ray*" - "*v2box*" - "*V2Box*" - "*nekoray*" - "*sing-box*" - "*OneBox*" - "*hiddify*" - "*Hiddify*" - "*loon*" - "*Loon*" - "*quantumult*" - - # Mesh & Corporate VPNs - "*tailscale*" - "io.tailscale.*" - "*zerotier*" - "com.zerotier.*" - "*1dot1dot1dot1*" # Cloudflare WARP - "*cloudflare*warp*" - - # Commercial VPNs - "*nordvpn*" - "*expressvpn*" - "*protonvpn*" - "*surfshark*" - "*windscribe*" - "*mullvad*" - "*privateinternetaccess*" - - # Screensaver & Wallpaper - "*Aerial.saver*" - "com.JohnCoates.Aerial*" - "*Fliqlo*" - "*fliqlo*" - - # Git & Version Control - "com.github.GitHubDesktop" - "com.sublimemerge" - "com.torusknot.SourceTreeNotMAS" - "com.git-tower.Tower*" - "com.gitfox.GitFox" - "com.github.Gitify" - "com.fork.Fork" - "com.axosoft.gitkraken" - - # Terminal & Shell - "com.googlecode.iterm2" - "net.kovidgoyal.kitty" - "io.alacritty" - "com.github.wez.wezterm" - "com.hyper.Hyper" - "com.mizage.divvy" - "com.fig.Fig" - "dev.warp.Warp-Stable" - "com.termius-dmg" - - # Docker & Virtualization - "com.docker.docker" - "com.getutm.UTM" - "com.vmware.fusion" - "com.parallels.desktop.*" - "org.virtualbox.app.VirtualBox" - "com.vagrant.*" - "com.orbstack.OrbStack" - - # System Monitoring - "com.bjango.istatmenus*" - "eu.exelban.Stats" - "com.monitorcontrol.*" - "com.bresink.system-toolkit.*" - "com.mediaatelier.MenuMeters" - "com.activity-indicator.app" - "net.cindori.sensei" - - # Window Management - "com.macitbetter.*" # BetterTouchTool, BetterSnapTool - "com.hegenberg.*" - "com.manytricks.*" # Moom, Witch, etc. - "com.divisiblebyzero.*" - "com.koingdev.*" - "com.if.Amphetamine" - "com.lwouis.alt-tab-macos" - "net.matthewpalmer.Vanilla" - "com.lightheadsw.Caffeine" - "com.contextual.Contexts" - "com.amethyst.Amethyst" - "com.knollsoft.Rectangle" - "com.knollsoft.Hookshot" - "com.surteesstudios.Bartender" - "com.gaosun.eul" - "com.pointum.hazeover" - - # Launcher & Automation - "com.runningwithcrayons.Alfred" - "com.raycast.macos" - "com.blacktree.Quicksilver" - "com.stairways.keyboardmaestro.*" - "com.manytricks.Butler" - "com.happenapps.Quitter" - "com.pilotmoon.scroll-reverser" - "org.pqrs.Karabiner-Elements" - "com.apple.Automator" - - # Note-Taking - "com.bear-writer.*" - "com.typora.*" - "com.ulyssesapp.*" - "com.literatureandlatte.*" - "com.dayoneapp.*" - "notion.id" - "md.obsidian" - "com.logseq.logseq" - "com.evernote.Evernote" - "com.onenote.mac" - "com.omnigroup.OmniOutliner*" - "net.shinyfrog.bear" - "com.goodnotes.GoodNotes" - "com.marginnote.MarginNote*" - "com.roamresearch.*" - "com.reflect.ReflectApp" - "com.inkdrop.*" - - # Design & Creative - "com.adobe.*" - "com.bohemiancoding.*" - "com.figma.*" - "com.framerx.*" - "com.zeplin.*" - "com.invisionapp.*" - "com.principle.*" - "com.pixelmatorteam.*" - "com.affinitydesigner.*" - "com.affinityphoto.*" - "com.affinitypublisher.*" - "com.linearity.curve" - "com.canva.CanvaDesktop" - "com.maxon.cinema4d" - "com.autodesk.*" - "com.sketchup.*" - - # Communication - "com.tencent.xinWeChat" - "com.tencent.qq" - "com.alibaba.DingTalkMac" - "com.alibaba.AliLang.osx" - "com.alibaba.alilang3.osx.ShipIt" - "com.alibaba.AlilangMgr.QueryNetworkInfo" - "us.zoom.xos" - "com.microsoft.teams*" - "com.slack.Slack" - "com.hnc.Discord" - "app.legcord.Legcord" - "org.telegram.desktop" - "ru.keepcoder.Telegram" - "net.whatsapp.WhatsApp" - "com.skype.skype" - "com.cisco.webexmeetings" - "com.ringcentral.RingCentral" - "com.readdle.smartemail-Mac" - "com.airmail.*" - "com.postbox-inc.postbox" - "com.tinyspeck.slackmacgap" - - # Task Management - "com.omnigroup.OmniFocus*" - "com.culturedcode.*" - "com.todoist.*" - "com.any.do.*" - "com.ticktick.*" - "com.microsoft.to-do" - "com.trello.trello" - "com.asana.nativeapp" - "com.clickup.*" - "com.monday.desktop" - "com.airtable.airtable" - "com.notion.id" - "com.linear.linear" - - # File Transfer & Sync - "com.panic.transmit*" - "com.binarynights.ForkLift*" - "com.noodlesoft.Hazel" - "com.cyberduck.Cyberduck" - "io.filezilla.FileZilla" - "com.apple.Xcode.CloudDocuments" - "com.synology.*" - - # Cloud Storage & Backup - "com.dropbox.*" - "com.getdropbox.*" - "*dropbox*" - "ws.agile.*" - "com.backblaze.*" - "*backblaze*" - "com.box.desktop*" - "*box.desktop*" - "com.microsoft.OneDrive*" - "com.microsoft.SyncReporter" - "*OneDrive*" - "com.google.GoogleDrive" - "com.google.keystone*" - "*GoogleDrive*" - "com.amazon.drive" - "com.apple.bird" - "com.apple.CloudDocs*" - "com.displaylink.*" - "com.fujitsu.pfu.ScanSnap*" - "com.citrix.*" - "org.xquartz.*" - "us.zoom.updater*" - "com.DigiDNA.iMazing*" - "com.shirtpocket.*" - "homebrew.mxcl.*" - - # Screenshot & Recording - "com.cleanshot.*" - "com.xnipapp.xnip" - "com.reincubate.camo" - "com.tunabellysoftware.ScreenFloat" - "net.telestream.screenflow*" - "com.techsmith.snagit*" - "com.techsmith.camtasia*" - "com.obsidianapp.screenrecorder" - "com.kap.Kap" - "com.getkap.*" - "com.linebreak.CloudApp" - "com.droplr.droplr-mac" - - # Media & Entertainment - "com.spotify.client" - "com.apple.Music" - "com.apple.podcasts" - "com.apple.BKAgentService" - "com.apple.iBooksX" - "com.apple.iBooks" - "com.blackmagic-design.*" - "com.colliderli.iina" - "org.videolan.vlc" - "io.mpv" - "tv.plex.player.desktop" - "com.netease.163music" - - # Web Browsers - "Firefox" - "org.mozilla.*" - - # License & App Stores - "com.paddle.Paddle*" - "com.setapp.DesktopClient" - "com.devmate.*" - "org.sparkle-project.Sparkle" -) +# Bundle ID / pattern data is sourced from a sibling file so this file +# stays focused on logic. See app_protection_data.sh for the lists. +# shellcheck source=lib/core/app_protection_data.sh +source "$_MOLE_CORE_DIR/app_protection_data.sh" # Centralized check for critical system components (case-insensitive) is_critical_system_component() { @@ -575,10 +40,6 @@ is_critical_system_component() { esac } -# Legacy function - preserved for backward compatibility -# Use should_protect_from_uninstall() or should_protect_data() instead -readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}") - # Check if bundle ID matches pattern (glob support) bundle_matches_pattern() { local bundle_id="$1" @@ -615,14 +76,13 @@ build_regex_var() { regex="$regex|$p" fi done + # eval: indirect write by name; bash 3.2 has no nameref eval "$var_name=\"\$regex\"" } # Lazy-loaded regex (only built when needed) APPLE_UNINSTALLABLE_REGEX="" SYSTEM_CRITICAL_REGEX="" -SYSTEM_CRITICAL_FAST_REGEX="" -DATA_PROTECTED_REGEX="" _ensure_uninstall_regex() { if [[ -z "$SYSTEM_CRITICAL_REGEX" ]]; then @@ -631,13 +91,6 @@ _ensure_uninstall_regex() { fi } -_ensure_data_protection_regex() { - if [[ -z "$SYSTEM_CRITICAL_FAST_REGEX" ]]; then - build_regex_var SYSTEM_CRITICAL_FAST_REGEX "${SYSTEM_CRITICAL_BUNDLES_FAST[@]}" - build_regex_var DATA_PROTECTED_REGEX "${DATA_PROTECTED_BUNDLES[@]}" - fi -} - # Check if application is a protected system component should_protect_from_uninstall() { local bundle_id="$1" @@ -655,6 +108,42 @@ should_protect_from_uninstall() { return 1 } +# Print the vendor name when an app must be removed through its official +# uninstaller instead of Mole's generic Trash/delete path. +official_uninstaller_vendor() { + local bundle_id="${1:-}" + local display_name="${2:-}" + local app_path="${3:-}" + local normalized_bundle normalized_name normalized_path + normalized_bundle=$(printf '%s' "$bundle_id" | LC_ALL=C tr '[:upper:]' '[:lower:]') + normalized_name=$(printf '%s' "$display_name" | LC_ALL=C tr '[:upper:]' '[:lower:]') + normalized_path=$(basename "${app_path:-}" .app | LC_ALL=C tr '[:upper:]' '[:lower:]') + + local rule vendor prefixes fragments prefix fragment + local -a _prefixes _fragments + for rule in "${OFFICIAL_UNINSTALLER_RULES[@]}"; do + IFS='|' read -r vendor prefixes fragments <<< "$rule" + IFS=',' read -r -a _prefixes <<< "$prefixes" + for prefix in "${_prefixes[@]}"; do + [[ -n "$prefix" && "$normalized_bundle" == "$prefix"* ]] && { + printf '%s\n' "$vendor" + return 0 + } + done + + IFS=',' read -r -a _fragments <<< "$fragments" + for fragment in "${_fragments[@]}"; do + if [[ -n "$fragment" ]] && + { [[ "$normalized_name" == *"$fragment"* ]] || [[ "$normalized_path" == *"$fragment"* ]]; }; then + printf '%s\n' "$vendor" + return 0 + fi + done + done + + return 1 +} + # Check if application data should be protected during cleanup should_protect_data() { local bundle_id="$1" @@ -663,6 +152,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 ;; @@ -684,7 +179,7 @@ should_protect_data() { com.jetbrains.* | JetBrains* | com.microsoft.* | com.visualstudio.*) return 0 ;; - com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | Ollama) + com.sublimetext.* | com.sublimehq.* | Cursor | Claude | ChatGPT | com.openai.codex | Codex | codex-runtimes | Ollama) return 0 ;; # Specific match to avoid ShellCheck redundancy warning with com.clash.* @@ -694,7 +189,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.*) @@ -733,25 +228,20 @@ should_protect_path() { local path="$1" [[ -z "$path" ]] && return 1 - local path_lower - path_lower=$(echo "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]') - - # 1. Keyword-based matching for system components - # Protect System Settings, Preferences, Control Center, and related XPC services - # Also protect "Settings" (used in macOS Sequoia) and savedState files - if [[ "$path_lower" =~ systemsettings || "$path_lower" =~ systempreferences || "$path_lower" =~ controlcenter ]]; then - return 0 - fi + local _container_cache_path=false - # Additional check for com.apple.Settings (macOS Sequoia System Settings) - if [[ "$path_lower" =~ com\.apple\.settings ]]; then - return 0 - fi - - # Protect Notes cache (search index issues) - if [[ "$path_lower" =~ com\.apple\.notes ]]; then - return 0 - fi + # 1. Keyword-based matching for system components (case-insensitive via character classes) + case "$path" in + *[Ss]ystem[Ss]ettings* | *[Ss]ystem[Pp]references* | *[Cc]ontrol[Cc]enter*) + return 0 + ;; + *com.apple.[Ss]ettings* | *com.apple.[Ss]ETTINGS*) + return 0 + ;; + *com.apple.[Nn]otes* | *com.apple.[Nn]OTES*) + return 0 + ;; + esac # 2. Protect caches critical for system UI rendering # These caches are essential for modern macOS (Sonoma/Sequoia) system UI rendering @@ -771,6 +261,10 @@ should_protect_path() { */Library/Group\ Containers/com.apple.systempreferences* | */Library/Group\ Containers/com.apple.Settings*) return 0 ;; + # OrbStack group containers hold live container filesystem images. + */Library/Group\ Containers/*dev.orbstack | */Library/Group\ Containers/*dev.orbstack/* | */.orbstack | */.orbstack/*) + return 0 + ;; # Shared file lists for System Settings (macOS Sequoia) - Issue #136 */com.apple.sharedfilelist/*com.apple.Settings* | */com.apple.sharedfilelist/*com.apple.SystemSettings* | */com.apple.sharedfilelist/*systempreferences*) return 0 @@ -782,8 +276,12 @@ should_protect_path() { # Matches: .../Library/Group Containers/group.id/... if [[ "$path" =~ /Library/Containers/([^/]+) ]] || [[ "$path" =~ /Library/Group\ Containers/([^/]+) ]]; then local bundle_id="${BASH_REMATCH[1]}" - # In uninstall mode, only system components are protected; skip data protection - if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]] && should_protect_data "$bundle_id"; then + # Cache and tmp directories inside containers are regenerable by definition. + # safe_clean calls explicitly target these; let them through instead of + # blocking on the blanket com.apple.* match in should_protect_data. + if [[ "$path" == */Data/Library/Caches/* || "$path" == */Data/tmp/* ]]; then + _container_cache_path=true + elif [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]] && should_protect_data "$bundle_id"; then return 0 fi fi @@ -800,48 +298,121 @@ 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 + ;; + # Codex Desktop and CLI keep conversation indexes and app state in cache- + # shaped paths. Default cleanup must not remove those records. + */Library/Application\ Support/Codex | */Library/Application\ Support/Codex/* | \ + */Library/Logs/com.openai.codex | */Library/Logs/com.openai.codex/* | \ + */.codex/sessions | */.codex/sessions/* | \ + */.codex/auth.json | */.codex/history.jsonl | \ + */.codex/state_*.sqlite | */.codex/logs_*.sqlite | \ + */.codex/session_index.jsonl | */.codex/cache/session_index.jsonl | \ + */.codex/cache/codex_app_directory | */.codex/cache/codex_app_directory/*) + return 0 + ;; # Bluetooth and WiFi configurations */ByHost/com.apple.bluetooth.* | */ByHost/com.apple.wifi.*) return 0 ;; + # NetworkExtension stores VPN tunnel state and provider preferences. + */Library/Preferences/com.apple.networkextension*.plist) + return 0 + ;; # iCloud Drive - protect user's cloud synced data */Library/Mobile\ Documents* | */Mobile\ Documents*) return 0 ;; + # High-risk cleanup denylist: these cache/preferences paths are known + # to contain license, account, plugin, MDM, or system-service state + # despite cache-like names. Keep this as a protection overlay only; it + # is not a cleanup allowlist. + */Library/Accounts | */Library/Accounts/* | \ + */Library/Keychains | */Library/Keychains/* | \ + */Library/Mail | */Library/Mail/* | \ + */Library/Calendars | \ + */Library/Contacts | */Library/Contacts/*) + return 0 + ;; + /Library/Audio/Plug-Ins/Components | /Library/Audio/Plug-Ins/Components/* | \ + /Library/Audio/Plug-Ins/VST | /Library/Audio/Plug-Ins/VST/* | \ + /Library/Audio/Plug-Ins/VST3 | /Library/Audio/Plug-Ins/VST3/* | \ + /Library/Application\ Support/iZotope | /Library/Application\ Support/iZotope/* | \ + */Library/Application\ Support/iZotope | */Library/Application\ Support/iZotope/* | \ + /Library/Application\ Support/LaserSoft\ Imaging | /Library/Application\ Support/LaserSoft\ Imaging/*) + return 0 + ;; + */Library/Preferences/com.native-instruments* | \ + */Library/Preferences/com.avid.mediacomposer*.plist | \ + */Library/Preferences/com.fabfilter.*.[0-9].plist | \ + */Library/Preferences/com.fabfilter.*.[0-9][0-9].plist | \ + */Library/Preferences/com.paceap.*.plist) + return 0 + ;; + /private/var/folders/*/C/com.native-instruments* | \ + /private/var/folders/*/C/com.avid.mediacomposer* | \ + /private/var/folders/*/C/com.paceap.eden.iLokLicenseManager*) + return 0 + ;; + */Library/Caches/ms-playwright | */Library/Caches/ms-playwright/* | \ + */Library/Caches/app.cotypist.Cotypist | */Library/Caches/app.cotypist.Cotypist/* | \ + */Library/Caches/com.displaylink.DisplayLinkUserAgent | */Library/Caches/com.displaylink.DisplayLinkUserAgent/* | \ + */Library/Caches/com.lasersoft-imaging.SilverFast9 | */Library/Caches/com.lasersoft-imaging.SilverFast9/* | \ + */Library/Caches/com.lasersoft-imaging.SilverFast-9-Installer | */Library/Caches/com.lasersoft-imaging.SilverFast-9-Installer/* | \ + */Library/Caches/Adobe\ * | \ + */Library/Caches/*\ Adobe* | \ + */Library/Caches/com.apple.containermanagerd | */Library/Caches/com.apple.containermanagerd/* | \ + */Library/Caches/com.apple.homed | */Library/Caches/com.apple.homed/* | \ + */Library/Caches/com.apple.ap.adprivacyd | */Library/Caches/com.apple.ap.adprivacyd/* | \ + */Library/Caches/FamilyCircle | */Library/Caches/FamilyCircle/* | \ + */Library/Caches/com.apple.HomeKit | */Library/Caches/com.apple.HomeKit/* | \ + */Library/Caches/com.apple.WorkflowKit.BackgroundShortcutRunner.ShortcutsSandboxCache | */Library/Caches/com.apple.WorkflowKit.BackgroundShortcutRunner.ShortcutsSandboxCache/* | \ + */Library/Caches/com.apple.siriactionsd.ShortcutsSandboxCache | */Library/Caches/com.apple.siriactionsd.ShortcutsSandboxCache/*) + 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 # This catches things like /Users/tw93/Library/Caches/Claude when pattern is *Claude* - # In uninstall mode, only check system-critical bundles (user explicitly chose to uninstall) - if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then - # Uninstall mode: first check if it's an uninstallable Apple app - for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then - return 1 # Can be uninstalled - fi - done - # Then check system-critical components - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then - return 0 - fi - done - else - # Normal mode (cleanup): protect both system-critical and data-protected bundles - for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do - if bundle_matches_pattern "$path" "$pattern"; then + # Skip for container cache/tmp paths: bundle ID was already checked in step 3, + # and critical containers are caught by steps 1/4/5. + if [[ "$_container_cache_path" != "true" ]]; then + if [[ "${MOLE_UNINSTALL_MODE:-0}" == "1" ]]; then + # Uninstall mode: first check if it's an uninstallable Apple app + for pattern in "${APPLE_UNINSTALLABLE_APPS[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 1 # Can be uninstalled + fi + done + # Then check system-critical components + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + else + # Normal mode (cleanup): protect both system-critical and data-protected bundles + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$path" "$pattern"; then + return 0 + fi + done + fi + + # 7. Check if the filename itself matches any protected patterns + # Skip in uninstall mode - user explicitly chose to remove this app + if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then + local filename="${path##*/}" + if should_protect_data "$filename"; then return 0 fi - done - fi - - # 7. Check if the filename itself matches any protected patterns - # Skip in uninstall mode - user explicitly chose to remove this app - if [[ "${MOLE_UNINSTALL_MODE:-0}" != "1" ]]; then - local filename - filename=$(basename "$path") - if should_protect_data "$filename"; then - return 0 fi fi @@ -855,8 +426,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 +447,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 *\** | *\?* | *\[*) @@ -894,6 +480,174 @@ is_path_whitelisted() { return 1 } +_mole_uninstall_lower() { + printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' +} + +_mole_uninstall_is_common_app_name() { + local lower_name + lower_name=$(_mole_uninstall_lower "${1:-}") + case "$lower_name" in + music | notes | photos | finder | safari | preview | calendar | contacts | messages | \ + reminders | clock | weather | stocks | books | news | podcasts | voice | files | \ + store | system | helper | agent | daemon | service | update | sync | backup | \ + cloud | manager | monitor | server | client | worker | runner | launcher | \ + driver | plugin | extension | widget | utility) + return 0 + ;; + esac + return 1 +} + +_mole_uninstall_vendor_product_tokens() { + local bundle_id="${1:-}" + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + + local product_token="${bundle_id##*.}" + local without_product="${bundle_id%.*}" + local vendor_token="${without_product##*.}" + + [[ "$vendor_token" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$ ]] || return 1 + [[ "$product_token" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{2,}$ ]] || return 1 + + printf '%s|%s\n' "$vendor_token" "$product_token" +} + +_mole_uninstall_name_variant_matches() { + local candidate_lower="$1" + shift + + local variant + for variant in "$@"; do + [[ -n "$variant" ]] || continue + if [[ "$candidate_lower" == "$variant" || + "$candidate_lower" == "$variant "* || + "$candidate_lower" == "$variant-"* || + "$candidate_lower" == "${variant}_"* || + "$candidate_lower" == "$variant."* ]]; then + return 0 + fi + done + + return 1 +} + +find_vendor_nested_app_paths() { + local bundle_id="$1" + local app_name="$2" + shift 2 + + [[ -n "$app_name" && ${#app_name} -ge 4 ]] || return 0 + _mole_uninstall_is_common_app_name "$app_name" && return 0 + + local token_pair + token_pair=$(_mole_uninstall_vendor_product_tokens "$bundle_id" 2> /dev/null) || return 0 + local vendor_token product_token + IFS='|' read -r vendor_token product_token <<< "$token_pair" + + local vendor_lower product_lower app_lower nospace_lower hyphen_lower underscore_lower + vendor_lower=$(_mole_uninstall_lower "$vendor_token") + product_lower=$(_mole_uninstall_lower "$product_token") + app_lower=$(_mole_uninstall_lower "$app_name") + nospace_lower=$(_mole_uninstall_lower "${app_name// /}") + hyphen_lower=$(_mole_uninstall_lower "${app_name// /-}") + underscore_lower=$(_mole_uninstall_lower "${app_name// /_}") + + local root candidate parent_dir parent_base parent_lower child_base child_lower + for root in "$@"; do + [[ -d "$root" ]] || continue + while IFS= read -r -d '' candidate; do + parent_dir="${candidate%/*}" + parent_base="${parent_dir##*/}" + parent_lower=$(_mole_uninstall_lower "$parent_base") + [[ "$parent_lower" == "$vendor_lower" ]] || continue + + child_base="${candidate##*/}" + child_lower=$(_mole_uninstall_lower "$child_base") + if _mole_uninstall_name_variant_matches "$child_lower" \ + "$app_lower" "$nospace_lower" "$hyphen_lower" "$underscore_lower" "$product_lower"; then + printf '%s\n' "$candidate" + fi + done < <(command find "$root" -mindepth 2 -maxdepth 2 -type d -print0 2> /dev/null) + done | sort -u +} + +find_shared_app_paths() { + local bundle_id="$1" + local app_name="$2" + shift 2 + + [[ -n "$app_name" && ${#app_name} -ge 5 ]] || return 0 + _mole_uninstall_is_common_app_name "$app_name" && return 0 + + local product_token="" + local token_pair + if token_pair=$(_mole_uninstall_vendor_product_tokens "$bundle_id" 2> /dev/null); then + IFS='|' read -r _ product_token <<< "$token_pair" + fi + + local app_lower nospace_lower hyphen_lower underscore_lower product_lower + app_lower=$(_mole_uninstall_lower "$app_name") + nospace_lower=$(_mole_uninstall_lower "${app_name// /}") + hyphen_lower=$(_mole_uninstall_lower "${app_name// /-}") + underscore_lower=$(_mole_uninstall_lower "${app_name// /_}") + product_lower=$(_mole_uninstall_lower "$product_token") + + local root candidate base lower_base + for root in "$@"; do + [[ -d "$root" ]] || continue + while IFS= read -r -d '' candidate; do + base="${candidate##*/}" + lower_base=$(_mole_uninstall_lower "$base") + if _mole_uninstall_name_variant_matches "$lower_base" \ + "$app_lower" "$nospace_lower" "$hyphen_lower" "$underscore_lower" "$product_lower"; then + printf '%s\n' "$candidate" + fi + done < <(command find "$root" -mindepth 1 -maxdepth 1 -print0 2> /dev/null) + done | sort -u +} + +# Return 0 when `path` looks like a dotdir / XDG state directory belonging to +# a standalone CLI tool shipped independently of any same-named GUI app. +# find_app_files uses this to skip candidates that would otherwise nuke +# unrelated CLI state when uninstalling a same-named GUI app (#993). +# +# Lowercase comparison so case-insensitive APFS (~/.Claude vs ~/.claude) is +# handled. Scope is restricted to four well-known parents so we never skip +# legitimate non-dotdir locations. +# +# The deny-list is inlined rather than read from an array because bats 1.x +# does not carry readonly arrays from setup() into the @test body, and a +# regression in any of these names is destructive enough that we never want +# the safeguard to silently no-op in a fresh subshell. +_path_belongs_to_independent_cli() { + local path="$1" + [[ -z "$path" ]] && return 1 + + local base parent lc_name + base="${path##*/}" + parent="${path%/*}" + lc_name=$(printf '%s' "${base#.}" | tr '[:upper:]' '[:lower:]') + [[ -z "$lc_name" ]] && return 1 + + case "$lc_name" in + # Standalone CLI tools shipped independently of a same-named GUI app: + # never delete their dotdirs when uninstalling the GUI namesake. + # Issue #993: uninstalling Claude.app wiped ~/.claude (Claude Code CLI), + # OpenCode.app wiped ~/.local/share/opencode. Case-insensitive APFS makes + # the collision worse. Add new GUI/CLI namesakes here. + claude | opencode | codex | gemini) ;; + *) return 1 ;; + esac + + case "$parent" in + "$HOME" | "$HOME/.config" | "$HOME/.local/share" | "$HOME/.cache") + return 0 + ;; + esac + return 1 +} + # Locate files associated with an application find_app_files() { local bundle_id="$1" @@ -929,45 +683,72 @@ find_app_files() { fi local base_lowercase=$(echo "$base_name" | tr '[:upper:]' '[:lower:]') # "Zed" -> "zed" - # Standard path patterns for user-level files - local -a user_patterns=( - "$HOME/Library/Application Support/$app_name" - "$HOME/Library/Application Support/$bundle_id" - "$HOME/Library/Caches/$bundle_id" - "$HOME/Library/Caches/$app_name" - "$HOME/Library/Logs/$app_name" - "$HOME/Library/Logs/$bundle_id" - "$HOME/Library/Application Support/CrashReporter/$app_name" - "$HOME/Library/Saved Application State/$bundle_id.savedState" - "$HOME/Library/Containers/$bundle_id" - "$HOME/Library/WebKit/$bundle_id" - "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" - "$HOME/Library/HTTPStorages/$bundle_id" - "$HOME/Library/Cookies/$bundle_id.binarycookies" - "$HOME/Library/LaunchAgents/$bundle_id.plist" - "$HOME/Library/Application Scripts/$bundle_id" - "$HOME/Library/Services/$app_name.workflow" - "$HOME/Library/QuickLook/$app_name.qlgenerator" - "$HOME/Library/Internet Plug-Ins/$app_name.plugin" - "$HOME/Library/Audio/Plug-Ins/Components/$app_name.component" - "$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst" - "$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3" - "$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" - "$HOME/Library/PreferencePanes/$app_name.prefPane" - "$HOME/Library/Input Methods/$app_name.app" - "$HOME/Library/Input Methods/$bundle_id.app" - "$HOME/Library/Screen Savers/$app_name.saver" - "$HOME/Library/Frameworks/$app_name.framework" - "$HOME/Library/Autosave Information/$bundle_id" - "$HOME/Library/Contextual Menu Items/$app_name.plugin" - "$HOME/Library/Spotlight/$app_name.mdimporter" - "$HOME/Library/ColorPickers/$app_name.colorPicker" - "$HOME/Library/Workflows/$app_name.workflow" - "$HOME/.config/$app_name" - "$HOME/.local/share/$app_name" - "$HOME/.$app_name" - "$HOME/.$app_name"rc - ) + # Only use bundle_id in literal paths or find patterns after reverse-DNS + # validation. A malformed Info.plist should not be able to traverse out of + # Library subtrees or broaden matches with glob metacharacters. + local bundle_id_valid="false" + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + bundle_id_valid="true" + fi + + # Standard path patterns for user-level files. App-name templates must never + # be built from an empty display name, otherwise dotdir/XDG paths collapse to + # broad roots like "$HOME/." or "$HOME/.config/". + local -a user_patterns=() + if [[ -n "$app_name" && ${#app_name} -ge 2 ]]; then + user_patterns+=( + "$HOME/Library/Application Support/$app_name" + "$HOME/Library/Caches/$app_name" + "$HOME/Library/Logs/$app_name" + "$HOME/Library/Preferences/$app_name" + "$HOME/Library/Preferences/$app_name.plist" + "$HOME/Library/Saved Application State/$app_name.savedState" + + "$HOME/Library/Services/$app_name.workflow" + "$HOME/Library/QuickLook/$app_name.qlgenerator" + "$HOME/Library/Internet Plug-Ins/$app_name.plugin" + "$HOME/Library/Audio/Plug-Ins/Components/$app_name.component" + "$HOME/Library/Audio/Plug-Ins/VST/$app_name.vst" + "$HOME/Library/Audio/Plug-Ins/VST3/$app_name.vst3" + "$HOME/Library/Audio/Plug-Ins/Digidesign/$app_name.dpm" + "$HOME/Library/PreferencePanes/$app_name.prefPane" + "$HOME/Library/Input Methods/$app_name.app" + "$HOME/Library/Screen Savers/$app_name.saver" + "$HOME/Library/Frameworks/$app_name.framework" + "$HOME/Library/Contextual Menu Items/$app_name.plugin" + "$HOME/Library/Spotlight/$app_name.mdimporter" + "$HOME/Library/ColorPickers/$app_name.colorPicker" + "$HOME/Library/Workflows/$app_name.workflow" + "$HOME/.config/$app_name" + "$HOME/.cache/$app_name" + "$HOME/.cache/$lowercase_name" + "$HOME/.local/share/$app_name" + "$HOME/.$app_name" + "$HOME/.$app_name"rc + "$HOME/Library/Address Book Plug-Ins/$app_name.bundle" + "$HOME/Library/Accessibility/$app_name.bundle" + "$HOME/Library/Mail/Bundles/$app_name.mailbundle" + ) + fi + + if [[ "$bundle_id_valid" == "true" ]]; then + user_patterns+=( + "$HOME/Library/Application Support/$bundle_id" + "$HOME/Library/Caches/$bundle_id" + "$HOME/Library/Logs/$bundle_id" + "$HOME/Library/Saved Application State/$bundle_id.savedState" + "$HOME/Library/Containers/$bundle_id" + "$HOME/Library/WebKit/$bundle_id" + "$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id" + "$HOME/Library/HTTPStorages/$bundle_id" + "$HOME/Library/HTTPStorages/$bundle_id.binarycookies" + "$HOME/Library/Cookies/$bundle_id.binarycookies" + "$HOME/Library/Application Scripts/$bundle_id" + "$HOME/Library/Input Methods/$bundle_id.app" + "$HOME/Library/Autosave Information/$bundle_id" + "$HOME/Library/SyncedPreferences/$bundle_id.plist" + ) + fi # Add all naming variants to cover inconsistent app directory naming # Issue #377: Apps create directories with various naming conventions @@ -977,12 +758,22 @@ find_app_files() { "$HOME/Library/Application Support/$nospace_name" "$HOME/Library/Caches/$nospace_name" "$HOME/Library/Logs/$nospace_name" + "$HOME/Library/Preferences/$nospace_name" + "$HOME/Library/Preferences/$nospace_name.plist" + "$HOME/Library/Saved Application State/$nospace_name.savedState" "$HOME/Library/Application Support/$underscore_name" "$HOME/Library/Application Support/$hyphen_name" + "$HOME/Library/Preferences/$underscore_name" + "$HOME/Library/Preferences/$underscore_name.plist" + "$HOME/Library/Preferences/$hyphen_name" + "$HOME/Library/Preferences/$hyphen_name.plist" # Lowercase variants (maestrostudio, maestro-studio, maestro_studio) "$HOME/.config/$lowercase_nospace" "$HOME/.config/$lowercase_hyphen" "$HOME/.config/$lowercase_underscore" + "$HOME/.cache/$lowercase_nospace" + "$HOME/.cache/$lowercase_hyphen" + "$HOME/.cache/$lowercase_underscore" "$HOME/.local/share/$lowercase_nospace" "$HOME/.local/share/$lowercase_hyphen" "$HOME/.local/share/$lowercase_underscore" @@ -995,7 +786,11 @@ find_app_files() { "$HOME/Library/Application Support/$base_name" "$HOME/Library/Caches/$base_name" "$HOME/Library/Logs/$base_name" + "$HOME/Library/Preferences/$base_name" + "$HOME/Library/Preferences/$base_name.plist" + "$HOME/Library/Saved Application State/$base_name.savedState" "$HOME/.config/$base_lowercase" + "$HOME/.cache/$base_lowercase" "$HOME/.local/share/$base_lowercase" "$HOME/.$base_lowercase" ) @@ -1003,14 +798,16 @@ find_app_files() { # Issue #422: Zed channel builds can leave data under another channel bundle id. # Example: uninstalling dev.zed.Zed-Nightly should also detect dev.zed.Zed-Preview leftovers. - if [[ "$bundle_id" =~ ^dev\.zed\.Zed- ]] && [[ -d "$HOME/Library/HTTPStorages" ]]; then + if [[ "$bundle_id_valid" == "true" && "$bundle_id" =~ ^dev\.zed\.Zed- ]] && [[ -d "$HOME/Library/HTTPStorages" ]]; then while IFS= read -r -d '' zed_http_storage; do files_to_clean+=("$zed_http_storage") done < <(command find "$HOME/Library/HTTPStorages" -maxdepth 1 -name "dev.zed.Zed-*" -print0 2> /dev/null) fi - # Process standard patterns - for p in "${user_patterns[@]}"; do + # Process standard patterns. user_patterns can be empty when app_name is + # too short and bundle_id is invalid; bash 3.2 under set -u treats an empty + # "${arr[@]}" expansion as an unbound variable, so use the +-guard idiom. + for p in "${user_patterns[@]+"${user_patterns[@]}"}"; do local expanded_path="${p/#\~/$HOME}" # Skip if path doesn't exist [[ ! -e "$expanded_path" ]] && continue @@ -1021,32 +818,113 @@ find_app_files() { */Library/Application\ Support | */Library/Application\ Support/ | \ */Library/Caches | */Library/Caches/ | \ */Library/Logs | */Library/Logs/ | \ + */Library/Preferences | */Library/Preferences/ | \ + */Library/Preferences/ByHost | */Library/Preferences/ByHost/ | \ */Library/Containers | */Library/Containers/ | \ */Library/WebKit | */Library/WebKit/ | \ */Library/HTTPStorages | */Library/HTTPStorages/ | \ */Library/Application\ Scripts | */Library/Application\ Scripts/ | \ */Library/Autosave\ Information | */Library/Autosave\ Information/ | \ - */Library/Group\ Containers | */Library/Group\ Containers/) + */Library/Group\ Containers | */Library/Group\ Containers/ | \ + */.config | */.config/ | \ + */.cache | */.cache/ | \ + */.local/share | */.local/share/ | \ + "$HOME" | "$HOME"/ | "$HOME"/.) continue ;; esac + # Skip XDG dotdirs that belong to independent CLI tools sharing a name + # with the GUI app being uninstalled (issue #993). + if _path_belongs_to_independent_cli "$expanded_path"; then + debug_log "Skipping independent CLI dotdir: $expanded_path" + continue + fi + files_to_clean+=("$expanded_path") done - # Handle Preferences and ByHost variants (only if bundle_id is valid) - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then + # Vendor-nested support directories, e.g.: + # ~/Library/Application Support/Avid/Sibelius + # Many professional apps store the product under a vendor folder rather + # than directly under Application Support. Match only when the vendor token + # comes from the bundle id to avoid broad name-only deletion. + if [[ "$bundle_id_valid" == "true" ]]; then + local vendor_nested_path + while IFS= read -r vendor_nested_path; do + [[ -n "$vendor_nested_path" && -e "$vendor_nested_path" ]] && files_to_clean+=("$vendor_nested_path") + done < <( + find_vendor_nested_app_paths "$bundle_id" "$app_name" \ + "$HOME/Library/Application Support" \ + "$HOME/Library/Caches" \ + "$HOME/Library/Logs" + ) + fi + + # Handle Preferences and ByHost variants (only if bundle_id is valid). + # Reverse-DNS check rejects malformed bundle ids before they reach any + # find -name pattern. Without this, a bundle id containing glob metachars + # (* ? [) or path separators could over-match unrelated user containers. + if [[ "$bundle_id_valid" == "true" ]]; 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) + if mole_name_starts_with_bundle_id_boundary "$pref" "$bundle_id"; then + files_to_clean+=("$pref") + fi + done < <(command find ~/Library/Preferences/ByHost -maxdepth 1 -type f -name "*.plist" -print0 2> /dev/null) + + # User LaunchAgents: wildcard scan for helper plists (e.g., com.example.app.helper.plist) + [[ -d ~/Library/LaunchAgents ]] && while IFS= read -r -d '' plist; do + files_to_clean+=("$plist") + done < <(command find ~/Library/LaunchAgents -maxdepth 1 \( -name "${bundle_id}.plist" -o -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) + if mole_name_has_bundle_id_boundary "$container" "$bundle_id"; then + files_to_clean+=("$container") + fi + done < <(command find ~/Library/Group\ Containers -maxdepth 1 -type d -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 + mole_name_has_bundle_id_boundary "$derived_path" "$bundle_id" || continue + 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 -print0 2> /dev/null) + done + fi + + # Shared file lists (.sfl4 - recent documents etc.) + if [[ "$bundle_id_valid" == "true" ]] && + [[ -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) @@ -1055,10 +933,10 @@ find_app_files() { # Short-name apps (e.g., Zoom, Arc) are still cleaned via bundle_id matching above # Security: Common words are excluded to prevent matching unrelated plist files if [[ ${#app_name} -ge 5 ]] && [[ -d ~/Library/LaunchAgents ]]; then - # Skip common words that could match many unrelated LaunchAgents - # These are either generic terms or names that overlap with system/common utilities - local common_words="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility" - if [[ "$app_name" =~ ^($common_words)$ ]]; then + # Skip generic words that collide with many unrelated LaunchAgents. + # Shared with the system-level scan in find_app_system_files(); + # defined in app_protection_data.sh. + if [[ "$app_name" =~ ^(${LAUNCH_AGENT_NAME_COMMON_WORDS})$ ]]; then debug_log "Skipping LaunchAgent name search for common word: $app_name" else while IFS= read -r -d '' plist; do @@ -1072,17 +950,30 @@ find_app_files() { fi fi - # Handle specialized toolchains and development environments + # Handle specialized toolchains and development environments. + # IMPORTANT: never auto-collect user project source, signing keys, OAuth + # tokens, AVD images, SDK installs, or other manually-curated data. Only + # regenerable cache/derived paths belong here. If a toolchain dir is mixed + # (config + cache), skip the whole tree rather than guess. # 1. DevEco-Studio (Huawei) if [[ "$app_name" =~ DevEco|deveco ]] || [[ "$bundle_id" =~ huawei.*deveco ]]; then - for d in ~/DevEcoStudioProjects ~/DevEco-Studio ~/Library/Application\ Support/Huawei ~/Library/Caches/Huawei ~/Library/Logs/Huawei ~/Library/Huawei ~/Huawei ~/HarmonyOS ~/.huawei ~/.ohos; do + # Skipped: ~/DevEcoStudioProjects, ~/HarmonyOS, ~/Huawei (project + # source); ~/DevEco-Studio (IDE config + license state); ~/Library/ + # Application Support/Huawei, ~/Library/Huawei, ~/.huawei, ~/.ohos + # (Huawei account tokens, signed device profiles, SDK config). Only + # sweep cache and log roots; everything else is opt-in. + for d in ~/Library/Caches/Huawei ~/Library/Logs/Huawei; do [[ -d "$d" ]] && files_to_clean+=("$d") done fi # 2. Android Studio (Google) if [[ "$app_name" =~ Android.*Studio|android.*studio ]] || [[ "$bundle_id" =~ google.*android.*studio|jetbrains.*android ]]; then - for d in ~/AndroidStudioProjects ~/Library/Android ~/.android; do + # Skipped: ~/AndroidStudioProjects (project source), ~/Library/Android + # (SDK installs, multi-GB), ~/.android root (debug.keystore signing + # key, adbkey device pairing, avd/ images). Only sweep regenerable + # caches under ~/.android. + for d in ~/.android/cache ~/.android/build-cache ~/.android/breakpad; do [[ -d "$d" ]] && files_to_clean+=("$d") done [[ -d ~/Library/Application\ Support/Google ]] && while IFS= read -r -d '' d; do files_to_clean+=("$d"); done < <(command find ~/Library/Application\ Support/Google -maxdepth 1 -name "AndroidStudio*" -print0 2> /dev/null) @@ -1090,7 +981,19 @@ find_app_files() { # 3. Xcode (Apple) if [[ "$app_name" =~ Xcode|xcode ]] || [[ "$bundle_id" =~ apple.*xcode ]]; then - [[ -d ~/Library/Developer ]] && files_to_clean+=("$HOME/Library/Developer") + # Skipped: ~/Library/Developer root (Toolchains, Archives, UserData, + # CoreSimulator/Devices, provisioning profiles). Only sweep + # regenerable build/device caches. + for d in \ + "$HOME/Library/Developer/Xcode/DerivedData" \ + "$HOME/Library/Developer/Xcode/iOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/macOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/watchOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/tvOS DeviceSupport" \ + "$HOME/Library/Developer/Xcode/xrOS DeviceSupport" \ + "$HOME/Library/Developer/CoreSimulator/Caches"; do + [[ -d "$d" ]] && files_to_clean+=("$d") + done [[ -d ~/.Xcode ]] && files_to_clean+=("$HOME/.Xcode") fi @@ -1107,12 +1010,31 @@ find_app_files() { [[ "$app_name" =~ Godot|godot ]] && [[ -d ~/Library/Application\ Support/Godot ]] && files_to_clean+=("$HOME/Library/Application Support/Godot") # 6. Tools + # VS Code stores user data under folder names that don't match the app name + # ("Visual Studio Code") or bundle id ("com.microsoft.VSCode"). The folder is + # named "Code" (stable) or "Code - Insiders". Cover both channels explicitly + # so uninstall removes them. Issue #850. if [[ "$bundle_id" =~ microsoft.*[vV][sS][cC]ode ]]; then - [[ -d "$HOME/.vscode" ]] && files_to_clean+=("$HOME/.vscode") [[ -d "$HOME/Library/Caches/com.microsoft.VSCode.ShipIt" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCode.ShipIt") [[ -d "$HOME/Library/Caches/com.microsoft.VSCodeInsiders.ShipIt" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCodeInsiders.ShipIt") + if [[ "$bundle_id" =~ [iI]nsiders ]]; then + [[ -d "$HOME/.vscode-insiders" ]] && files_to_clean+=("$HOME/.vscode-insiders") + [[ -d "$HOME/Library/Application Support/Code - Insiders" ]] && files_to_clean+=("$HOME/Library/Application Support/Code - Insiders") + [[ -d "$HOME/Library/Caches/com.microsoft.VSCodeInsiders" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCodeInsiders") + else + [[ -d "$HOME/.vscode" ]] && files_to_clean+=("$HOME/.vscode") + [[ -d "$HOME/Library/Application Support/Code" ]] && files_to_clean+=("$HOME/Library/Application Support/Code") + [[ -d "$HOME/Library/Caches/com.microsoft.VSCode" ]] && files_to_clean+=("$HOME/Library/Caches/com.microsoft.VSCode") + fi + fi + # Docker: ~/.docker holds config.json (Docker Hub auth tokens), contexts/ + # (kubeconfig-style endpoints, possibly with credentials), and cli-plugins. + # Only sweep regenerable cache subtrees, never the whole tree. + if [[ "$app_name" =~ Docker ]]; then + for d in ~/.docker/buildx ~/.docker/scan; do + [[ -d "$d" ]] && files_to_clean+=("$d") + done fi - [[ "$app_name" =~ Docker ]] && [[ -d ~/.docker ]] && files_to_clean+=("$HOME/.docker") # 6.1 Maestro Studio if [[ "$bundle_id" == "com.maestro.studio" ]] || [[ "$lowercase_name" =~ maestro[[:space:]]*studio ]]; then @@ -1149,6 +1071,16 @@ find_app_files() { done < <(command find "$vscode_global" -maxdepth 1 -type d -iname "*raycast*" -print0 2> /dev/null) fi + # CrashReporter plists: named AppName_UUID.plist (not subdirectories) + local crash_reporter_dir="$HOME/Library/Application Support/CrashReporter" + if [[ -d "$crash_reporter_dir" && ${#nospace_name} -ge 3 ]]; then + while IFS= read -r -d '' cr; do + files_to_clean+=("$cr") + done < <(command find "$crash_reporter_dir" -maxdepth 1 -type f \ + \( -name "${app_name}_*.plist" -o -name "${nospace_name}_*.plist" \) \ + -print0 2> /dev/null) + fi + # Output results if [[ ${#files_to_clean[@]} -gt 0 ]]; then printf '%s\n' "${files_to_clean[@]}" @@ -1187,7 +1119,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" @@ -1209,7 +1141,10 @@ find_app_system_files() { local nospace_name="${app_name// /}" local underscore_name="${app_name// /_}" local hyphen_name="${app_name// /-}" + local lowercase_name=$(echo "$app_name" | tr '[:upper:]' '[:lower:]') + local lowercase_nospace=$(echo "$nospace_name" | tr '[:upper:]' '[:lower:]') local lowercase_hyphen=$(echo "$hyphen_name" | tr '[:upper:]' '[:lower:]') + local lowercase_underscore=$(echo "$underscore_name" | tr '[:upper:]' '[:lower:]') # Standard system path patterns local -a system_patterns=( @@ -1218,6 +1153,8 @@ find_app_system_files() { "/Library/LaunchAgents/$bundle_id.plist" "/Library/LaunchDaemons/$bundle_id.plist" "/Library/Preferences/$bundle_id.plist" + "/Library/Preferences/$app_name" + "/Library/Preferences/$app_name.plist" "/Library/Receipts/$bundle_id.bom" "/Library/Receipts/$bundle_id.plist" "/Library/Frameworks/$app_name.framework" @@ -1233,6 +1170,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 @@ -1243,6 +1184,12 @@ find_app_system_files() { "/Library/Logs/$nospace_name" "/Library/Application Support/$underscore_name" "/Library/Application Support/$hyphen_name" + "/Library/Preferences/$nospace_name" + "/Library/Preferences/$nospace_name.plist" + "/Library/Preferences/$underscore_name" + "/Library/Preferences/$underscore_name.plist" + "/Library/Preferences/$hyphen_name" + "/Library/Preferences/$hyphen_name.plist" "/Library/Caches/$hyphen_name" "/Library/Caches/$lowercase_hyphen" ) @@ -1256,7 +1203,8 @@ find_app_system_files() { case "$p" in /Library/Application\ Support | /Library/Application\ Support/ | \ /Library/Caches | /Library/Caches/ | \ - /Library/Logs | /Library/Logs/) + /Library/Logs | /Library/Logs/ | \ + /Library/Preferences | /Library/Preferences/) continue ;; esac @@ -1264,10 +1212,49 @@ find_app_system_files() { system_files+=("$p") done - # System LaunchAgents/LaunchDaemons by name - if [[ ${#app_name} -gt 3 ]]; then + # Vendor-nested system support directories, e.g.: + # /Library/Application Support/Avid/Sibelius + local vendor_nested_system_path + while IFS= read -r vendor_nested_system_path; do + [[ -n "$vendor_nested_system_path" && -e "$vendor_nested_system_path" ]] && system_files+=("$vendor_nested_system_path") + done < <( + find_vendor_nested_app_paths "$bundle_id" "$app_name" \ + "/Library/Application Support" \ + "/Library/Caches" \ + "/Library/Logs" + ) + + # Shared sample/support files are usually outside the user's Library but + # are app-owned data (for example /Users/Shared/Sibelius ...). + local shared_app_path + while IFS= read -r shared_app_path; do + [[ -n "$shared_app_path" && -e "$shared_app_path" ]] && system_files+=("$shared_app_path") + done < <(find_shared_app_paths "$bundle_id" "$app_name" "/Users/Shared") + + # 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 mole_is_reverse_dns_bundle_id "$bundle_id"; 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. These live under /Library and + # are removed with sudo, so mirror the (stricter) user-level guard above: + # require >=5 chars, skip generic collision words, and skip Apple's own + # plists. A short or generic app name must not match unrelated system agents. + if [[ ${#app_name} -ge 5 ]] && ! [[ "$app_name" =~ ^(${LAUNCH_AGENT_NAME_COMMON_WORDS})$ ]]; then for base in /Library/LaunchAgents /Library/LaunchDaemons; do [[ -d "$base" ]] && while IFS= read -r -d '' plist; do + local plist_name + plist_name=$(basename "$plist") + [[ "$plist_name" =~ ^com\.apple\. ]] && continue system_files+=("$plist") done < <(command find "$base" -maxdepth 1 \( -name "*$app_name*.plist" \) -print0 2> /dev/null) done @@ -1275,14 +1262,45 @@ find_app_system_files() { # Privileged Helper Tools and Receipts (special handling) # Only search with bundle_id if it's valid (not empty and not "unknown") - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" && ${#bundle_id} -gt 3 ]]; then + if mole_is_reverse_dns_bundle_id "$bundle_id"; then [[ -d /Library/PrivilegedHelperTools ]] && while IFS= read -r -d '' helper; do - system_files+=("$helper") - done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 \( -name "$bundle_id*" \) -print0 2> /dev/null) + if mole_name_starts_with_bundle_id_boundary "$helper" "$bundle_id"; then + system_files+=("$helper") + fi + done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 -print0 2> /dev/null) [[ -d /private/var/db/receipts ]] && while IFS= read -r -d '' receipt; do - system_files+=("$receipt") - done < <(command find /private/var/db/receipts -maxdepth 1 \( -name "*$bundle_id*" \) -print0 2> /dev/null) + if mole_name_starts_with_bundle_id_boundary "$receipt" "$bundle_id"; then + system_files+=("$receipt") + fi + done < <(command find /private/var/db/receipts -maxdepth 1 -print0 2> /dev/null) + fi + + # Some vendors name privileged helpers after the product rather than the + # bundle id. System remnants are review-only in the CLI, but keep + # conservative name guards to avoid noisy system matches: reject common app + # words case-insensitively and require each matched variant to be at least + # 5 characters, since nospace variants can be shorter than app_name itself. + local -a helper_name_variants=() + if ! _mole_uninstall_is_common_app_name "$app_name"; then + local name_variant + for name_variant in "$lowercase_name" "$lowercase_nospace" "$lowercase_hyphen" "$lowercase_underscore"; do + if [[ ${#name_variant} -ge 5 ]]; then + helper_name_variants+=("$name_variant") + fi + done + fi + if [[ ${#helper_name_variants[@]} -gt 0 && -d /Library/PrivilegedHelperTools ]]; then + while IFS= read -r -d '' helper; do + local helper_name + local helper_lower + helper_name=$(basename "$helper") + [[ "$helper_name" =~ ^com\.apple\. ]] && continue + helper_lower=$(_mole_uninstall_lower "$helper_name") + if _mole_uninstall_name_variant_matches "$helper_lower" "${helper_name_variants[@]}"; then + system_files+=("$helper") + fi + done < <(command find /Library/PrivilegedHelperTools -maxdepth 1 -print0 2> /dev/null) fi # Raycast system-level files @@ -1319,9 +1337,8 @@ find_app_receipt_files() { # Skip if no bundle ID [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 - # Validate bundle_id format to prevent wildcard injection - # Only allow alphanumeric characters, dots, hyphens, and underscores - if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9._-]+$ ]]; then + # Validate bundle_id format to prevent wildcard or defaults-domain abuse. + if ! mole_is_reverse_dns_bundle_id "$bundle_id"; then debug_log "Invalid bundle_id format: $bundle_id" return 0 fi @@ -1333,8 +1350,10 @@ find_app_receipt_files() { # Usually in /var/db/receipts/ if [[ -d /private/var/db/receipts ]]; then while IFS= read -r -d '' bom; do - bom_files+=("$bom") - done < <(find /private/var/db/receipts -maxdepth 1 -name "${bundle_id}*.bom" -print0 2> /dev/null) + if mole_name_starts_with_bundle_id_boundary "$bom" "$bundle_id"; then + bom_files+=("$bom") + fi + done < <(find /private/var/db/receipts -maxdepth 1 -name "*.bom" -print0 2> /dev/null) fi # Process bom files if any found @@ -1366,31 +1385,7 @@ find_app_receipt_files() { # Normalize path (remove duplicate slashes) clean_path=$(tr -s "/" <<< "$clean_path") - # ------------------------------------------------------------------------ - # Safety check: restrict removal to trusted paths - # ------------------------------------------------------------------------ - local is_safe=false - - # Whitelisted prefixes (exclude /Users, /usr, /opt) - case "$clean_path" in - /Applications/*) is_safe=true ;; - /Library/Application\ Support/*) is_safe=true ;; - /Library/Caches/*) is_safe=true ;; - /Library/Logs/*) is_safe=true ;; - /Library/Preferences/*) is_safe=true ;; - /Library/LaunchAgents/*) is_safe=true ;; - /Library/LaunchDaemons/*) is_safe=true ;; - /Library/PrivilegedHelperTools/*) is_safe=true ;; - /Library/Extensions/*) is_safe=false ;; - *) is_safe=false ;; - esac - - # Hard blocks - case "$clean_path" in - /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/*) is_safe=false ;; - esac - - if [[ "$is_safe" == "true" && -e "$clean_path" ]]; then + if receipt_payload_path_is_allowlisted "$clean_path" "$bundle_id" && [[ -e "$clean_path" ]]; then # Skip top-level directories if [[ "$clean_path" == "/Applications" || "$clean_path" == "/Library" ]]; then continue @@ -1413,9 +1408,41 @@ find_app_receipt_files() { fi } -# Terminate a running application +receipt_payload_path_is_allowlisted() { + local clean_path="$1" + local bundle_id="$2" + local base + base=$(basename "$clean_path") + + [[ -n "$clean_path" && -n "$bundle_id" ]] || return 1 + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + + case "$clean_path" in + /Library/LaunchAgents/*.plist | /Library/LaunchDaemons/*.plist) + [[ "$base" == "$bundle_id.plist" || "$base" == "$bundle_id."*.plist ]] + return + ;; + /Library/PrivilegedHelperTools/*) + mole_name_starts_with_bundle_id_boundary "$base" "$bundle_id" + return + ;; + /private/var/db/receipts/*) + [[ "$base" == "$bundle_id.bom" || "$base" == "$bundle_id.plist" || "$base" == "$bundle_id."* ]] + return + ;; + esac + + return 1 +} + +# Terminate a running application during uninstall. +# The user has already confirmed removal, so after the graceful Quit Apple +# Event we escalate through SIGTERM and SIGKILL (and one sudo retry when +# non-interactive sudo is already cached) to avoid leaving a zombie process +# after "Uninstall complete". Apps that need to flush state get the graceful +# Quit window first; apps that stall past it lose unsaved work, which the +# user has implicitly accepted by confirming. force_kill_app() { - # Gracefully terminates or force-kills an application local app_name="$1" local app_path="${2:-""}" @@ -1424,42 +1451,96 @@ force_kill_app() { return 0 fi - # Get the executable name from bundle if app_path is provided + # Get the executable name and bundle id from Info.plist when available. + # bundle id is preferred for the AppleScript Quit step because it is more + # precise than the display name (which may be localized). local exec_name="" + local bundle_id="" if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + bundle_id=$(defaults read "$app_path/Contents/Info.plist" CFBundleIdentifier 2> /dev/null || echo "") fi # Use executable name for precise matching, fallback to app name local match_pattern="${exec_name:-$app_name}" + # Defensive guard: even though should_protect_from_uninstall (bin/uninstall.sh) + # filters protected bundle IDs out of the selection list, match_pattern comes + # from CFBundleExecutable (a string a third-party .app can set freely). Refuse + # to AppleScript-Quit or pkill any pattern that exactly matches a critical + # system process name. force_kill_app is a public function; future callers + # must not be able to weaponise a spoofed executable name to take down Finder, + # Dock, loginwindow, etc. + case "$match_pattern" in + Finder | Dock | loginwindow | WindowServer | SystemUIServer | launchd | coreaudiod | NotificationCenter | ControlCenter | Spotlight) + debug_log "force_kill_app: refusing to operate on system process name '$match_pattern'" + return 1 + ;; + esac + # Check if process is running using exact match only if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then return 0 fi - # Try graceful termination first + # Send a graceful Quit Apple Event first. Many Tauri/Electron/SwiftUI GUI + # apps install an event loop that ignores SIGTERM but responds to the + # standard "quit" Apple Event by going through their normal terminate + # flow (including unsaved-state prompts). osascript is best-effort: we + # cap the wait so a hung app, an automation-permission dialog, or a + # missing osascript binary can never stall the uninstall. + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]] && + command -v osascript > /dev/null 2>&1; then + local quit_target="" + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + quit_target="id \"$bundle_id\"" + else + # Escape embedded double quotes in app_name before passing into + # the AppleScript literal. + local escaped_name="${app_name//\\/\\\\}" + escaped_name="${escaped_name//\"/\\\"}" + quit_target="\"$escaped_name\"" + fi + run_with_timeout "$MOLE_TIMEOUT_SHORT_QUERY_SEC" osascript -e "tell application $quit_target to quit" > /dev/null 2>&1 & + local quit_pid=$! + # Poll briefly so the kill ladder skips when the app exits cleanly. + local quit_wait=20 + while [[ $quit_wait -gt 0 ]] && pgrep -x "$match_pattern" > /dev/null 2>&1; do + sleep 0.1 + ((quit_wait--)) + done + wait "$quit_pid" 2> /dev/null || true + fi + + # Graceful Quit landed: skip the kill ladder entirely. + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + return 0 + fi + + # Escalate: SIGTERM, then SIGKILL, then one sudo SIGKILL retry when a + # cached sudo session is already available (no new prompt). The user + # confirmed uninstall, so a still-running process at this point is + # blocking a clean result and we trade unsaved state for that. pkill -x "$match_pattern" 2> /dev/null || true sleep 2 - - # Check again after graceful kill if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then return 0 fi - # Force kill if still running pkill -9 -x "$match_pattern" 2> /dev/null || true sleep 2 + if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then + return 0 + fi - # If still running and sudo is available, try with sudo - if pgrep -x "$match_pattern" > /dev/null 2>&1; then - if sudo -n true 2> /dev/null; then - sudo pkill -9 -x "$match_pattern" 2> /dev/null || true - sleep 2 - fi + if [[ "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]] && + sudo -n true 2> /dev/null; then + sudo pkill -9 -x "$match_pattern" 2> /dev/null || true + sleep 2 fi - # Final check with longer timeout for stubborn processes + # Final retries for stubborn processes (e.g. apps mid-fsync that need a + # moment to fully exit after SIGKILL). local retries=3 while [[ $retries -gt 0 ]]; do if ! pgrep -x "$match_pattern" > /dev/null 2>&1; then @@ -1469,7 +1550,6 @@ force_kill_app() { ((retries--)) done - # Still running after all attempts pgrep -x "$match_pattern" > /dev/null 2>&1 && return 1 || return 0 } diff --git a/Resources/mole/lib/core/app_protection_data.sh b/Resources/mole/lib/core/app_protection_data.sh new file mode 100644 index 0000000..c4ab42e --- /dev/null +++ b/Resources/mole/lib/core/app_protection_data.sh @@ -0,0 +1,573 @@ +#!/bin/bash +# Mole - Application Protection Data +# Static bundle ID and pattern lists, sourced by lib/core/app_protection.sh. +# Keep this file data-only. Logic belongs in app_protection.sh. + +set -euo pipefail + +if [[ -n "${MOLE_APP_PROTECTION_DATA_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_APP_PROTECTION_DATA_LOADED=1 + +# Application Management + +# Detailed list for uninstall protection (lazy-loaded into SYSTEM_CRITICAL_REGEX). +# Critical system components protected from uninstallation +# Note: We explicitly list system components instead of using "com.apple.*" wildcard +# to allow uninstallation of user-installed Apple apps (Xcode, Final Cut Pro, etc.) +readonly SYSTEM_CRITICAL_BUNDLES=( + # Core system applications (in /System/Applications/) + "com.apple.finder" + "com.apple.dock" + "com.apple.Safari" + "com.apple.mail" + "com.apple.systempreferences" + "com.apple.SystemSettings" + "com.apple.Settings*" + "com.apple.controlcenter*" + "com.apple.Spotlight" + "com.apple.notificationcenterui" + "com.apple.loginwindow" + "com.apple.Preview" + "com.apple.TextEdit" + "com.apple.Notes" + "com.apple.reminders" + "com.apple.iCal" + "com.apple.AddressBook" + "com.apple.Photos" + "com.apple.AppStore" + "com.apple.calculator" + "com.apple.Dictionary" + "com.apple.ScreenSharing" + "com.apple.ActivityMonitor" + "com.apple.Console" + "com.apple.DiskUtility" + "com.apple.KeychainAccess" + "com.apple.DigitalColorMeter" + "com.apple.grapher" + "com.apple.Terminal" + "com.apple.ScriptEditor2" + "com.apple.VoiceOverUtility" + "com.apple.BluetoothFileExchange" + "com.apple.print.PrinterProxy" + "com.apple.systempreferences*" + "com.apple.SystemProfiler" + "com.apple.FontBook" + "com.apple.ColorSyncUtility" + "com.apple.audio.AudioMIDISetup" + "com.apple.DirectoryUtility" + "com.apple.NetworkUtility" + "com.apple.exposelauncher" + "com.apple.MigrateAssistant" + "com.apple.RAIDUtility" + "com.apple.BootCampAssistant" + + # System services and daemons + "com.apple.SecurityAgent" + "com.apple.CoreServices*" + "com.apple.SystemUIServer" + "com.apple.backgroundtaskmanagement*" + "com.apple.loginitems*" + "com.apple.sharedfilelist*" + "com.apple.sfl*" + "com.apple.coreservices*" + "com.apple.metadata*" + "com.apple.MobileSoftwareUpdate*" + "com.apple.SoftwareUpdate*" + "com.apple.installer*" + "com.apple.frameworks*" + "com.apple.security*" + "com.apple.keychain*" + "com.apple.trustd*" + "com.apple.securityd*" + "com.apple.cloudd*" + "com.apple.iCloud*" + "com.apple.WiFi*" + "com.apple.airport*" + "com.apple.Bluetooth*" + + # Input methods (system built-in) + "com.apple.inputmethod.*" + "com.apple.inputsource*" + "com.apple.TextInput*" + "com.apple.CharacterPicker*" + "com.apple.PressAndHold*" + + # Legacy pattern-based entries (non com.apple.*) + "loginwindow" + "dock" + "systempreferences" + "finder" + "safari" + "backgroundtaskmanagementagent" + "keychain*" + "security*" + "bluetooth*" + "wifi*" + "network*" + "tcc" + "notification*" + "accessibility*" + "universalaccess*" + "HIToolbox*" + "textinput*" + "TextInput*" + "keyboard*" + "Keyboard*" + "inputsource*" + "InputSource*" + "keylayout*" + "KeyLayout*" + "GlobalPreferences" + ".GlobalPreferences" + "org.pqrs.Karabiner*" +) + +# Apple apps that CAN be uninstalled (from App Store or developer.apple.com) +readonly APPLE_UNINSTALLABLE_APPS=( + "com.apple.dt.*" # Xcode, Instruments, FileMerge + "com.apple.FinalCut*" # Final Cut Pro + "com.apple.Motion" + "com.apple.Compressor" + "com.apple.logic*" # Logic Pro + "com.apple.garageband*" # GarageBand + "com.apple.iMovie" + "com.apple.iWork.*" # Pages, Numbers, Keynote + "com.apple.MainStage*" + "com.apple.server.*" # macOS Server + "com.apple.Playgrounds" # Swift Playgrounds +) + +# Vendor-managed security / MDM apps must use their official uninstallers. +# Shape: vendor|bundle-prefixes-comma-separated|name-fragments-comma-separated +readonly OFFICIAL_UNINSTALLER_RULES=( + "ESET|com.eset.|eset management agent,eset remote administrator agent,eset endpoint security,eset endpoint antivirus" + "Jamf|com.jamf.,com.jamfsoftware.|jamf connect,jamf protect,jamf self service" + "CrowdStrike|com.crowdstrike.|crowdstrike,falcon" + "SentinelOne|com.sentinelone.,com.sentinel-labs.|sentinelone,sentinel agent" + "GlobalProtect|com.paloaltonetworks.|globalprotect" + "Cisco|com.cisco.anyconnect,com.cisco.secureclient|cisco secure client,cisco anyconnect" +) + +# Applications with sensitive data; protected during cleanup but removable +readonly DATA_PROTECTED_BUNDLES=( + # Input Methods (protected during cleanup, uninstall allowed) + "com.tencent.inputmethod.QQInput" + "com.sogou.inputmethod.*" + "com.baidu.inputmethod.*" + "com.googlecode.rimeime.*" + "im.rime.*" + "*.inputmethod" + "*.InputMethod" + "*IME" + + # System Utilities & Cleanup + "com.nektony.*" + "com.macpaw.*" + "com.freemacsoft.AppCleaner" + "com.omnigroup.omnidisksweeper" + "com.daisydiskapp.*" + "com.tunabellysoftware.*" + "com.grandperspectiv.*" + "com.binaryfruit.*" + + # Password Managers + "com.1password.*" + "com.agilebits.*" + "com.lastpass.*" + "com.dashlane.*" + "com.bitwarden.*" + "com.keepassx.*" + "org.keepassx.*" + "org.keepassxc.*" + "com.authy.*" + "com.yubico.*" + + # IDEs & Editors + "com.jetbrains.*" + "JetBrains*" + "com.microsoft.VSCode" + "com.visualstudio.code.*" + "com.sublimetext.*" + "com.sublimehq.*" + "com.microsoft.VSCodeInsiders" + "com.apple.dt.Xcode" + "com.coteditor.CotEditor" + "com.macromates.TextMate" + "com.panic.Nova" + "abnerworks.Typora" + "com.uranusjr.macdown" + + # AI & LLM Tools + "com.todesktop.*" + "Cursor" + "com.anthropic.claude*" + "Claude" + "com.openai.chat*" + "ChatGPT" + "com.openai.codex" + "Codex" + "codex-runtimes" + "com.ollama.ollama" + "Ollama" + "com.lmstudio.lmstudio" + "LM Studio" + "co.supertool.chatbox" + "page.jan.jan" + "com.huggingface.huggingchat" + "Gemini" + "com.perplexity.Perplexity" + "com.drawthings.DrawThings" + "com.divamgupta.diffusionbee" + "com.exafunction.windsurf" + "com.quora.poe.electron" + "chat.openai.com.*" + + # Database Clients + "com.sequelpro.*" + "com.sequel-ace.*" + "com.tinyapp.*" + "com.dbeaver.*" + "com.navicat.*" + "com.mongodb.compass" + "com.redis.RedisInsight" + "com.pgadmin.pgadmin4" + "com.eggerapps.Sequel-Pro" + "com.valentina-db.Valentina-Studio" + "com.dbvis.DbVisualizer" + + # API & Network Tools + "com.postmanlabs.mac" + "com.konghq.insomnia" + "com.CharlesProxy.*" + "com.proxyman.*" + "com.getpaw.*" + "com.luckymarmot.Paw" + "com.charlesproxy.charles" + "com.telerik.Fiddler" + "com.usebruno.app" + + # Network Proxy & VPN Tools (Clash variants - use specific patterns to avoid false positives) + "com.clash.*" + "ClashX*" + "clash-*" + "Clash-*" + "*-clash" + "*-Clash" + "clash.*" + "Clash.*" + "clash_*" + "*clash-verge*" + "*Clash-Verge*" + "clashverge*" + "ClashVerge*" + "com.nssurge.surge-mac" + "*surge*" + "*Surge*" + "mihomo*" + "*openvpn*" + "*OpenVPN*" + "net.openvpn.*" + + # Proxy Clients + "*ShadowsocksX-NG*" + "com.qiuyuzhou.*" + "*v2ray*" + "*V2Ray*" + "*v2box*" + "*V2Box*" + "*nekoray*" + "*sing-box*" + "*OneBox*" + "*hiddify*" + "*Hiddify*" + "*loon*" + "*Loon*" + "*quantumult*" + + # Mesh & Corporate VPNs + "*tailscale*" + "io.tailscale.*" + "*zerotier*" + "com.zerotier.*" + "*1dot1dot1dot1*" # Cloudflare WARP + "*cloudflare*warp*" + "org.amnezia.*" + "*amnezia*" + "*Amnezia*" + "com.wireguard.*" + "*wireguard*" + "*WireGuard*" + + # Commercial VPNs + "*nordvpn*" + "*expressvpn*" + "*protonvpn*" + "*surfshark*" + "*windscribe*" + "*mullvad*" + "*privateinternetaccess*" + + # Screensaver & Wallpaper + "*Aerial.saver*" + "com.JohnCoates.Aerial*" + "*Fliqlo*" + "*fliqlo*" + + # Git & Version Control + "com.github.GitHubDesktop" + "com.sublimemerge" + "com.torusknot.SourceTreeNotMAS" + "com.git-tower.Tower*" + "com.gitfox.GitFox" + "com.github.Gitify" + "com.fork.Fork" + "com.axosoft.gitkraken" + + # Terminal & Shell + "com.googlecode.iterm2" + "net.kovidgoyal.kitty" + "io.alacritty" + "com.github.wez.wezterm" + "com.hyper.Hyper" + "com.mizage.divvy" + "com.fig.Fig" + "dev.warp.Warp-Stable" + "com.termius-dmg" + + # Docker & Virtualization + "com.docker.docker" + "dev.orbstack.OrbStack" + "dev.orbstack.*" + "dev.kdrag0n.MacVirt" + "com.getutm.UTM" + "com.vmware.fusion" + "com.parallels.desktop.*" + "org.virtualbox.app.VirtualBox" + "com.vagrant.*" + "com.orbstack.OrbStack" + + # System Monitoring + "com.bjango.istatmenus*" + "eu.exelban.Stats" + "com.monitorcontrol.*" + "com.bresink.system-toolkit.*" + "com.mediaatelier.MenuMeters" + "com.activity-indicator.app" + "net.cindori.sensei" + + # Window Management + "com.macitbetter.*" # BetterTouchTool, BetterSnapTool + "com.hegenberg.*" + "com.manytricks.*" # Moom, Witch, etc. + "com.divisiblebyzero.*" + "com.koingdev.*" + "com.if.Amphetamine" + "com.lwouis.alt-tab-macos" + "net.matthewpalmer.Vanilla" + "com.lightheadsw.Caffeine" + "com.contextual.Contexts" + "com.amethyst.Amethyst" + "com.knollsoft.Rectangle" + "com.knollsoft.Hookshot" + "com.surteesstudios.Bartender" + "com.gaosun.eul" + "com.pointum.hazeover" + + # Launcher & Automation + "com.runningwithcrayons.Alfred" + "com.raycast.*" + "com.blacktree.Quicksilver" + "com.stairways.keyboardmaestro.*" + "com.manytricks.Butler" + "com.happenapps.Quitter" + "com.pilotmoon.scroll-reverser" + "org.pqrs.Karabiner-Elements" + "com.apple.Automator" + + # Note-Taking + "com.bear-writer.*" + "com.typora.*" + "com.ulyssesapp.*" + "com.literatureandlatte.*" + "com.dayoneapp.*" + "notion.id" + "md.obsidian" + "com.logseq.logseq" + "com.evernote.Evernote" + "com.onenote.mac" + "com.omnigroup.OmniOutliner*" + "net.shinyfrog.bear" + "com.goodnotes.GoodNotes" + "com.marginnote.MarginNote*" + "com.roamresearch.*" + "com.reflect.ReflectApp" + "com.inkdrop.*" + + # Design & Creative + "com.adobe.*" + "com.avid.mediacomposer*" + "com.bohemiancoding.*" + "com.figma.*" + "com.framerx.*" + "com.zeplin.*" + "com.invisionapp.*" + "com.principle.*" + "com.pixelmatorteam.*" + "com.affinitydesigner.*" + "com.affinityphoto.*" + "com.affinitypublisher.*" + "com.linearity.curve" + "com.canva.CanvaDesktop" + "com.maxon.cinema4d" + "com.autodesk.*" + "com.sketchup.*" + "com.native-instruments.*" + "com.fabfilter.*" + "com.paceap.*" + "com.izotope.*" + "iZotope" + "com.lasersoft-imaging.*" + "app.cotypist.Cotypist" + + # Communication + "com.tencent.xinWeChat" + "com.tencent.qq" + "com.alibaba.DingTalkMac" + "com.alibaba.AliLang.osx" + "com.alibaba.alilang3.osx.ShipIt" + "com.alibaba.AlilangMgr.QueryNetworkInfo" + "us.zoom.xos" + "com.microsoft.teams*" + "com.slack.Slack" + "com.hnc.Discord" + "app.legcord.Legcord" + "org.telegram.desktop" + "ru.keepcoder.Telegram" + "net.whatsapp.WhatsApp" + "com.skype.skype" + "com.cisco.webexmeetings" + "com.ringcentral.RingCentral" + "com.readdle.smartemail-Mac" + "com.airmail.*" + "com.postbox-inc.postbox" + "com.tinyspeck.slackmacgap" + + # Task Management + "com.omnigroup.OmniFocus*" + "com.culturedcode.*" + "com.todoist.*" + "com.any.do.*" + "com.ticktick.*" + "com.microsoft.to-do" + "com.trello.trello" + "com.asana.nativeapp" + "com.clickup.*" + "com.monday.desktop" + "com.airtable.airtable" + "com.notion.id" + "com.linear.linear" + + # File Transfer & Sync + "com.panic.transmit*" + "com.binarynights.ForkLift*" + "com.noodlesoft.Hazel" + "com.cyberduck.Cyberduck" + "io.filezilla.FileZilla" + "com.apple.Xcode.CloudDocuments" + "com.synology.*" + + # Cloud Storage & Backup + "com.dropbox.*" + "com.getdropbox.*" + "*dropbox*" + "ws.agile.*" + "com.backblaze.*" + "*backblaze*" + "com.box.desktop*" + "*box.desktop*" + "com.microsoft.OneDrive*" + "com.microsoft.SyncReporter" + "*OneDrive*" + "com.google.GoogleDrive" + "com.google.keystone*" + "*GoogleDrive*" + "com.amazon.drive" + "com.apple.bird" + "com.apple.CloudDocs*" + "com.displaylink.*" + "com.fujitsu.pfu.ScanSnap*" + "com.citrix.*" + "org.xquartz.*" + "us.zoom.updater*" + "com.DigiDNA.iMazing*" + "com.shirtpocket.*" + "homebrew.mxcl.*" + + # Remote Desktop / Remote Access + "org.chromium.chromoting*" + "com.google.chrome_remote_desktop*" + "com.teamviewer.*" + "com.realvnc.*" + "com.logmein.*" + "com.anydesk.*" + + # Screenshot & Recording + "com.cleanshot.*" + "com.xnipapp.xnip" + "com.reincubate.camo" + "com.tunabellysoftware.ScreenFloat" + "net.telestream.screenflow*" + "com.techsmith.snagit*" + "com.techsmith.camtasia*" + "com.obsidianapp.screenrecorder" + "com.kap.Kap" + "com.getkap.*" + "com.linebreak.CloudApp" + "com.droplr.droplr-mac" + + # Media & Entertainment + "com.spotify.client" + "com.apple.Music" + "com.apple.podcasts" + "com.apple.BKAgentService" + "com.apple.iBooksX" + "com.apple.iBooks" + "com.blackmagic-design.*" + "com.colliderli.iina" + "org.videolan.vlc" + "io.mpv" + "tv.plex.player.desktop" + "com.netease.163music" + + # Web Browsers + "Firefox" + "org.mozilla.*" + + # Scientific & Professional Software + "com.crowdstrike.*" + "com.kolide.*" + "com.sas.*" + "com.mathworks.*" + "com.ibm.spss.*" + "com.wolfram.*" + "com.stata.*" + "org.rstudio.*" + "com.tableausoftware.*" + + # License & App Stores + "com.paddle.Paddle*" + "com.quicken.*" + "com.setapp.DesktopClient" + "com.devmate.*" + "org.sparkle-project.Sparkle*" +) + +# Generic app-name words that collide with many unrelated LaunchAgents/Daemons. +# When an app's display name is exactly one of these, name-based plist matching +# is skipped (bundle-id matching still applies) so we never delete third-party or +# system agents that merely share the word. Shared by find_app_files() (user +# LaunchAgents) and find_app_system_files() (system LaunchAgents/Daemons) so the +# two scans stay symmetric. +readonly LAUNCH_AGENT_NAME_COMMON_WORDS="Music|Notes|Photos|Finder|Safari|Preview|Calendar|Contacts|Messages|Reminders|Clock|Weather|Stocks|Books|News|Podcasts|Voice|Files|Store|System|Helper|Agent|Daemon|Service|Update|Sync|Backup|Cloud|Manager|Monitor|Server|Client|Worker|Runner|Launcher|Driver|Plugin|Extension|Widget|Utility" diff --git a/Resources/mole/lib/core/base.sh b/Resources/mole/lib/core/base.sh index 5479fa3..e87d825 100644 --- a/Resources/mole/lib/core/base.sh +++ b/Resources/mole/lib/core/base.sh @@ -12,17 +12,31 @@ readonly MOLE_BASE_LOADED=1 # ============================================================================ # Color Definitions +# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes. # ============================================================================ -readonly ESC=$'\033' -readonly GREEN="${ESC}[0;32m" -readonly BLUE="${ESC}[1;34m" -readonly CYAN="${ESC}[0;36m" -readonly YELLOW="${ESC}[0;33m" -readonly PURPLE="${ESC}[0;35m" -readonly PURPLE_BOLD="${ESC}[1;35m" -readonly RED="${ESC}[0;31m" -readonly GRAY="${ESC}[0;90m" -readonly NC="${ESC}[0m" +if [[ -n "${NO_COLOR:-}" ]]; then + readonly ESC="" + readonly GREEN="" + readonly BLUE="" + readonly CYAN="" + readonly YELLOW="" + readonly PURPLE="" + readonly PURPLE_BOLD="" + readonly RED="" + readonly GRAY="" + readonly NC="" +else + readonly ESC=$'\033' + readonly GREEN="${ESC}[0;32m" + readonly BLUE="${ESC}[1;34m" + readonly CYAN="${ESC}[0;36m" + readonly YELLOW="${ESC}[0;33m" + readonly PURPLE="${ESC}[0;35m" + readonly PURPLE_BOLD="${ESC}[1;35m" + readonly RED="${ESC}[0;31m" + readonly GRAY="${ESC}[0;90m" + readonly NC="${ESC}[0m" +fi # ============================================================================ # Icon Definitions @@ -69,15 +83,19 @@ get_lsregister_path() { # ============================================================================ readonly MOLE_TEMP_FILE_AGE_DAYS=7 # Temp file retention (days) readonly MOLE_ORPHAN_AGE_DAYS=30 # Orphaned data retention (days) +readonly MOLE_DOTDIR_ORPHAN_AGE_DAYS=60 # Orphan dotfile hint threshold (days) readonly MOLE_MAX_PARALLEL_JOBS=15 # Parallel job limit readonly MOLE_MAIL_DOWNLOADS_MIN_KB=5120 # Mail attachment size threshold readonly MOLE_MAIL_AGE_DAYS=30 # Mail attachment retention (days) readonly MOLE_LOG_AGE_DAYS=7 # Log retention (days) readonly MOLE_CRASH_REPORT_AGE_DAYS=7 # Crash report retention (days) readonly MOLE_SAVED_STATE_AGE_DAYS=30 # Saved state retention (days) - increased for safety +readonly MOLE_GPU_CACHE_AGE_DAYS=1 # Rebuildable GPU cache retention (days) 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 +114,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*" @@ -109,9 +128,6 @@ declare -a DEFAULT_WHITELIST_PATTERNS=( ) declare -a DEFAULT_OPTIMIZE_WHITELIST_PATTERNS=( - "check_brew_health" - "check_touchid" - "check_git_config" ) # ============================================================================ @@ -171,23 +187,6 @@ get_file_owner() { # System Utilities # ============================================================================ -# Check if System Integrity Protection is enabled -# Returns: 0 if SIP is enabled, 1 if disabled or cannot determine -is_sip_enabled() { - if ! command -v csrutil > /dev/null 2>&1; then - return 0 - fi - - local sip_status - sip_status=$(csrutil status 2> /dev/null || echo "") - - if echo "$sip_status" | grep -qi "enabled"; then - return 0 - else - return 1 - fi -} - # Detect CPU architecture # Returns: "Apple Silicon" or "Intel" detect_architecture() { @@ -204,42 +203,50 @@ detect_architecture() { echo "$MOLE_ARCH_CACHE" } -# Get free disk space on root volume -# Returns: human-readable string (e.g., "100G") -get_free_space() { +get_free_space_target() { local target="/" if [[ -d "/System/Volumes/Data" ]]; then target="/System/Volumes/Data" fi - df -h "$target" | awk 'NR==2 {print $4}' + printf '%s\n' "$target" } -# Get Darwin kernel major version (e.g., 24 for 24.2.0) -# Returns 999 on failure to adopt conservative behavior (assume modern system) -get_darwin_major() { - if [[ -n "${MOLE_DARWIN_MAJOR_CACHE:-}" ]]; then - echo "$MOLE_DARWIN_MAJOR_CACHE" +# Get free disk space on root volume in 1K blocks. +get_free_space_kb() { + local target + target=$(get_free_space_target) + + local available_kb + available_kb=$(command df -Pk "$target" 2> /dev/null | awk 'NR==2 {print $4}' || true) + if [[ "$available_kb" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$available_kb" return 0 fi - local kernel - kernel=$(uname -r 2> /dev/null || true) - local major="${kernel%%.*}" - if [[ ! "$major" =~ ^[0-9]+$ ]]; then - # Return high number to skip potentially dangerous operations on unknown systems - major=999 + return 1 +} + +format_free_space_kb() { + local free_kb="${1:-}" + if [[ "$free_kb" =~ ^[0-9]+$ ]]; then + bytes_to_human_kb "$free_kb" + return 0 fi - export MOLE_DARWIN_MAJOR_CACHE="$major" - echo "$major" + + echo "Unknown" } -# Check if Darwin kernel major version meets minimum -is_darwin_ge() { - local minimum="$1" - local major - major=$(get_darwin_major) - [[ "$major" -ge "$minimum" ]] +# Get free disk space on root volume. +# Returns: human-readable decimal string (e.g., "100.00GB") +get_free_space() { + local free_kb + if free_kb=$(get_free_space_kb) && [[ "$free_kb" =~ ^[0-9]+$ ]]; then + format_free_space_kb "$free_kb" + return $? + fi + + echo "Unknown" } # Get optimal parallel jobs for operation type (scan|io|compute|default) @@ -270,23 +277,6 @@ is_root_user() { [[ "$(id -u)" == "0" ]] } -get_invoking_user() { - if [[ -n "${_MOLE_INVOKING_USER_CACHE:-}" ]]; then - echo "$_MOLE_INVOKING_USER_CACHE" - return 0 - fi - - local user - if [[ -n "${SUDO_USER:-}" && "${SUDO_USER:-}" != "root" ]]; then - user="$SUDO_USER" - else - user="${USER:-}" - fi - - export _MOLE_INVOKING_USER_CACHE="$user" - echo "$user" -} - get_invoking_uid() { if [[ -n "${SUDO_UID:-}" ]]; then echo "$SUDO_UID" @@ -459,62 +449,6 @@ ensure_user_file() { # Formatting Utilities # ============================================================================ -# Get brand-friendly localized name for an application -get_brand_name() { - local name="$1" - - # Detect if system primary language is Chinese (Cached) - if [[ -z "${MOLE_IS_CHINESE_SYSTEM:-}" ]]; then - local sys_lang - sys_lang=$(defaults read -g AppleLanguages 2> /dev/null | grep -o 'zh-Hans\|zh-Hant\|zh' | head -1 || echo "") - if [[ -n "$sys_lang" ]]; then - export MOLE_IS_CHINESE_SYSTEM="true" - else - export MOLE_IS_CHINESE_SYSTEM="false" - fi - fi - - local is_chinese="${MOLE_IS_CHINESE_SYSTEM}" - - # Return localized names based on system language - if [[ "$is_chinese" == true ]]; then - # Chinese system - prefer Chinese names - case "$name" in - "qiyimac" | "iQiyi") echo "爱奇艺" ;; - "wechat" | "WeChat") echo "微信" ;; - "QQ") echo "QQ" ;; - "VooV Meeting") echo "腾讯会议" ;; - "dingtalk" | "DingTalk") echo "钉钉" ;; - "NeteaseMusic" | "NetEase Music") echo "网易云音乐" ;; - "BaiduNetdisk" | "Baidu NetDisk") echo "百度网盘" ;; - "alipay" | "Alipay") echo "支付宝" ;; - "taobao" | "Taobao") echo "淘宝" ;; - "futunn" | "Futu NiuNiu") echo "富途牛牛" ;; - "tencent lemon" | "Tencent Lemon Cleaner" | "Tencent Lemon") echo "腾讯柠檬清理" ;; - *) echo "$name" ;; - esac - else - # Non-Chinese system - use English names - case "$name" in - "qiyimac" | "爱奇艺") echo "iQiyi" ;; - "wechat" | "微信") echo "WeChat" ;; - "QQ") echo "QQ" ;; - "腾讯会议") echo "VooV Meeting" ;; - "dingtalk" | "钉钉") echo "DingTalk" ;; - "网易云音乐") echo "NetEase Music" ;; - "百度网盘") echo "Baidu NetDisk" ;; - "alipay" | "支付宝") echo "Alipay" ;; - "taobao" | "淘宝") echo "Taobao" ;; - "富途牛牛") echo "Futu NiuNiu" ;; - "腾讯柠檬清理" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;; - "keynote" | "Keynote") echo "Keynote" ;; - "pages" | "Pages") echo "Pages" ;; - "numbers" | "Numbers") echo "Numbers" ;; - *) echo "$name" ;; - esac - fi -} - # Convert bytes to human-readable format (e.g., 1.5GB) # macOS (since Snow Leopard) uses Base-10 calculation (1 KB = 1000 bytes) bytes_to_human() { @@ -547,6 +481,74 @@ bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))" } +format_free_space_delta_kb() { + local delta_kb="${1:-0}" + [[ "$delta_kb" =~ ^-?[0-9]+$ ]] || delta_kb=0 + + local sign="" + local abs_kb="$delta_kb" + if ((delta_kb > 0)); then + sign="+" + elif ((delta_kb < 0)); then + sign="-" + abs_kb=$((-delta_kb)) + fi + + printf '%s%s\n' "$sign" "$(bytes_to_human_kb "$abs_kb")" +} + +mole_is_reverse_dns_bundle_id() { + local bundle_id="${1:-}" + + [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] || return 1 + [[ "$bundle_id" =~ ^[A-Za-z0-9][-A-Za-z0-9]*(\.[A-Za-z0-9][-A-Za-z0-9]*)+$ ]] +} + +mole_name_starts_with_bundle_id_boundary() { + local name="${1##*/}" + local bundle_id="${2:-}" + + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + [[ "$name" == "$bundle_id" || + "$name" == "$bundle_id".* ]] +} + +mole_name_has_bundle_id_boundary() { + local name="${1##*/}" + local bundle_id="${2:-}" + + mole_name_starts_with_bundle_id_boundary "$name" "$bundle_id" && return 0 + mole_is_reverse_dns_bundle_id "$bundle_id" || return 1 + [[ "$name" == *."$bundle_id" || + "$name" == *."$bundle_id".* ]] +} + +# Colorize an already-formatted human size string by unit. +colorize_human_size() { + local size_human="$1" + + local size_color="" + case "$size_human" in + *GB) size_color="$RED" ;; + *MB) size_color="$YELLOW" ;; + *KB) size_color="$GREEN" ;; + *B) size_color="$GRAY" ;; + *) + printf '%s' "$size_human" + return 0 + ;; + esac + + printf '%s%s%s' "$size_color" "$size_human" "$NC" +} + +# Cleanup result lines are always shown in green. Kept as a function (callers +# still pass a size in KB) so per-size coloring can be reintroduced in one place +# if ever wanted. +cleanup_result_color_kb() { + printf '%s' "$GREEN" +} + # ============================================================================ # Temporary File Management # ============================================================================ @@ -555,10 +557,88 @@ 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 +} + +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 +646,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 +668,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 +680,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 @@ -626,6 +708,24 @@ cleanup_temp_files() { TRACK_SECTION=0 SECTION_ACTIVITY=0 +# IMPORTANT: There are intentionally three start_section / end_section / +# note_activity implementations across the codebase. The one that wins is the +# one loaded last, and each variant has product-level differences (color, +# fallback wording, dry-run export behavior). Before changing any of them, +# read the cross references first: +# +# - lib/core/base.sh (this file): purple arrow header, "Nothing to tidy" +# fallback, no dry-run export. +# - bin/clean.sh: purple arrow header, "Nothing to clean" fallback, +# appends '=== title ===' to EXPORT_LIST_FILE under +# DRY_RUN, stops the section spinner on close. +# - bin/purge.sh: blue ━━━ box header, no fallback message, writes +# each note_activity line directly to EXPORT_LIST_FILE. +# +# Treat this file's version as the default for everything outside the clean +# and purge entry points. Do not unify the three blindly; the wording and +# export semantics are user-visible. + # Start a new section # Args: $1 - section title start_section() { @@ -721,6 +821,7 @@ update_progress_if_needed() { # Get last update time from variable local last_time + # eval: indirect read by name; bash 3.2 has no nameref (declare -n) eval "last_time=\${$last_update_var:-0}" [[ "$last_time" =~ ^[0-9]+$ ]] || last_time=0 @@ -731,6 +832,7 @@ update_progress_if_needed() { start_section_spinner "Scanning items... $completed/$total" # Update the last_update_time variable + # eval: indirect write by name; bash 3.2 has no nameref eval "$last_update_var=$current_time" return 0 fi diff --git a/Resources/mole/lib/core/bundle_resolver.sh b/Resources/mole/lib/core/bundle_resolver.sh new file mode 100644 index 0000000..44a10fb --- /dev/null +++ b/Resources/mole/lib/core/bundle_resolver.sh @@ -0,0 +1,102 @@ +#!/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 malformed IDs to avoid feeding junk into mdfind/find. + mole_is_reverse_dns_bundle_id "$bundle_id" || 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 + # `|| true` guards against two failure modes under `set -e` + `pipefail`: + # run_with_timeout returning 124 on timeout, and mdfind itself exiting + # non-zero. Both must fall through to the filesystem scan below. + local hit="" + if declare -f run_with_timeout > /dev/null 2>&1; then + hit=$(run_with_timeout "$MOLE_TIMEOUT_QUICK_DETECT_SEC" mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1) || true + else + hit=$(mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1) || true + 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 -a mapped_app_bundles=() + case "$bundle_id" in + com.microsoft.autoupdate.helper | com.microsoft.office.licensingV2.helper) + mapped_app_bundles=( + "com.microsoft.Word" + "com.microsoft.Excel" + "com.microsoft.Powerpoint" + "com.microsoft.Outlook" + "com.microsoft.OneNote" + ) + ;; + esac + + 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 + if ((${#mapped_app_bundles[@]} > 0)); then + local mapped_bundle + for mapped_bundle in "${mapped_app_bundles[@]}"; do + [[ "$app_bundle" == "$mapped_bundle" ]] && return 0 + done + fi + done < <(find "$app_root" -maxdepth 1 -name "*.app" -print0 2> /dev/null) + done + + return 1 +} diff --git a/Resources/mole/lib/core/commands.sh b/Resources/mole/lib/core/commands.sh index 3d2559e..87a770d 100644 --- a/Resources/mole/lib/core/commands.sh +++ b/Resources/mole/lib/core/commands.sh @@ -4,9 +4,10 @@ MOLE_COMMANDS=( "clean:Free up disk space" "uninstall:Remove apps completely" - "optimize:Check and maintain system" + "optimize:Refresh caches and services" "analyze:Explore disk usage" "status:Monitor system health" + "history:Review cleanup activity" "purge:Remove old project artifacts" "installer:Find and remove installer files" "touchid:Configure Touch ID for sudo" diff --git a/Resources/mole/lib/core/common.sh b/Resources/mole/lib/core/common.sh index 38f7640..62b5f7f 100755 --- a/Resources/mole/lib/core/common.sh +++ b/Resources/mole/lib/core/common.sh @@ -14,19 +14,62 @@ _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" +source "$_MOLE_CORE_DIR/timeouts.sh" 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" +source "$_MOLE_CORE_DIR/pkg_receipts.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" @@ -44,9 +87,9 @@ update_via_homebrew() { echo "Updating Homebrew..." fi - brew update > "$temp_update" 2>&1 & - local update_pid=$! - wait $update_pid 2> /dev/null || true # Continue even if brew update fails + local brew_update_timeout="${MOLE_HOMEBREW_UPDATE_TIMEOUT:-120}" + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$brew_update_timeout" brew update > "$temp_update" 2>&1 || true if [[ -t 1 ]]; then stop_inline_spinner @@ -59,9 +102,9 @@ update_via_homebrew() { echo "Upgrading Mole..." fi - brew upgrade mole > "$temp_upgrade" 2>&1 & - local upgrade_pid=$! - wait $upgrade_pid 2> /dev/null || true # Continue even if brew upgrade fails + local brew_upgrade_timeout="${MOLE_HOMEBREW_UPGRADE_TIMEOUT:-120}" + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$brew_upgrade_timeout" brew upgrade mole > "$temp_upgrade" 2>&1 || true local upgrade_output upgrade_output=$(cat "$temp_upgrade") @@ -79,7 +122,8 @@ update_via_homebrew() { if echo "$upgrade_output" | grep -q "already installed"; then local installed_version - installed_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + installed_version=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew list --versions mole 2> /dev/null | awk '{print $2}') [[ -z "$installed_version" ]] && installed_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${installed_version:-$current_version}" @@ -91,7 +135,8 @@ update_via_homebrew() { else echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true local new_version - new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}') + new_version=$(HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 \ + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" brew list --versions mole 2> /dev/null | awk '{print $2}') [[ -z "$new_version" ]] && new_version=$(mo --version 2> /dev/null | awk '/Mole version/ {print $3; exit}') echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version, ${new_version:-$current_version}" @@ -128,8 +173,14 @@ remove_apps_from_dock() { local changed=false for target in "${targets[@]}"; do local app_path="$target" + local bundle_id="" local full_path="" + if [[ "$target" == *"|"* ]]; then + app_path="${target%%|*}" + bundle_id="${target#*|}" + fi + if [[ "$app_path" =~ [[:cntrl:]] ]]; then debug_log "Skipping dock removal for path with control chars: $app_path" continue @@ -152,30 +203,31 @@ remove_apps_from_dock() { [[ -z "$full_path" ]] && continue local encoded_path="${full_path// /%20}" - - # Find the index of the app in persistent-apps - local i=0 - while true; do - local label - label=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-label" "$plist" 2> /dev/null || echo "") - [[ -z "$label" ]] && break - - local url - url=$(/usr/libexec/PlistBuddy -c "Print :persistent-apps:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") - [[ -z "$url" ]] && { - i=$((i + 1)) - continue - } - - # Match by URL-encoded path to handle spaces in app names - if [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]]; then - if /usr/libexec/PlistBuddy -c "Delete :persistent-apps:$i" "$plist" 2> /dev/null; then - changed=true - # After deletion, current index i now points to the next item - continue + local raw_path="$full_path" + + local dock_array + for dock_array in persistent-apps persistent-others recent-apps; do + local i=0 + while true; do + local tile_type + tile_type=$(/usr/libexec/PlistBuddy -c "Print :$dock_array:$i:tile-type" "$plist" 2> /dev/null || echo "") + [[ -z "$tile_type" ]] && break + + local url dock_bundle_id + url=$(/usr/libexec/PlistBuddy -c "Print :$dock_array:$i:tile-data:file-data:_CFURLString" "$plist" 2> /dev/null || echo "") + dock_bundle_id=$(/usr/libexec/PlistBuddy -c "Print :$dock_array:$i:tile-data:bundle-identifier" "$plist" 2> /dev/null || echo "") + + if { [[ -n "$bundle_id" && "$dock_bundle_id" == "$bundle_id" ]] || + [[ -n "$encoded_path" && "$url" == *"$encoded_path"* ]] || + [[ -n "$raw_path" && "$url" == *"$raw_path"* ]]; }; then + if /usr/libexec/PlistBuddy -c "Delete :$dock_array:$i" "$plist" 2> /dev/null; then + changed=true + # After deletion, current index i now points to the next item. + continue + fi fi - fi - i=$((i + 1)) + i=$((i + 1)) + done done done diff --git a/Resources/mole/lib/core/file_ops.sh b/Resources/mole/lib/core/file_ops.sh index 5c41618..1974249 100644 --- a/Resources/mole/lib/core/file_ops.sh +++ b/Resources/mole/lib/core/file_ops.sh @@ -63,6 +63,52 @@ format_duration_human() { # Path Validation # ============================================================================ +_mole_normalize_deletion_policy_path() { + local path="$1" + local slash="/" + local double_slash="//" + + while [[ "$path" == *"$double_slash"* ]]; do + path="${path//$double_slash/$slash}" + done + + local trimmed="${path%/}" + [[ -n "$trimmed" ]] && printf '%s\n' "$trimmed" || printf '%s\n' "$path" +} + +# Deletion policy only. App/data protection stays in app_protection.sh. +_mole_is_critical_deletion_path() { + local path="$1" + + case "$path" in + / | \ + /bin | /bin/* | \ + /sbin | /sbin/* | \ + /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | \ + /System | /System/* | \ + /Library/Apple | /Library/Apple/* | \ + /Library/Extensions | /Library/Extensions/* | \ + /Library/Keychains | /Library/Keychains/* | \ + /Applications/Finder.app | /Applications/Finder.app/* | \ + /Applications/Safari.app | /Applications/Safari.app/* | \ + /Users | /Users/Shared | /Users/Guest | /Users/Guest/*) + return 0 + ;; + /private) + return 0 + ;; + /etc | /etc/* | /private/etc | /private/etc/*) + return 0 + ;; + /var | /var/db | /var/db/* | /var/audit | /var/audit/* | \ + /private/var | /private/var/db | /private/var/db/* | /private/var/audit | /private/var/audit/*) + return 0 + ;; + esac + + return 1 +} + # Validate path for deletion (absolute, no traversal, not system dir) validate_path_for_deletion() { local path="$1" @@ -73,6 +119,29 @@ validate_path_for_deletion() { return 1 fi + # Check path is absolute + if [[ "$path" != /* ]]; then + log_error "Path validation failed: path must be absolute: $path" + return 1 + fi + + # Check for path traversal attempts + # Only reject .. when it appears as a complete path component (/../ or /.. or ../) + # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") + if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then + log_error "Path validation failed: path traversal not allowed: $path" + return 1 + fi + + # Check path doesn't contain dangerous characters + if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then + log_error "Path validation failed: contains control characters: $path" + return 1 + fi + + local policy_path + policy_path=$(_mole_normalize_deletion_policy_path "$path") + # Check symlink target if path is a symbolic link if [[ -L "$path" ]]; then local link_target @@ -91,44 +160,23 @@ 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/*) - log_error "Symlink points to protected system path: $path -> $resolved_target" - return 1 - ;; - esac + resolved_target=$(_mole_normalize_deletion_policy_path "$resolved_target") + if _mole_is_critical_deletion_path "$resolved_target"; then + log_error "Symlink points to protected system path: $path -> $resolved_target" + return 1 + fi fi fi - # Check path is absolute - if [[ "$path" != /* ]]; then - log_error "Path validation failed: path must be absolute: $path" - return 1 - fi - - # Check for path traversal attempts - # Only reject .. when it appears as a complete path component (/../ or /.. or ../) - # This allows legitimate directory names containing .. (e.g., Firefox's "name..files") - if [[ "$path" =~ (^|/)\.\.(\/|$) ]]; then - log_error "Path validation failed: path traversal not allowed: $path" - return 1 - fi - - # Check path doesn't contain dangerous characters - if [[ "$path" =~ [[:cntrl:]] ]] || [[ "$path" =~ $'\n' ]]; then - log_error "Path validation failed: contains control characters: $path" - return 1 - fi - # Allow deletion of coresymbolicationd cache (safe system cache that can be rebuilt) - case "$path" in + case "$policy_path" in /System/Library/Caches/com.apple.coresymbolicationd/data | /System/Library/Caches/com.apple.coresymbolicationd/data/*) return 0 ;; esac # Allow known safe paths under /private - case "$path" in + case "$policy_path" in /private/tmp | /private/tmp/* | \ /private/var/tmp | /private/var/tmp/* | \ /private/var/log | /private/var/log/* | \ @@ -143,30 +191,16 @@ validate_path_for_deletion() { esac # Check path isn't critical system directory - case "$path" in - / | /bin | /bin/* | /sbin | /sbin/* | /usr | /usr/bin | /usr/bin/* | /usr/sbin | /usr/sbin/* | /usr/lib | /usr/lib/* | /System | /System/* | /Library/Extensions) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /private) - log_error "Path validation failed: critical system directory: $path" - return 1 - ;; - /etc | /etc/* | /private/etc | /private/etc/*) - log_error "Path validation failed: /etc contains critical system files: $path" - return 1 - ;; - /var | /var/db | /var/db/* | /private/var | /private/var/db | /private/var/db/*) - log_error "Path validation failed: /var/db contains system databases: $path" - return 1 - ;; - esac + if _mole_is_critical_deletion_path "$policy_path"; then + log_error "Path validation failed: critical system path: $path" + return 1 + fi # Check if path is protected (keychains, system settings, etc) if declare -f should_protect_path > /dev/null 2>&1; then - if should_protect_path "$path"; then + if should_protect_path "$policy_path"; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then - log_warning "Path validation: protected path skipped: $path" + log_warning "Path validation: protected path skipped: $policy_path" fi return 1 fi @@ -183,9 +217,13 @@ validate_path_for_deletion() { safe_remove() { local path="$1" local silent="${2:-false}" + local precomputed_size_kb="${3:-}" - # Validate path - if ! validate_path_for_deletion "$path"; then + # Validate path. Silent cleanup callers still need the same policy result, + # but should not print one validation warning per skipped cache item. + if [[ "$silent" == "true" ]]; then + validate_path_for_deletion "$path" 2> /dev/null || return 1 + elif ! validate_path_for_deletion "$path"; then return 1 fi @@ -231,15 +269,18 @@ safe_remove() { debug_log "Removing: $path" - # Calculate size before deletion for logging + # Calculate size before deletion for logging. + # Accept pre-computed size to skip redundant I/O when the caller already measured. local size_kb=0 local size_human="" if oplog_enabled; then - if [[ -e "$path" ]]; then + if [[ -n "$precomputed_size_kb" && "$precomputed_size_kb" =~ ^[0-9]+$ ]]; then + size_kb="$precomputed_size_kb" + elif [[ -e "$path" ]]; then size_kb=$(get_path_size_kb "$path" 2> /dev/null || echo "0") - if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") - fi + fi + if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then + size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi fi @@ -283,6 +324,10 @@ safe_remove_symlink() { return 1 fi + if ! validate_path_for_deletion "$path"; then + return 1 + fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then debug_log "[DRY RUN] Would remove symlink: $path" return 0 @@ -290,7 +335,11 @@ safe_remove_symlink() { local rm_exit=0 if [[ "$use_sudo" == "true" ]]; then - sudo rm "$path" 2> /dev/null || rm_exit=$? + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo blocked in test mode" + return 1 + fi + sudo -n rm "$path" 2> /dev/null || rm_exit=$? else rm "$path" 2> /dev/null || rm_exit=$? fi @@ -309,7 +358,11 @@ safe_sudo_remove() { local path="$1" if ! validate_path_for_deletion "$path"; then - log_error "Path validation failed for sudo remove: $path" + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$path"; then + debug_log "Skipped sudo remove for protected path: $path" + else + log_error "Path validation failed for sudo remove: $path" + fi return 1 fi @@ -322,6 +375,15 @@ safe_sudo_remove() { return 1 fi + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + log_info "[DRY-RUN] Would sudo remove: $path" + return 0 + fi + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sudo blocked in test mode" + return 1 + fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then if [[ "${MO_DEBUG:-}" == "1" ]]; then local file_type="file" @@ -330,16 +392,16 @@ safe_sudo_remove() { local file_size="" local file_age="" - if sudo test -e "$path" 2> /dev/null; then + if sudo -n test -e "$path" 2> /dev/null; then local size_kb - size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") + size_kb=$(sudo -n du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" -gt 0 ]]; then file_size=$(bytes_to_human "$((size_kb * 1024))") fi - if sudo test -f "$path" 2> /dev/null || sudo test -d "$path" 2> /dev/null; then + if sudo -n test -f "$path" 2> /dev/null || sudo -n test -d "$path" 2> /dev/null; then local mod_time - mod_time=$(sudo stat -f%m "$path" 2> /dev/null || echo "0") + mod_time=$(sudo -n stat -f%m "$path" 2> /dev/null || echo "0") local now now=$(date +%s 2> /dev/null || echo "0") if [[ "$mod_time" -gt 0 && "$now" -gt 0 ]]; then @@ -361,8 +423,8 @@ safe_sudo_remove() { local size_kb=0 local size_human="" if oplog_enabled; then - if sudo test -e "$path" 2> /dev/null; then - size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") + if sudo -n test -e "$path" 2> /dev/null; then + size_kb=$(sudo -n du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0") if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB") fi @@ -371,7 +433,7 @@ safe_sudo_remove() { local output local ret=0 - output=$(sudo rm -rf "$path" 2>&1) || ret=$? # safe_remove + output=$(sudo -n rm -rf "$path" 2>&1) || ret=$? # safe_remove if [[ $ret -eq 0 ]]; then log_operation "${MOLE_CURRENT_COMMAND:-clean}" "REMOVED" "$path" "$size_human" @@ -379,6 +441,10 @@ safe_sudo_remove() { fi case "$output" in + *"a password is required"* | *"a terminal is required"* | *"Password:"*) + log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "auth required" + return "$MOLE_ERR_AUTH_FAILED" + ;; *"Operation not permitted"*) log_operation "${MOLE_CURRENT_COMMAND:-clean}" "FAILED" "$path" "sip/mdm protected" return "$MOLE_ERR_SIP_PROTECTED" @@ -399,6 +465,350 @@ 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"; other values fail +# 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 + + case "$mode" in + permanent | trash) ;; + *) + _mole_delete_log "$mode" "unknown" "invalid-mode" "$path" + if [[ -z "${_MOLE_INVALID_MODE_WARNED:-}" ]]; then + _MOLE_INVALID_MODE_WARNED=1 + export _MOLE_INVALID_MODE_WARNED + printf 'Error: invalid MOLE_DELETE_MODE: %s (expected "permanent" or "trash")\n' "$mode" >&2 + fi + return 1 + ;; + esac + + # 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 ! 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 + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + du_rc=1 + else + raw_size=$(sudo -n du -skP "$path" 2> /dev/null | awk '{print $1; exit}') + du_rc=${PIPESTATUS[0]} + fi + 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 + + if [[ "$needs_sudo" == "true" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + _mole_delete_log "$mode" "$size_kb" "sudo-blocked-test-mode" "$path" + return 1 + fi + fi + + # Trash mode is a recoverable-delete contract. If Trash is unavailable, + # fail closed instead of silently switching to permanent removal. + 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 + _mole_delete_log "trash" "$size_kb" "trash-failed" "$path" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "SKIPPED" "$path" "trash-failed" + if [[ -z "${_MOLE_TRASH_UNAVAILABLE_WARNED:-}" ]]; then + _MOLE_TRASH_UNAVAILABLE_WARNED=1 + export _MOLE_TRASH_UNAVAILABLE_WARNED + printf 'Error: Trash unavailable; refusing permanent delete. Use --permanent to delete immediately.\n' >&2 + fi + debug_log "Trash move failed, refusing permanent delete: $path" + return 1 + 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" + _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 + + if [[ "$needs_sudo" == "true" ]]; then + _mole_move_sudo_path_to_user_trash "$path" + return $? + fi + + # Prefer the `trash` CLI (Homebrew formula) for normal user-owned paths. + if command -v trash > /dev/null 2>&1; then + trash "$path" > /dev/null 2>&1 && return 0 + 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_move_sudo_path_to_user_trash() { + local path="$1" + local user_home="" + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + if declare -f get_invoking_home > /dev/null 2>&1; then + user_home=$(get_invoking_home) + else + user_home="${HOME:-}" + fi + + if [[ -z "$user_home" || "$user_home" != /* || "$user_home" == "/" || "$user_home" == "/var/root" ]]; then + debug_log "Refusing sudo Trash move: invalid invoking user home: ${user_home:-}" + return 1 + fi + + if [[ -z "$path" ]] || [[ ! -e "$path" && ! -L "$path" ]]; then + debug_log "Refusing sudo Trash move: path does not exist: ${path:-}" + return 1 + fi + + local trash_dir="${user_home%/}/.Trash" + + # The destination must be the invoking user's Trash, even though sudo is + # needed to unlink the original protected path. + if [[ -L "$trash_dir" ]]; then + debug_log "Refusing sudo Trash move: invoking user Trash is a symlink: $trash_dir" + return 1 + fi + if ! mkdir -p "$trash_dir" 2> /dev/null; then + if ! sudo -n mkdir -p "$trash_dir" 2> /dev/null; then + debug_log "Failed to create invoking user Trash: $trash_dir" + return 1 + fi + fi + if [[ ! -d "$trash_dir" || -L "$trash_dir" ]]; then + debug_log "Refusing sudo Trash move: invoking user Trash is not a normal directory: $trash_dir" + return 1 + fi + + local owner_uid="" owner_gid="" + if declare -f get_invoking_uid > /dev/null 2>&1; then + owner_uid=$(get_invoking_uid) + fi + if declare -f get_invoking_gid > /dev/null 2>&1; then + owner_gid=$(get_invoking_gid) + fi + if [[ "$owner_uid" =~ ^[0-9]+$ && "$owner_gid" =~ ^[0-9]+$ ]]; then + sudo -n chown "$owner_uid:$owner_gid" "$trash_dir" 2> /dev/null || true + fi + if ! chmod 700 "$trash_dir" 2> /dev/null; then + sudo -n chmod 700 "$trash_dir" 2> /dev/null || true + fi + + # Avoid Finder-style ':' path weirdness and keep generated names filesystem-safe. + local base + base=$(basename "$path") + base="${base//:/__}" + base="${base//\//__}" + [[ -n "$base" && "$base" != "." && "$base" != ".." ]] || base="mole-trash-item" + + local dest="$trash_dir/$base" + local ts suffix + ts=$(date +%s 2> /dev/null || echo 0) + suffix=0 + + while [[ -e "$dest" || -L "$dest" ]]; do + suffix=$((suffix + 1)) + if [[ $suffix -gt 100 ]]; then + debug_log "Failed to choose unique Trash destination for: $path" + return 1 + fi + dest="$trash_dir/$base.$ts.$$.$suffix" + done + + if ! sudo -n mv -n "$path" "$dest" > /dev/null 2>&1; then + debug_log "Failed to move sudo-required path to invoking user Trash: $path -> $dest" + return 1 + fi + if [[ -e "$path" || -L "$path" ]] || [[ ! -e "$dest" && ! -L "$dest" ]]; then + debug_log "Failed to move sudo-required path without overwriting destination: $path -> $dest" + return 1 + fi + + # Best-effort ownership repair makes restored Trash items behave like user files. + if [[ "$owner_uid" =~ ^[0-9]+$ && "$owner_gid" =~ ^[0-9]+$ ]]; then + sudo -n chown -R "$owner_uid:$owner_gid" "$dest" 2> /dev/null || true + fi + + debug_log "Moved sudo-required path to invoking user Trash: $path -> $dest" + return 0 +} + +# Batched Trash move for non-sudo, non-symlink paths. Removes the per-file +# subprocess fan-out that made AppleScript-fallback uninstalls feel frozen +# (100 files * ~1s each). Returns 0 only when the entire batch landed in the +# Trash; callers must fall back to the per-file path on non-zero so nothing +# is silently skipped. +_mole_move_to_trash_batch() { + local -a paths=("$@") + [[ ${#paths[@]} -eq 0 ]] && return 0 + + if [[ -n "${MOLE_TEST_TRASH_DIR:-}" ]]; then + mkdir -p "$MOLE_TEST_TRASH_DIR" 2> /dev/null || return 1 + local ts + ts=$(date +%s 2> /dev/null || echo 0) + local p dest + for p in "${paths[@]}"; do + dest="$MOLE_TEST_TRASH_DIR/$(basename "$p").$$.${ts}.$RANDOM" + mv "$p" "$dest" 2> /dev/null || return 1 + done + return 0 + fi + + if [[ "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + + if command -v trash > /dev/null 2>&1; then + trash "${paths[@]}" > /dev/null 2>&1 && return 0 + fi + + # AppleScript fallback: build one POSIX-file list and tell Finder once. + osascript - "${paths[@]}" > /dev/null 2>&1 << 'APPLESCRIPT' +on run argv + set posixList to {} + repeat with a in argv + set end of posixList to POSIX file (a as text) + end repeat + tell application "Finder" to delete posixList +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,13 +844,19 @@ 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 + if declare -f should_protect_path > /dev/null 2>&1 && 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) + done < <(command find "$base_dir" "${find_args[@]}" -print0 2> /dev/null < /dev/null || true) return 0 } @@ -452,13 +868,18 @@ safe_sudo_find_delete() { local age_days="${3:-7}" local type_filter="${4:-f}" + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + debug_log "Skipping sudo find/delete in test mode: $base_dir" + return 0 + fi + # Validate base directory (use sudo for permission-restricted dirs) - if ! sudo test -d "$base_dir" 2> /dev/null; then + if ! sudo -n test -d "$base_dir" 2> /dev/null; then debug_log "Directory does not exist, skipping: $base_dir" return 0 fi - if sudo test -L "$base_dir" 2> /dev/null; then + if sudo -n test -L "$base_dir" 2> /dev/null; then log_error "Refusing to search symlinked directory: $base_dir" return 1 fi @@ -481,13 +902,17 @@ 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) + done < <(sudo -n find "$base_dir" "${find_args[@]}" -print0 2> /dev/null || true) return 0 } @@ -497,6 +922,16 @@ safe_sudo_find_delete() { # ============================================================================ # Get path size in KB (returns 0 if not found) +# +# For regular files and symlinks, prefer 'stat' over 'du': it avoids the +# fork+pipe cost of 'du | awk' on every call, which adds up in tight loops +# (e.g. external-volume ._* sweeps, Application Support log scans). 'du -skP' +# and 'stat -f%z' both report logical size without following symlinks on +# macOS, and the 1KB-rounded outputs match for the file types we encounter +# (logs, caches, leftovers). Directories still go through 'du' because 'stat' +# only reports a single directory entry, not recursive content size. .app +# bundles continue to go through mdls because APFS clones make 'du' +# under-report large bundles like Xcode. get_path_size_kb() { local path="$1" [[ -z "$path" || ! -e "$path" ]] && { @@ -516,6 +951,17 @@ get_path_size_kb() { fi fi + # Fast path for regular files and symlinks: avoid forking 'du'. + if [[ -f "$path" || -L "$path" ]]; then + local bytes + bytes=$(stat -f%z "$path" 2> /dev/null || echo "") + if [[ "$bytes" =~ ^[0-9]+$ ]]; then + # Round up to whole KB to match 'du -skP' semantics. + echo $(((bytes + 1023) / 1024)) + return + fi + fi + local size size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true) diff --git a/Resources/mole/lib/core/help.sh b/Resources/mole/lib/core/help.sh index 6deb945..0c045d5 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" @@ -26,7 +27,7 @@ show_installer_help() { show_optimize_help() { echo "Usage: mo optimize [OPTIONS]" echo "" - echo "Check and maintain system health, apply optimizations." + echo "Refresh system caches and services, repair safe maintenance issues." echo "" echo "Options:" echo " --dry-run Preview optimization without making changes" @@ -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/history.sh b/Resources/mole/lib/core/history.sh new file mode 100644 index 0000000..0a99d7d --- /dev/null +++ b/Resources/mole/lib/core/history.sh @@ -0,0 +1,553 @@ +#!/bin/bash +# Mole - History parsing and rendering. + +set -euo pipefail + +if [[ -n "${MOLE_HISTORY_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_HISTORY_LOADED=1 + +if [[ -z "${MOLE_BASE_LOADED:-}" ]]; then + _MOLE_CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck source=lib/core/base.sh + source "$_MOLE_CORE_DIR/base.sh" +fi + +readonly MOLE_HISTORY_DEFAULT_LIMIT=20 +readonly MOLE_HISTORY_MAX_LIMIT=200 + +declare -a HISTORY_SESSION_COMMANDS=() +declare -a HISTORY_SESSION_STARTED_AT=() +declare -a HISTORY_SESSION_ENDED_AT=() +declare -a HISTORY_SESSION_ITEMS=() +declare -a HISTORY_SESSION_SIZE=() +declare -a HISTORY_SESSION_REMOVED=() +declare -a HISTORY_SESSION_TRASHED=() +declare -a HISTORY_SESSION_SKIPPED=() +declare -a HISTORY_SESSION_FAILED=() +declare -a HISTORY_SESSION_REBUILT=() +declare -a HISTORY_SESSION_OTHER=() +declare -a HISTORY_SESSION_OPERATIONS=() + +declare -a HISTORY_DELETE_TIMESTAMPS=() +declare -a HISTORY_DELETE_MODES=() +declare -a HISTORY_DELETE_SIZE_KB=() +declare -a HISTORY_DELETE_STATUSES=() +declare -a HISTORY_DELETE_PATHS=() + +HISTORY_ACTIVE_COMMAND="" +HISTORY_ACTIVE_STARTED_AT="" +HISTORY_ACTIVE_ENDED_AT="" +HISTORY_ACTIVE_ITEMS=0 +HISTORY_ACTIVE_SIZE="0B" +HISTORY_ACTIVE_REMOVED=0 +HISTORY_ACTIVE_TRASHED=0 +HISTORY_ACTIVE_SKIPPED=0 +HISTORY_ACTIVE_FAILED=0 +HISTORY_ACTIVE_REBUILT=0 +HISTORY_ACTIVE_OTHER=0 +HISTORY_ACTIVE_OPERATIONS=0 + +history_operations_log_file() { + printf '%s\n' "${MOLE_OPERATIONS_LOG:-${OPERATIONS_LOG_FILE:-$HOME/Library/Logs/mole/operations.log}}" +} + +history_deletions_log_file() { + printf '%s\n' "${MOLE_DELETE_LOG:-$HOME/Library/Logs/mole/deletions.log}" +} + +history_normalize_limit() { + local value="${1:-$MOLE_HISTORY_DEFAULT_LIMIT}" + local normalized max_digits + + if ! normalized=$(history_normalize_decimal "$value"); then + printf '%s\n' "$MOLE_HISTORY_DEFAULT_LIMIT" + return 0 + fi + if [[ "$normalized" == "0" ]]; then + printf '%s\n' "$MOLE_HISTORY_DEFAULT_LIMIT" + return 0 + fi + max_digits=${#MOLE_HISTORY_MAX_LIMIT} + if [[ "${#normalized}" -gt "$max_digits" ]]; then + printf '%s\n' "$MOLE_HISTORY_MAX_LIMIT" + return 0 + fi + if [[ "$normalized" -gt "$MOLE_HISTORY_MAX_LIMIT" ]]; then + printf '%s\n' "$MOLE_HISTORY_MAX_LIMIT" + return 0 + fi + printf '%s\n' "$normalized" +} + +history_normalize_decimal() { + local value="${1:-}" + + [[ "$value" =~ ^[0-9]+$ ]] || return 1 + while [[ "$value" != "0" && "${value#0}" != "$value" ]]; do + value="${value#0}" + done + printf '%s\n' "$value" +} + +history_parse_limit() { + local value="$1" + local normalized max_digits + + normalized=$(history_normalize_decimal "$value") || return 1 + [[ "$normalized" != "0" ]] || return 1 + max_digits=${#MOLE_HISTORY_MAX_LIMIT} + [[ "${#normalized}" -le "$max_digits" ]] || return 1 + [[ "$normalized" -le "$MOLE_HISTORY_MAX_LIMIT" ]] || return 1 + printf '%s\n' "$normalized" +} + +history_reset_active_session() { + HISTORY_ACTIVE_COMMAND="" + HISTORY_ACTIVE_STARTED_AT="" + HISTORY_ACTIVE_ENDED_AT="" + HISTORY_ACTIVE_ITEMS=0 + HISTORY_ACTIVE_SIZE="0B" + HISTORY_ACTIVE_REMOVED=0 + HISTORY_ACTIVE_TRASHED=0 + HISTORY_ACTIVE_SKIPPED=0 + HISTORY_ACTIVE_FAILED=0 + HISTORY_ACTIVE_REBUILT=0 + HISTORY_ACTIVE_OTHER=0 + HISTORY_ACTIVE_OPERATIONS=0 +} + +history_start_session() { + local command="$1" + local started_at="$2" + + if [[ -n "$HISTORY_ACTIVE_COMMAND" ]]; then + history_finish_session + fi + + history_reset_active_session + HISTORY_ACTIVE_COMMAND="$command" + HISTORY_ACTIVE_STARTED_AT="$started_at" +} + +history_finish_session() { + [[ -z "$HISTORY_ACTIVE_COMMAND" ]] && return 0 + + HISTORY_SESSION_COMMANDS+=("$HISTORY_ACTIVE_COMMAND") + HISTORY_SESSION_STARTED_AT+=("$HISTORY_ACTIVE_STARTED_AT") + HISTORY_SESSION_ENDED_AT+=("$HISTORY_ACTIVE_ENDED_AT") + HISTORY_SESSION_ITEMS+=("$HISTORY_ACTIVE_ITEMS") + HISTORY_SESSION_SIZE+=("$HISTORY_ACTIVE_SIZE") + HISTORY_SESSION_REMOVED+=("$HISTORY_ACTIVE_REMOVED") + HISTORY_SESSION_TRASHED+=("$HISTORY_ACTIVE_TRASHED") + HISTORY_SESSION_SKIPPED+=("$HISTORY_ACTIVE_SKIPPED") + HISTORY_SESSION_FAILED+=("$HISTORY_ACTIVE_FAILED") + HISTORY_SESSION_REBUILT+=("$HISTORY_ACTIVE_REBUILT") + HISTORY_SESSION_OTHER+=("$HISTORY_ACTIVE_OTHER") + HISTORY_SESSION_OPERATIONS+=("$HISTORY_ACTIVE_OPERATIONS") + + history_reset_active_session +} + +history_record_operation() { + local command="$1" + local action="$2" + local timestamp="$3" + + if [[ -z "$HISTORY_ACTIVE_COMMAND" ]]; then + history_start_session "$command" "$timestamp" + fi + + HISTORY_ACTIVE_OPERATIONS=$((HISTORY_ACTIVE_OPERATIONS + 1)) + case "$action" in + REMOVED) HISTORY_ACTIVE_REMOVED=$((HISTORY_ACTIVE_REMOVED + 1)) ;; + TRASHED) HISTORY_ACTIVE_TRASHED=$((HISTORY_ACTIVE_TRASHED + 1)) ;; + SKIPPED) HISTORY_ACTIVE_SKIPPED=$((HISTORY_ACTIVE_SKIPPED + 1)) ;; + FAILED) HISTORY_ACTIVE_FAILED=$((HISTORY_ACTIVE_FAILED + 1)) ;; + REBUILT) HISTORY_ACTIVE_REBUILT=$((HISTORY_ACTIVE_REBUILT + 1)) ;; + *) HISTORY_ACTIVE_OTHER=$((HISTORY_ACTIVE_OTHER + 1)) ;; + esac +} + +history_parse_session_start() { + local line="$1" + local inner command started_at + + case "$line" in + "# ========== "*" session started at "*" ==========") ;; + *) return 1 ;; + esac + inner="${line#"# ========== "}" + command="${inner%% session started at *}" + started_at="${inner#* session started at }" + started_at="${started_at%" =========="}" + history_start_session "$command" "$started_at" + return 0 +} + +history_parse_session_end() { + local line="$1" + local inner command rest ended_at tail items size + + case "$line" in + "# ========== "*" session ended at "*" ==========") ;; + *) return 1 ;; + esac + inner="${line#"# ========== "}" + command="${inner%% session ended at *}" + rest="${inner#* session ended at }" + rest="${rest%" =========="}" + ended_at="$rest" + items="" + size="" + if [[ "$rest" == *", "* ]]; then + ended_at="${rest%%, *}" + tail="${rest#"$ended_at, "}" + if [[ "$tail" == *" items, "* ]]; then + items="${tail%% items,*}" + size="${tail#*, }" + fi + fi + + if [[ -z "$HISTORY_ACTIVE_COMMAND" ]]; then + history_start_session "$command" "$ended_at" + fi + + HISTORY_ACTIVE_ENDED_AT="$ended_at" + [[ "$items" =~ ^[0-9]+$ ]] && HISTORY_ACTIVE_ITEMS="$items" + [[ -n "$size" ]] && HISTORY_ACTIVE_SIZE="$size" + history_finish_session + return 0 +} + +history_parse_operation_line() { + local line="$1" + local timestamp rest command rest_after_command action + + [[ "$line" == "["*"] ["*"] "* ]] || return 1 + + timestamp="${line#\[}" + timestamp="${timestamp%%]*}" + rest="${line#*\] }" + command="${rest#\[}" + command="${command%%]*}" + rest_after_command="${rest#*\] }" + action="${rest_after_command%% *}" + + [[ -n "$timestamp" && -n "$command" && -n "$action" ]] || return 1 + history_record_operation "$command" "$action" "$timestamp" + return 0 +} + +history_reset_sessions() { + history_reset_active_session + HISTORY_SESSION_COMMANDS=() + HISTORY_SESSION_STARTED_AT=() + HISTORY_SESSION_ENDED_AT=() + HISTORY_SESSION_ITEMS=() + HISTORY_SESSION_SIZE=() + HISTORY_SESSION_REMOVED=() + HISTORY_SESSION_TRASHED=() + HISTORY_SESSION_SKIPPED=() + HISTORY_SESSION_FAILED=() + HISTORY_SESSION_REBUILT=() + HISTORY_SESSION_OTHER=() + HISTORY_SESSION_OPERATIONS=() +} + +history_reset_deletions() { + HISTORY_DELETE_TIMESTAMPS=() + HISTORY_DELETE_MODES=() + HISTORY_DELETE_SIZE_KB=() + HISTORY_DELETE_STATUSES=() + HISTORY_DELETE_PATHS=() +} + +history_load_operations() { + local log_file="$1" + local line + + history_reset_sessions + + [[ -f "$log_file" ]] || return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + history_parse_session_start "$line" && continue + history_parse_session_end "$line" && continue + history_parse_operation_line "$line" && continue + done < "$log_file" + + if [[ -n "$HISTORY_ACTIVE_COMMAND" ]]; then + history_finish_session + fi +} + +history_load_deletions() { + local log_file="$1" + local line timestamp mode size_kb status path + + history_reset_deletions + + [[ -f "$log_file" ]] || return 0 + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" ]] && continue + IFS=$'\t' read -r timestamp mode size_kb status path <<< "$line" + [[ -n "${timestamp:-}" && -n "${mode:-}" && -n "${status:-}" ]] || continue + HISTORY_DELETE_TIMESTAMPS+=("$timestamp") + HISTORY_DELETE_MODES+=("$mode") + HISTORY_DELETE_SIZE_KB+=("${size_kb:-unknown}") + HISTORY_DELETE_STATUSES+=("$status") + HISTORY_DELETE_PATHS+=("${path:-}") + done < "$log_file" +} + +history_join_counts() { + local -a parts=() + local removed="$1" + local trashed="$2" + local skipped="$3" + local failed="$4" + local rebuilt="$5" + local other="$6" + + [[ "$removed" -gt 0 ]] && parts+=("removed $removed") + [[ "$trashed" -gt 0 ]] && parts+=("trashed $trashed") + [[ "$skipped" -gt 0 ]] && parts+=("skipped $skipped") + [[ "$failed" -gt 0 ]] && parts+=("failed $failed") + [[ "$rebuilt" -gt 0 ]] && parts+=("rebuilt $rebuilt") + [[ "$other" -gt 0 ]] && parts+=("other $other") + + if [[ ${#parts[@]} -eq 0 ]]; then + printf 'no file actions' + return 0 + fi + + local output="${parts[0]}" + local idx=1 + while [[ $idx -lt ${#parts[@]} ]]; do + output+=", ${parts[$idx]}" + idx=$((idx + 1)) + done + printf '%s' "$output" +} + +history_size_label() { + local size_kb="$1" + + if [[ "$size_kb" =~ ^[0-9]+$ ]]; then + bytes_to_human_kb "$size_kb" + else + printf 'unknown' + fi +} + +history_json_escape() { + local value="${1:-}" + local LC_ALL=C + local char code idx + + idx=0 + while [[ "$idx" -lt "${#value}" ]]; do + char="${value:$idx:1}" + case "$char" in + "\\") printf '%s' "\\\\" ;; + "\"") printf '%s' "\\\"" ;; + $'\b') printf '%s' "\\b" ;; + $'\f') printf '%s' "\\f" ;; + $'\n') printf '%s' "\\n" ;; + $'\r') printf '%s' "\\r" ;; + $'\t') printf '%s' "\\t" ;; + *) + printf -v code '%d' "'$char" + if [[ "$code" -lt 0 ]]; then + code=$((code + 256)) + fi + if [[ "$code" -lt 32 ]]; then + printf '\\u%04x' "$code" + else + printf '%s' "$char" + fi + ;; + esac + idx=$((idx + 1)) + done +} + +history_json_string() { + printf '"' + history_json_escape "${1:-}" + printf '"' +} + +history_json_string_field() { + local indent="$1" + local key="$2" + local value="${3:-}" + local suffix="${4-,}" + + printf '%s"%s": ' "$indent" "$key" + history_json_string "$value" + printf '%s\n' "$suffix" +} + +history_json_number_field() { + local indent="$1" + local key="$2" + local value="$3" + local suffix="${4-,}" + + printf '%s"%s": %s%s\n' "$indent" "$key" "$value" "$suffix" +} + +history_render_text() { + local limit + limit=$(history_normalize_limit "${1:-$MOLE_HISTORY_DEFAULT_LIMIT}") + + local operations_log deletions_log session_count deletion_count + operations_log=$(history_operations_log_file) + deletions_log=$(history_deletions_log_file) + session_count=${#HISTORY_SESSION_COMMANDS[@]} + deletion_count=${#HISTORY_DELETE_TIMESTAMPS[@]} + + printf '\n%sMole History%s\n\n' "$BLUE" "$NC" + + if [[ "$session_count" -eq 0 ]]; then + printf 'Recent sessions\n' + printf ' No operation history yet.\n' + else + printf 'Recent sessions\n' + local start=$((session_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + local idx=$((session_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + local command="${HISTORY_SESSION_COMMANDS[$idx]}" + local started="${HISTORY_SESSION_STARTED_AT[$idx]}" + local ended="${HISTORY_SESSION_ENDED_AT[$idx]}" + local items="${HISTORY_SESSION_ITEMS[$idx]}" + local size="${HISTORY_SESSION_SIZE[$idx]}" + local removed="${HISTORY_SESSION_REMOVED[$idx]}" + local trashed="${HISTORY_SESSION_TRASHED[$idx]}" + local skipped="${HISTORY_SESSION_SKIPPED[$idx]}" + local failed="${HISTORY_SESSION_FAILED[$idx]}" + local rebuilt="${HISTORY_SESSION_REBUILT[$idx]}" + local other="${HISTORY_SESSION_OTHER[$idx]}" + local count_text + count_text=$(history_join_counts "$removed" "$trashed" "$skipped" "$failed" "$rebuilt" "$other") + [[ -z "$ended" ]] && ended="not ended" + printf ' %-10s %s, %s items, %s\n' "$command" "$started" "$items" "$size" + printf ' %s, ended %s\n' "$count_text" "$ended" + idx=$((idx - 1)) + done + fi + + printf '\nDeletion audit\n' + if [[ "$deletion_count" -eq 0 ]]; then + printf ' No deletion audit entries yet.\n' + else + local start=$((deletion_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + local idx=$((deletion_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + local timestamp="${HISTORY_DELETE_TIMESTAMPS[$idx]}" + local mode="${HISTORY_DELETE_MODES[$idx]}" + local size_kb="${HISTORY_DELETE_SIZE_KB[$idx]}" + local status="${HISTORY_DELETE_STATUSES[$idx]}" + local path="${HISTORY_DELETE_PATHS[$idx]}" + local size_label + size_label=$(history_size_label "$size_kb") + printf ' %-24s %-9s %-16s %8s %s\n' "$timestamp" "$mode" "$status" "$size_label" "$path" + idx=$((idx - 1)) + done + fi + + printf '\nLogs\n' + printf ' operations: %s\n' "$operations_log" + printf ' deletions: %s\n\n' "$deletions_log" +} + +history_render_json_sessions() { + local limit="$1" + local session_count=${#HISTORY_SESSION_COMMANDS[@]} + local start=$((session_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + + printf ' "sessions": [\n' + local emitted=0 + if [[ "$session_count" -gt 0 ]]; then + local idx=$((session_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + [[ "$emitted" -gt 0 ]] && printf ',\n' + printf ' {\n' + history_json_string_field " " "command" "${HISTORY_SESSION_COMMANDS[$idx]}" + history_json_string_field " " "started_at" "${HISTORY_SESSION_STARTED_AT[$idx]}" + history_json_string_field " " "ended_at" "${HISTORY_SESSION_ENDED_AT[$idx]}" + history_json_number_field " " "items" "${HISTORY_SESSION_ITEMS[$idx]}" + history_json_string_field " " "size" "${HISTORY_SESSION_SIZE[$idx]}" + history_json_number_field " " "operation_count" "${HISTORY_SESSION_OPERATIONS[$idx]}" + printf ' "actions": {"removed": %s, "trashed": %s, "skipped": %s, "failed": %s, "rebuilt": %s, "other": %s}\n' \ + "${HISTORY_SESSION_REMOVED[$idx]}" \ + "${HISTORY_SESSION_TRASHED[$idx]}" \ + "${HISTORY_SESSION_SKIPPED[$idx]}" \ + "${HISTORY_SESSION_FAILED[$idx]}" \ + "${HISTORY_SESSION_REBUILT[$idx]}" \ + "${HISTORY_SESSION_OTHER[$idx]}" + printf ' }' + emitted=$((emitted + 1)) + idx=$((idx - 1)) + done + fi + printf '\n ]' +} + +history_render_json_deletions() { + local limit="$1" + local deletion_count=${#HISTORY_DELETE_TIMESTAMPS[@]} + local start=$((deletion_count - limit)) + [[ "$start" -lt 0 ]] && start=0 + + printf ' "deletions": [\n' + local emitted=0 + if [[ "$deletion_count" -gt 0 ]]; then + local idx=$((deletion_count - 1)) + while [[ "$idx" -ge "$start" ]]; do + [[ "$emitted" -gt 0 ]] && printf ',\n' + printf ' {\n' + history_json_string_field " " "timestamp" "${HISTORY_DELETE_TIMESTAMPS[$idx]}" + history_json_string_field " " "mode" "${HISTORY_DELETE_MODES[$idx]}" + history_json_string_field " " "status" "${HISTORY_DELETE_STATUSES[$idx]}" + if [[ "${HISTORY_DELETE_SIZE_KB[$idx]}" =~ ^[0-9]+$ ]]; then + history_json_number_field " " "size_kb" "${HISTORY_DELETE_SIZE_KB[$idx]}" + else + printf ' "size_kb": null,\n' + fi + history_json_string_field " " "path" "${HISTORY_DELETE_PATHS[$idx]}" "" + printf ' }' + emitted=$((emitted + 1)) + idx=$((idx - 1)) + done + fi + printf '\n ]' +} + +history_render_json() { + local limit + limit=$(history_normalize_limit "${1:-$MOLE_HISTORY_DEFAULT_LIMIT}") + + local operations_log deletions_log + operations_log=$(history_operations_log_file) + deletions_log=$(history_deletions_log_file) + + printf '{\n' + printf ' "logs": {"operations": ' + history_json_string "$operations_log" + printf ', "deletions": ' + history_json_string "$deletions_log" + printf '},\n' + printf ' "limit": %s,\n' "$limit" + history_render_json_sessions "$limit" + printf ',\n' + history_render_json_deletions "$limit" + printf '\n}\n' +} diff --git a/Resources/mole/lib/core/log.sh b/Resources/mole/lib/core/log.sh index 95d92ce..139289e 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,10 +142,36 @@ 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 } +# Phase-level performance timing, gated behind MO_DEBUG=1. +# Uses perl for millisecond precision; falls back to date +%s. +debug_timer_start() { + [[ "${MO_DEBUG:-}" != "1" ]] && return 0 + local varname="$1" + local ts + ts=$(perl -MTime::HiRes -e 'printf "%.3f\n", Time::HiRes::time()' 2> /dev/null || date +%s) + # eval: indirect write by name; bash 3.2 has no nameref + eval "$varname=$ts" +} + +debug_timer_end() { + [[ "${MO_DEBUG:-}" != "1" ]] && return 0 + local label="$1" + local start_var="$2" + local start_ts + # eval: indirect read by name; bash 3.2 has no nameref + eval "start_ts=\$$start_var" + [[ -z "$start_ts" ]] && return 0 + local end_ts + end_ts=$(perl -MTime::HiRes -e 'printf "%.3f\n", Time::HiRes::time()' 2> /dev/null || date +%s) + local elapsed + elapsed=$(perl -e "printf '%.3f', $end_ts - $start_ts" 2> /dev/null || echo "$((end_ts - start_ts))") + debug_log "PERF [$label] ${elapsed}s" +} + # ============================================================================ # Operation Logging (Enabled by default) # ============================================================================ @@ -163,7 +205,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 +217,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 +240,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 +256,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 +281,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 +301,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 +352,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" @@ -316,33 +367,6 @@ log_system_info() { echo -e "${GRAY}[DEBUG] Debug logging enabled. Session log: $DEBUG_LOG_FILE${NC}" >&2 } -# ============================================================================ -# Command Execution Wrappers -# ============================================================================ - -# Run command silently (ignore errors) -run_silent() { - "$@" > /dev/null 2>&1 || true -} - -# Run command with error logging -run_logged() { - local cmd="$1" - # Log to main file, and also to debug file if enabled - if [[ "${MO_DEBUG:-}" == "1" ]]; then - if ! "$@" 2>&1 | tee -a "$LOG_FILE" | tee -a "$DEBUG_LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - else - if ! "$@" 2>&1 | tee -a "$LOG_FILE" > /dev/null; then - log_warning "Command failed: $cmd" - return 1 - fi - fi - return 0 -} - # ============================================================================ # Formatted Output # ============================================================================ diff --git a/Resources/mole/lib/core/pkg_receipts.sh b/Resources/mole/lib/core/pkg_receipts.sh new file mode 100644 index 0000000..292690e --- /dev/null +++ b/Resources/mole/lib/core/pkg_receipts.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Mole - pkgutil receipt helpers. +# Finds package-installed app bundles outside the standard app locations. + +set -euo pipefail + +if [[ -n "${MOLE_PKG_RECEIPTS_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_PKG_RECEIPTS_LOADED=1 + +pkg_receipt_nonstandard_app_paths() { + if ! command -v pkgutil > /dev/null 2>&1; then + return 0 + fi + + local cache_file="${MOLE_PKG_RECEIPT_CACHE_FILE:-$HOME/.cache/mole/pkg_receipt_apps_v1}" + local cache_ttl="${MOLE_PKG_RECEIPT_CACHE_TTL:-3600}" + local now_epoch=0 + if declare -f get_epoch_seconds > /dev/null 2>&1; then + now_epoch=$(get_epoch_seconds) + else + now_epoch=$(date +%s 2> /dev/null || echo 0) + fi + + if [[ "${MOLE_PKG_RECEIPT_CACHE_DISABLE:-0}" != "1" && -r "$cache_file" ]]; then + local cache_mtime=0 + if declare -f get_file_mtime > /dev/null 2>&1; then + cache_mtime=$(get_file_mtime "$cache_file") + else + cache_mtime=$(stat -f "%m" "$cache_file" 2> /dev/null || echo 0) + fi + if [[ "$cache_ttl" =~ ^[0-9]+$ && "$cache_mtime" =~ ^[0-9]+$ && + "$now_epoch" =~ ^[0-9]+$ && $cache_ttl -gt 0 && + $((now_epoch - cache_mtime)) -lt $cache_ttl ]]; then + while IFS= read -r cached_app_path; do + [[ -n "$cached_app_path" && -d "$cached_app_path" ]] && printf '%s\n' "$cached_app_path" + done < "$cache_file" + return 0 + fi + fi + + local pkgs_output + if declare -f run_with_timeout > /dev/null 2>&1; then + pkgs_output=$(run_with_timeout "${MOLE_PKG_RECEIPT_LIST_TIMEOUT:-3}" pkgutil --pkgs 2> /dev/null || true) + else + pkgs_output=$(pkgutil --pkgs 2> /dev/null || true) + fi + [[ -n "$pkgs_output" ]] || return 0 + + local -a seen_apps=() + local scan_start=$SECONDS + local scan_timeout="${MOLE_PKG_RECEIPT_SCAN_TIMEOUT:-8}" + local pkg_id + while IFS= read -r pkg_id; do + if [[ "$scan_timeout" =~ ^[0-9]+$ && $scan_timeout -gt 0 && $((SECONDS - scan_start)) -ge $scan_timeout ]]; then + break + fi + + [[ -n "$pkg_id" ]] || continue + [[ "$pkg_id" =~ ^com\.apple\. ]] && continue + + local pkg_files + pkg_files=$(pkgutil --files "$pkg_id" 2> /dev/null | command grep -E '^(/usr/local/|/opt/).*\.app(/|$)' || true) + [[ -n "$pkg_files" ]] || continue + + local rel_path app_path duplicate + while IFS= read -r rel_path; do + if [[ "$scan_timeout" =~ ^[0-9]+$ && $scan_timeout -gt 0 && $((SECONDS - scan_start)) -ge $scan_timeout ]]; then + break 2 + fi + + local stripped="${rel_path#/}" + [[ -n "$stripped" ]] || continue + local candidate="/$stripped" + + case "$candidate" in + /usr/local/*.app) app_path="$candidate" ;; + /opt/*.app) app_path="$candidate" ;; + /usr/local/*.app/*) app_path="${candidate%%.app/*}.app" ;; + /opt/*.app/*) app_path="${candidate%%.app/*}.app" ;; + *) continue ;; + esac + + [[ -n "$app_path" && -d "$app_path" ]] || continue + + duplicate=false + local seen + for seen in "${seen_apps[@]}"; do + if [[ "$seen" == "$app_path" ]]; then + duplicate=true + break + fi + done + [[ "$duplicate" == "true" ]] && continue + + seen_apps+=("$app_path") + done <<< "$pkg_files" + done <<< "$pkgs_output" + + if [[ ${#seen_apps[@]} -gt 0 ]]; then + printf '%s\n' "${seen_apps[@]}" | sort -u + fi + + if [[ "${MOLE_PKG_RECEIPT_CACHE_DISABLE:-0}" != "1" && -n "$cache_file" ]]; then + local cache_dir="${cache_file%/*}" + if [[ -n "$cache_dir" && "$cache_dir" != "$cache_file" ]]; then + if declare -f ensure_user_dir > /dev/null 2>&1; then + ensure_user_dir "$cache_dir" + else + mkdir -p "$cache_dir" 2> /dev/null || true + fi + fi + local cache_tmp + cache_tmp=$(mktemp "${TMPDIR:-/tmp}/mole.pkg_receipts.XXXXXX" 2> /dev/null || true) + if [[ -n "$cache_tmp" ]]; then + if [[ ${#seen_apps[@]} -gt 0 ]]; then + printf '%s\n' "${seen_apps[@]}" | sort -u > "$cache_tmp" + else + : > "$cache_tmp" + fi + mv -f "$cache_tmp" "$cache_file" 2> /dev/null || rm -f "$cache_tmp" 2> /dev/null || true + fi + fi +} diff --git a/Resources/mole/lib/core/sudo.sh b/Resources/mole/lib/core/sudo.sh index 483497d..e43c26c 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 } @@ -103,6 +69,11 @@ _request_password() { request_sudo_access() { local prompt_msg="${1:-Admin access required}" + # 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 + # Check if already have sudo access if sudo -n true 2> /dev/null; then return 0 @@ -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 @@ -223,6 +198,31 @@ request_sudo_access() { return 1 } +request_sudo_access_with_password() { + local password="$1" + local prompt_msg="${2:-Admin access required}" + + # 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 + + if [[ -z "$password" ]]; then + request_sudo_access "$prompt_msg" + return $? + fi + + sudo -k 2> /dev/null + + if printf '%s\n' "$password" | sudo -S -p "" -v > /dev/null 2>&1; then + unset password + return 0 + fi + + unset password + return 1 +} + # ============================================================================ # Sudo Session Management # ============================================================================ @@ -271,9 +271,43 @@ _stop_sudo_keepalive() { # Check if sudo session is active has_sudo_session() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + sudo -n true 2> /dev/null } +adopt_sudo_session() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + MOLE_SUDO_ESTABLISHED="false" + return 1 + fi + + if [[ "$MOLE_SUDO_ESTABLISHED" == "true" && -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then + if has_sudo_session; then + return 0 + fi + _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" + MOLE_SUDO_KEEPALIVE_PID="" + MOLE_SUDO_ESTABLISHED="false" + fi + + if ! sudo -n -v 2> /dev/null; then + MOLE_SUDO_ESTABLISHED="false" + return 1 + fi + + if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then + _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" + MOLE_SUDO_KEEPALIVE_PID="" + fi + + MOLE_SUDO_KEEPALIVE_PID=$(_start_sudo_keepalive) + MOLE_SUDO_ESTABLISHED="true" + return 0 +} + # Request administrative access request_sudo() { local prompt_msg="${1:-Admin access required}" @@ -299,6 +333,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" @@ -318,29 +357,49 @@ ensure_sudo_session() { return 0 } -# Stop sudo session and cleanup -stop_sudo_session() { +ensure_sudo_session_with_password() { + local password="$1" + local prompt="${2:-Admin access required}" + + # Check if already established + if has_sudo_session && [[ "$MOLE_SUDO_ESTABLISHED" == "true" ]]; then + unset password + return 0 + fi + + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + MOLE_SUDO_ESTABLISHED="false" + unset password + return 1 + fi + + # Stop old keepalive if exists if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" MOLE_SUDO_KEEPALIVE_PID="" fi - MOLE_SUDO_ESTABLISHED="false" -} -# Register cleanup on script exit -register_sudo_cleanup() { - trap stop_sudo_session EXIT INT TERM + # Request sudo access + if ! request_sudo_access_with_password "$password" "$prompt"; then + MOLE_SUDO_ESTABLISHED="false" + unset password + return 1 + fi + + unset password + + # Start keepalive + MOLE_SUDO_KEEPALIVE_PID=$(_start_sudo_keepalive) + + MOLE_SUDO_ESTABLISHED="true" + return 0 } -# Predict if operation requires administrative access -will_need_sudo() { - local -a operations=("$@") - for op in "${operations[@]}"; do - case "$op" in - system_update | appstore_update | macos_update | firewall | touchid | rosetta | system_fix) - return 0 - ;; - esac - done - return 1 +# Stop sudo session and cleanup +stop_sudo_session() { + if [[ -n "$MOLE_SUDO_KEEPALIVE_PID" ]]; then + _stop_sudo_keepalive "$MOLE_SUDO_KEEPALIVE_PID" + MOLE_SUDO_KEEPALIVE_PID="" + fi + MOLE_SUDO_ESTABLISHED="false" } diff --git a/Resources/mole/lib/core/timeout.sh b/Resources/mole/lib/core/timeout.sh index edd7051..e5db247 100644 --- a/Resources/mole/lib/core/timeout.sh +++ b/Resources/mole/lib/core/timeout.sh @@ -34,9 +34,9 @@ if [[ -z "${MO_TIMEOUT_INITIALIZED:-}" ]]; then MO_TIMEOUT_PERL_BIN="" for candidate in gtimeout timeout; do if command -v "$candidate" > /dev/null 2>&1; then - MO_TIMEOUT_BIN="$candidate" + MO_TIMEOUT_BIN="$(command -v "$candidate")" if [[ "${MO_DEBUG:-0}" == "1" ]]; then - echo "[TIMEOUT] Using command: $candidate" >&2 + echo "[TIMEOUT] Using command: $MO_TIMEOUT_BIN" >&2 fi break fi @@ -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 @@ -62,6 +67,28 @@ fi # Timeout Execution # ============================================================================ +_mole_cleanup_timeout_killer() { + local killer_pid="${1:-}" + [[ "$killer_pid" =~ ^[0-9]+$ ]] || return 0 + + local child_pids="" + if command -v pgrep > /dev/null 2>&1; then + child_pids=$(pgrep -P "$killer_pid" 2> /dev/null || true) + fi + + kill "$killer_pid" 2> /dev/null || true + + if [[ -n "$child_pids" ]]; then + local child_pid + while IFS= read -r child_pid; do + [[ "$child_pid" =~ ^[0-9]+$ ]] || continue + kill "$child_pid" 2> /dev/null || true + done <<< "$child_pids" + fi + + wait "$killer_pid" 2> /dev/null || true +} + # Run command with timeout # Uses gtimeout/timeout if available, falls back to shell-based implementation # @@ -93,18 +120,29 @@ run_with_timeout() { local duration="${1:-0}" shift || true - # No timeout if duration is invalid or zero - if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ $(echo "$duration <= 0" | bc -l 2> /dev/null) -eq 1 ]]; then + # No timeout if duration is invalid or zero. The regex already forbids a + # leading sign, so "<= 0" reduces to "is zero"; match that in pure bash + # rather than shelling out to bc, which is not guaranteed on macOS. + if [[ ! "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]] || [[ "$duration" =~ ^0+(\.0+)?$ ]]; then "$@" return $? fi # Use timeout command if available (preferred path) if [[ -n "${MO_TIMEOUT_BIN:-}" ]]; then + local timeout_bin="$MO_TIMEOUT_BIN" + if [[ "$timeout_bin" != */* ]]; then + timeout_bin=$(command -v "$timeout_bin" 2> /dev/null || true) + fi + if [[ -z "$timeout_bin" || ! -x "$timeout_bin" ]]; then + timeout_bin="" + fi + fi + if [[ -n "${timeout_bin:-}" ]]; then if [[ "${MO_DEBUG:-0}" == "1" ]]; then echo "[TIMEOUT] Running with ${duration}s timeout: $*" >&2 fi - "$MO_TIMEOUT_BIN" "$duration" "$@" + "$timeout_bin" "$duration" "$@" return $? fi @@ -117,7 +155,7 @@ run_with_timeout() { "$MO_TIMEOUT_PERL_BIN" -e ' use strict; use warnings; - use POSIX qw(:sys_wait_h setsid); + use POSIX qw(:sys_wait_h setpgid); use Time::HiRes qw(time sleep); my $duration = 0 + shift @ARGV; @@ -127,7 +165,13 @@ run_with_timeout() { defined $pid or exit 125; if ($pid == 0) { - setsid() or exit 125; + # New process group, NOT a new session: keep the controlling + # terminal so nested sudo inside the wrapped command can reuse + # the cached credential. setsid() would detach the tty and break + # brew cask uninstall scripts that call sudo (issue #1003). + # setpgid returns 0 on success (falsy in Perl), so it must not be + # guarded with `or exit`; a rare failure only degrades group-kill. + setpgid(0, 0); exec @ARGV; exit 127; } @@ -181,7 +225,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,9 +247,16 @@ 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=$! + local interrupted=0 + local previous_int_trap + previous_int_trap=$(trap -p INT || true) + + # Forward SIGINT to the command while preserving the caller's trap. + trap 'interrupted=1; kill -INT "$cmd_pid" 2>/dev/null || true; _mole_cleanup_timeout_killer "$killer_pid"' INT + # Wait for command to complete local exit_code=0 set +e @@ -210,10 +264,17 @@ run_with_timeout() { exit_code=$? set -e - # Clean up killer process - if kill -0 "$killer_pid" 2> /dev/null; then - kill "$killer_pid" 2> /dev/null || true - wait "$killer_pid" 2> /dev/null || true + if [[ -n "$previous_int_trap" ]]; then + # eval: restore previous trap captured by $(trap -p INT) + eval "$previous_int_trap" + else + trap - INT + fi + + _mole_cleanup_timeout_killer "$killer_pid" + + if [[ $interrupted -eq 1 ]]; then + return 130 fi # Check if command was killed by timeout (exit codes 143=SIGTERM, 137=SIGKILL) diff --git a/Resources/mole/lib/core/timeouts.sh b/Resources/mole/lib/core/timeouts.sh new file mode 100644 index 0000000..c18ac88 --- /dev/null +++ b/Resources/mole/lib/core/timeouts.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Mole - Centralized timeout constants for run_with_timeout calls. +# +# Goal: when someone needs to tune "all quick command-availability probes" +# or "all package-manager cleanup ceilings", they edit ONE place instead +# of grepping 100+ call sites. +# +# Naming: MOLE_TIMEOUT__SEC. All values are seconds (integer or +# fractional). All are overridable via the same-named env var so operators +# can lengthen them for slow disks / cold Spotlight / etc. +# +# Categories (with rationale, not "what they happen to be tuned to"): +# +# QUICK_DETECT command -v + version-check style probes. Should fail +# fast when the tool is missing or wedged. ~2s. +# SHORT_QUERY Lightweight subprocess query (df, tmutil status). ~3s. +# MEDIUM_PROBE Heavier probe that occasionally talks to the network +# or scans a directory tree. ~5s. +# PKG_LIST Package manager listing (brew list, simctl list). ~10s. +# PKG_CLEANUP Cache cleanup commands that walk disks. ~20s. +# DISK_VERIFY Filesystem-level verify/repair operations. ~30s. +# HINT_SCAN Non-destructive hint that walks an unbounded user +# directory tree (project-artifact discovery). Per-listing +# finds are already capped; this is the cumulative wall-clock +# ceiling for the whole walk so it can never appear hung. ~15s. +# +# Migration: new code should use these constants. Existing call sites can +# be migrated incrementally; the script `grep 'run_with_timeout [0-9]'` lists +# remaining literal-timeout calls. +# +# Intentionally NOT in this table (values that appear hardcoded in lib/): +# +# 1s Volume/filesystem type probes that should be near-instant on a +# healthy disk: `df -T`, `diskutil info`, `find -maxdepth 1`. A +# wedge here usually means the volume itself is sick; failing fast +# is the right behavior. +# 8s External tool calls that are too slow for MEDIUM_PROBE (5s) but +# shouldn't pay the PKG_LIST (10s) ceiling: `hdiutil info`, +# `brew outdated`, `simctl list` warm-up retry. Also the deep +# `find /private/var/folders -maxdepth 8` GPU-cache scan in +# lib/clean/system.sh - same "occasionally slow disk probe" shape. +# 15s Long-running maintenance ops on user-selected targets: +# `hdiutil detach`, `lsregister -r -f`, Time Machine backupdb +# `find`. Different shape from PKG_CLEANUP (20s, brew/conda) - +# keep them apart so tuning one doesn't move the other. +# 0.2s Per-app inline mdls probe in the uninstall scan tight loop. Tens +# to hundreds of invocations per scan; bucket constants would +# imply this is reusable elsewhere, which it isn't. +# +# If you find yourself adding a new use of one of these literals, consider +# whether a bucket actually exists for it before copying the magic number. + +set -euo pipefail + +if [[ -n "${MOLE_TIMEOUTS_LOADED:-}" ]]; then + return 0 +fi +readonly MOLE_TIMEOUTS_LOADED=1 + +readonly MOLE_TIMEOUT_QUICK_DETECT_SEC="${MOLE_TIMEOUT_QUICK_DETECT_SEC:-2}" +readonly MOLE_TIMEOUT_SHORT_QUERY_SEC="${MOLE_TIMEOUT_SHORT_QUERY_SEC:-3}" +readonly MOLE_TIMEOUT_MEDIUM_PROBE_SEC="${MOLE_TIMEOUT_MEDIUM_PROBE_SEC:-5}" +readonly MOLE_TIMEOUT_PKG_LIST_SEC="${MOLE_TIMEOUT_PKG_LIST_SEC:-10}" +readonly MOLE_TIMEOUT_PKG_CLEANUP_SEC="${MOLE_TIMEOUT_PKG_CLEANUP_SEC:-20}" +readonly MOLE_TIMEOUT_DISK_VERIFY_SEC="${MOLE_TIMEOUT_DISK_VERIFY_SEC:-30}" +readonly MOLE_TIMEOUT_HINT_SCAN_SEC="${MOLE_TIMEOUT_HINT_SCAN_SEC:-15}" diff --git a/Resources/mole/lib/core/ui.sh b/Resources/mole/lib/core/ui.sh index 421d29a..bcd1ec5 100755 --- a/Resources/mole/lib/core/ui.sh +++ b/Resources/mole/lib/core/ui.sh @@ -207,6 +207,7 @@ read_key() { fi ;; ' ') echo "SPACE" ;; # Allow space in filter mode for selection + $'\x03') echo "QUIT" ;; [[:print:]]) echo "CHAR:$key" ;; *) echo "OTHER" ;; esac @@ -223,12 +224,25 @@ 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" ;; 'k' | 'K') echo "UP" ;; 'h' | 'H') echo "LEFT" ;; 'l' | 'L') echo "RIGHT" ;; + 'G') echo "BOTTOM" ;; + 'g') + if IFS= read -r -s -n 1 -t 0.3 rest 2> /dev/null; then + if [[ "$rest" == "g" ]]; then + echo "TOP" + else + echo "OTHER" + fi + else + echo "OTHER" + fi + ;; $'\x03') echo "QUIT" ;; $'\x7f' | $'\x08') echo "DELETE" ;; $'\x15') echo "CLEAR_LINE" ;; # Ctrl+U @@ -263,11 +277,14 @@ read_key() { } drain_pending_input() { + local idle_timeout="${1:-0.01}" local drained=0 - while IFS= read -r -s -n 1 -t 0.01 _ 2> /dev/null; do + while IFS= read -r -s -n 1 -t "$idle_timeout" _ 2> /dev/null; do drained=$((drained + 1)) [[ $drained -gt 100 ]] && break + idle_timeout="0.01" done + return 0 } # Format menu option display @@ -324,7 +341,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 +360,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 +384,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 @@ -389,9 +407,7 @@ stop_inline_spinner() { # Get spinner characters mo_spinner_chars() { - local chars="|/-\\" - [[ -z "$chars" ]] && chars="|/-\\" - printf "%s" "$chars" + printf "%s" "|/-\\" } # Format relative time for compact display (e.g., 3d ago) diff --git a/Resources/mole/lib/manage/autofix.sh b/Resources/mole/lib/manage/autofix.sh deleted file mode 100644 index eb76fb4..0000000 --- a/Resources/mole/lib/manage/autofix.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/bin/bash -# Auto-fix Manager -# Unified auto-fix suggestions and execution - -set -euo pipefail - -# Show system suggestions with auto-fix markers -show_suggestions() { - local has_suggestions=false - local can_auto_fix=false - local -a auto_fix_items=() - local -a manual_items=() - local skip_security_autofix=false - if [[ "${MOLE_SECURITY_FIXES_SHOWN:-}" == "true" ]]; then - skip_security_autofix=true - fi - - # Security suggestions - if [[ "$skip_security_autofix" == "false" && -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then - auto_fix_items+=("Enable Firewall for better security") - has_suggestions=true - can_auto_fix=true - fi - - if [[ -n "${FILEVAULT_DISABLED:-}" && "${FILEVAULT_DISABLED}" == "true" ]]; then - manual_items+=("Enable FileVault|System Settings → Privacy & Security → FileVault") - has_suggestions=true - fi - - # Configuration suggestions - if [[ "$skip_security_autofix" == "false" && -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then - auto_fix_items+=("Enable Touch ID for sudo") - has_suggestions=true - can_auto_fix=true - fi - - if [[ -n "${ROSETTA_NOT_INSTALLED:-}" && "${ROSETTA_NOT_INSTALLED}" == "true" ]]; then - auto_fix_items+=("Install Rosetta 2 for Intel app support") - has_suggestions=true - can_auto_fix=true - fi - - # Health suggestions - if [[ -n "${CACHE_SIZE_GB:-}" ]]; then - local cache_gb="${CACHE_SIZE_GB:-0}" - if (($(echo "$cache_gb > 5" | bc -l 2> /dev/null || echo 0))); then - manual_items+=("Free up ${cache_gb}GB by cleaning caches|Run: mo clean") - has_suggestions=true - fi - fi - - if [[ -n "${BREW_HAS_WARNINGS:-}" && "${BREW_HAS_WARNINGS}" == "true" ]]; then - manual_items+=("Fix Homebrew warnings|Run: brew doctor to see details") - has_suggestions=true - fi - - if [[ -n "${DISK_FREE_GB:-}" && "${DISK_FREE_GB:-0}" -lt 50 ]]; then - if [[ -z "${CACHE_SIZE_GB:-}" ]] || (($(echo "${CACHE_SIZE_GB:-0} <= 5" | bc -l 2> /dev/null || echo 1))); then - manual_items+=("Low disk space, ${DISK_FREE_GB}GB free|Run: mo analyze to find large files") - has_suggestions=true - fi - fi - - # Display suggestions - echo -e "${BLUE}${ICON_ARROW}${NC} Suggestions" - - if [[ "$has_suggestions" == "false" ]]; then - echo -e " ${GREEN}✓${NC} All looks good" - export HAS_AUTO_FIX_SUGGESTIONS="false" - return - fi - - # Show auto-fix items - if [[ ${#auto_fix_items[@]} -gt 0 ]]; then - for item in "${auto_fix_items[@]}"; do - echo -e " ${GRAY}${ICON_WARNING}${NC} ${item} ${GREEN}[auto]${NC}" - done - fi - - # Show manual items - if [[ ${#manual_items[@]} -gt 0 ]]; then - for item in "${manual_items[@]}"; do - local title="${item%%|*}" - local hint="${item#*|}" - echo -e " ${GRAY}${ICON_WARNING}${NC} ${title}" - echo -e " ${GRAY}${hint}${NC}" - done - fi - - # Export for use in auto-fix - export HAS_AUTO_FIX_SUGGESTIONS="$can_auto_fix" -} - -# Ask user if they want to auto-fix -# Returns: 0 if yes, 1 if no -ask_for_auto_fix() { - if [[ "${HAS_AUTO_FIX_SUGGESTIONS:-false}" != "true" ]]; then - return 1 - fi - - echo -ne "${PURPLE}${ICON_ARROW}${NC} Auto-fix issues now? ${GRAY}Enter confirm / Space cancel${NC}: " - - local key - if ! key=$(read_key); then - echo "no" - echo "" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "yes" - echo "" - return 0 - else - echo "no" - echo "" - return 1 - fi -} - -# Perform auto-fixes -# Returns: number of fixes applied -perform_auto_fix() { - local fixed_count=0 - local -a fixed_items=() - - # Ensure sudo access - if ! has_sudo_session; then - if ! ensure_sudo_session "System fixes require admin access"; then - echo -e "${YELLOW}Skipping auto fixes, admin authentication required${NC}" - echo "" - return 0 - fi - fi - - # Fix Firewall - if [[ -n "${FIREWALL_DISABLED:-}" && "${FIREWALL_DISABLED}" == "true" ]]; then - echo -e "${BLUE}Enabling Firewall...${NC}" - if sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on > /dev/null 2>&1; then - echo -e "${GREEN}✓${NC} Firewall enabled" - fixed_count=$((fixed_count + 1)) - fixed_items+=("Firewall enabled") - else - echo -e "${RED}✗${NC} Failed to enable Firewall" - fi - echo "" - fi - - # Fix Touch ID - if [[ -n "${TOUCHID_NOT_CONFIGURED:-}" && "${TOUCHID_NOT_CONFIGURED}" == "true" ]]; then - echo -e "${BLUE}${ICON_ARROW}${NC} Configuring Touch ID for sudo..." - local pam_file="/etc/pam.d/sudo" - if sudo bash -c "grep -q 'pam_tid.so' '$pam_file' 2>/dev/null || sed -i '' '2i\\ -auth sufficient pam_tid.so -' '$pam_file'" 2> /dev/null; then - echo -e "${GREEN}✓${NC} Touch ID configured" - fixed_count=$((fixed_count + 1)) - fixed_items+=("Touch ID configured for sudo") - else - echo -e "${RED}✗${NC} Failed to configure Touch ID" - fi - echo "" - fi - - # Install Rosetta 2 - if [[ -n "${ROSETTA_NOT_INSTALLED:-}" && "${ROSETTA_NOT_INSTALLED}" == "true" ]]; then - echo -e "${BLUE}Installing Rosetta 2...${NC}" - if sudo softwareupdate --install-rosetta --agree-to-license 2>&1 | grep -qE "(Installing|Installed|already installed)"; then - echo -e "${GREEN}✓${NC} Rosetta 2 installed" - fixed_count=$((fixed_count + 1)) - fixed_items+=("Rosetta 2 installed") - else - echo -e "${RED}✗${NC} Failed to install Rosetta 2" - fi - echo "" - fi - - if [[ $fixed_count -gt 0 ]]; then - AUTO_FIX_SUMMARY="Auto fixes applied: ${fixed_count} issues" - if [[ ${#fixed_items[@]} -gt 0 ]]; then - AUTO_FIX_DETAILS=$(printf '%s\n' "${fixed_items[@]}") - else - AUTO_FIX_DETAILS="" - fi - else - AUTO_FIX_SUMMARY="Auto fixes skipped: No changes were required" - AUTO_FIX_DETAILS="" - fi - export AUTO_FIX_SUMMARY AUTO_FIX_DETAILS - return 0 -} 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/update.sh b/Resources/mole/lib/manage/update.sh deleted file mode 100644 index 2dc4027..0000000 --- a/Resources/mole/lib/manage/update.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/bin/bash -# Update Manager -# Unified update execution for all update types - -set -euo pipefail - -# Format Homebrew update details for display -format_brew_update_detail() { - local total="${BREW_OUTDATED_COUNT:-0}" - if [[ -z "$total" || "$total" -le 0 ]]; then - return - fi - - local -a details=() - local formulas="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local casks="${BREW_CASK_OUTDATED_COUNT:-0}" - - ((formulas > 0)) && details+=("${formulas} formula") - ((casks > 0)) && details+=("${casks} cask") - - local detail_str="${total} updates" - if ((${#details[@]} > 0)); then - detail_str="$( - IFS=', ' - printf '%s' "${details[*]}" - )" - fi - printf "%s" "$detail_str" -} - -# Keep for compatibility with existing callers/tests. -format_brew_update_label() { - local detail - detail=$(format_brew_update_detail || true) - [[ -n "$detail" ]] && printf "Homebrew, %s" "$detail" -} - -populate_brew_update_counts_if_unset() { - local need_probe=false - [[ -z "${BREW_OUTDATED_COUNT:-}" ]] && need_probe=true - [[ -z "${BREW_FORMULA_OUTDATED_COUNT:-}" ]] && need_probe=true - [[ -z "${BREW_CASK_OUTDATED_COUNT:-}" ]] && need_probe=true - - if [[ "$need_probe" == "false" ]]; then - return 0 - fi - - local formula_count="${BREW_FORMULA_OUTDATED_COUNT:-0}" - local cask_count="${BREW_CASK_OUTDATED_COUNT:-0}" - - if command -v brew > /dev/null 2>&1; then - local formula_outdated="" - local cask_outdated="" - - formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null || true) - cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null || true) - - 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}') - fi - - BREW_FORMULA_OUTDATED_COUNT="$formula_count" - BREW_CASK_OUTDATED_COUNT="$cask_count" - BREW_OUTDATED_COUNT="$((formula_count + cask_count))" -} - -brew_has_outdated() { - local kind="${1:-formula}" - command -v brew > /dev/null 2>&1 || return 1 - - if [[ "$kind" == "cask" ]]; then - brew outdated --cask --quiet 2> /dev/null | grep -q . - else - brew outdated --quiet 2> /dev/null | grep -q . - fi -} - -# Ask user if they want to update -# Returns: 0 if yes, 1 if no -ask_for_updates() { - populate_brew_update_counts_if_unset - - local has_updates=false - if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then - has_updates=true - fi - - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - has_updates=true - fi - - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true - fi - - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - has_updates=true - fi - - if [[ "$has_updates" == "false" ]]; then - return 1 - fi - - if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then - echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " - - local key - if ! key=$(read_key); then - echo "skip" - return 1 - fi - - if [[ "$key" == "ENTER" ]]; then - echo "yes" - return 0 - fi - fi - - if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then - echo -e " ${GRAY}${ICON_REVIEW}${NC} Run ${GREEN}brew upgrade${NC} to update" - fi - if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then - echo -e " ${GRAY}${ICON_REVIEW}${NC} Open ${GREEN}System Settings${NC} → ${GREEN}General${NC} → ${GREEN}Software Update${NC}" - fi - if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then - echo -e " ${GRAY}${ICON_REVIEW}${NC} Open ${GREEN}App Store${NC} → ${GREEN}Updates${NC}" - fi - - return 1 -} - -# Perform all pending updates -# Returns: 0 if all succeeded, 1 if some failed -perform_updates() { - # Only handle Mole updates here; Homebrew/App Store/macOS are manual (tips shown in ask_for_updates) - local updated_count=0 - local total_count=0 - - if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then - echo -e "${BLUE}Updating Mole...${NC}" - local mole_bin="${SCRIPT_DIR}/../../mole" - [[ ! -f "$mole_bin" ]] && mole_bin=$(command -v mole 2> /dev/null || echo "") - - if [[ -x "$mole_bin" ]]; then - if "$mole_bin" update 2>&1 | grep -qE "(Updated|latest version)"; then - echo -e "${GREEN}${ICON_SUCCESS}${NC} Mole updated" - reset_mole_cache - updated_count=$((updated_count + 1)) - else - echo -e "${RED}✗${NC} Mole update failed" - fi - else - echo -e "${RED}✗${NC} Mole executable not found" - fi - echo "" - total_count=1 - fi - - if [[ $total_count -eq 0 ]]; then - echo -e "${GRAY}No updates to perform${NC}" - return 0 - elif [[ $updated_count -eq $total_count ]]; then - echo -e "${GREEN}All updates completed, ${updated_count}/${total_count}${NC}" - return 0 - else - echo -e "${RED}Update failed, ${updated_count}/${total_count}${NC}" - return 1 - fi -} diff --git a/Resources/mole/lib/manage/whitelist.sh b/Resources/mole/lib/manage/whitelist.sh index 41259ac..7aaba3c 100755 --- a/Resources/mole/lib/manage/whitelist.sh +++ b/Resources/mole/lib/manage/whitelist.sh @@ -78,10 +78,10 @@ get_all_cache_items() { # Format: "display_name|pattern|category" cat << 'EOF' Apple Mail cache|$HOME/Library/Caches/com.apple.mail/*|system_cache -Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/*|ide_cache +Gradle build cache (Android Studio, Gradle projects)|$HOME/.gradle/caches/build-cache-*/*|ide_cache Gradle daemon processes cache|$HOME/.gradle/daemon/*|ide_cache +Gradle worker cache|$HOME/.gradle/workers/*|ide_cache Xcode DerivedData (build outputs, indexes)|$HOME/Library/Developer/Xcode/DerivedData/*|ide_cache -Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide_cache Xcode internal cache files|$HOME/Library/Caches/com.apple.dt.Xcode/*|ide_cache Xcode iOS device support symbols|$HOME/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*|ide_cache Maven local repository (Java dependencies)|$HOME/.m2/repository/*|ide_cache @@ -120,13 +120,15 @@ 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 +Conda package metadata/tarball cache|$HOME/.conda/pkgs|package_manager +Anaconda package metadata/tarball cache|$HOME/anaconda3/pkgs|package_manager PyTorch model cache|$HOME/.cache/torch/*|ai_ml_cache TensorFlow model and dataset cache|$HOME/.cache/tensorflow/*|ai_ml_cache HuggingFace models and datasets|$HOME/.cache/huggingface/*|ai_ml_cache @@ -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" @@ -155,17 +159,28 @@ EOF get_optimize_whitelist_items() { # Format: "display_name|pattern|category" cat << 'EOF' -macOS Firewall check|firewall|security_check -Gatekeeper check|gatekeeper|security_check -macOS system updates check|check_macos_updates|update_check -Mole updates check|check_mole_update|update_check -Homebrew health check (doctor)|check_brew_health|health_check -SIP status check|check_sip|security_check -FileVault status check|check_filevault|security_check -TouchID sudo check|check_touchid|config_check -Rosetta 2 check|check_rosetta|config_check -Git configuration check|check_git_config|config_check -Login items check|check_login_items|config_check +DNS & Spotlight Check|system_maintenance|optimize_task +Finder Cache Refresh|cache_refresh|optimize_task +App State Cleanup|saved_state_cleanup|optimize_task +Broken Config Repair|fix_broken_configs|optimize_task +Network Cache Refresh|network_optimization|optimize_task +Database Optimization|sqlite_vacuum|optimize_task +LaunchServices Repair|launch_services_rebuild|optimize_task +Dock Refresh|dock_refresh|optimize_task +Prevent Finder .DS_Store|prevent_network_dsstore|optimize_task +Memory Optimization|memory_pressure_relief|optimize_task +Network Stack Refresh|network_stack_optimize|optimize_task +Permission Repair|disk_permissions_repair|optimize_task +Spotlight Optimization|spotlight_index_optimize|optimize_task +Spotlight Orphan Rules|spotlight_orphan_rules_cleanup|optimize_task +Periodic Maintenance|periodic_maintenance|optimize_task +Shared File Lists|shared_file_list_repair|optimize_task +Disk Health|disk_verify|optimize_task +Login Items Audit|login_items_audit|optimize_task +Quarantine Database Cleanup|quarantine_cleanup|optimize_task +Launch Agents Cleanup|launch_agents_cleanup|optimize_task +Notifications|notification_cleanup|optimize_task +Usage Data|coreduet_cleanup|optimize_task EOF } @@ -207,10 +222,18 @@ load_whitelist() { patterns+=("$line") done < "$config_file" else + # bash 3.2 (default on macOS) raises "unbound variable" under set -u + # when expanding "${arr[@]}" on an empty array, so gate each branch + # on the array length. patterns stays the local empty default when a + # default list is empty, which the downstream dedupe loop handles. if [[ "$mode" == "clean" ]]; then - patterns=("${DEFAULT_WHITELIST_PATTERNS[@]}") + if [[ ${#DEFAULT_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + patterns=("${DEFAULT_WHITELIST_PATTERNS[@]}") + fi elif [[ "$mode" == "optimize" ]]; then - patterns=("${DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]}") + if [[ ${#DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]} -gt 0 ]]; then + patterns=("${DEFAULT_OPTIMIZE_WHITELIST_PATTERNS[@]}") + fi fi fi @@ -230,6 +253,7 @@ load_whitelist() { unique_patterns+=("$pattern") done CURRENT_WHITELIST_PATTERNS=("${unique_patterns[@]}") + WHITELIST_PATTERNS=("${unique_patterns[@]}") # Migrate legacy optimize config to the new path automatically if [[ "$mode" == "optimize" && "$using_legacy" == "true" && "$config_file" != "$WHITELIST_CONFIG_OPTIMIZE" ]]; then @@ -237,6 +261,7 @@ load_whitelist() { fi else CURRENT_WHITELIST_PATTERNS=() + WHITELIST_PATTERNS=() fi } @@ -284,7 +309,7 @@ manage_whitelist_categories() { items_source=$(get_optimize_whitelist_items) active_config_file="$WHITELIST_CONFIG_OPTIMIZE" local display_config="${active_config_file/#$HOME/~}" - menu_title="Whitelist Manager, Select system checks to ignore + menu_title="Whitelist Manager, Select optimize tasks to ignore ${GRAY}Edit: ${display_config}${NC}" else items_source=$(get_all_cache_items) @@ -368,13 +393,13 @@ ${GRAY}Edit: ${display_config}${NC}" fi MOLE_SELECTION_RESULT="" - paginated_multi_select "$menu_title" "${menu_options[@]}" + local exit_code=0 + paginated_multi_select "$menu_title" "${menu_options[@]}" || exit_code=$? unset MOLE_PRESELECTED_INDICES - local exit_code=$? - # Normal exit or cancel if [[ $exit_code -ne 0 ]]; then - return 1 + echo -e "${GRAY}Cancelled, no changes saved${NC}" + return 0 fi # Convert selected indices to patterns diff --git a/Resources/mole/lib/optimize/diagnostics.sh b/Resources/mole/lib/optimize/diagnostics.sh new file mode 100644 index 0000000..e139ae0 --- /dev/null +++ b/Resources/mole/lib/optimize/diagnostics.sh @@ -0,0 +1,426 @@ +#!/bin/bash +# Optimize performance diagnosis helpers. + +set -euo pipefail + +readonly MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD_DEFAULT=25 +readonly MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY_DEFAULT=1 + +opt_diag_cpu_threshold() { + local threshold="${MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD:-$MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD_DEFAULT}" + if ! [[ "$threshold" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + threshold="$MOLE_OPTIMIZE_DIAG_CPU_THRESHOLD_DEFAULT" + fi + printf '%s\n' "$threshold" +} + +opt_diag_sample_delay() { + local delay="${MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY:-$MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY_DEFAULT}" + if ! [[ "$delay" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + delay="$MOLE_OPTIMIZE_DIAG_SAMPLE_DELAY_DEFAULT" + fi + printf '%s\n' "$delay" +} + +opt_diag_float_ge() { + local left="${1:-0}" + local right="${2:-0}" + awk -v left="$left" -v right="$right" 'BEGIN { exit !((left + 0) >= (right + 0)) }' +} + +opt_diag_float_gt() { + local left="${1:-0}" + local right="${2:-0}" + awk -v left="$left" -v right="$right" 'BEGIN { exit !((left + 0) > (right + 0)) }' +} + +opt_diag_float_avg() { + local left="${1:-0}" + local right="${2:-0}" + awk -v left="$left" -v right="$right" 'BEGIN { printf "%.1f\n", ((left + 0) + (right + 0)) / 2 }' +} + +opt_diag_get_ps_sample() { + local index="$1" + local override="" + + case "$index" in + 1) override="${MOLE_OPTIMIZE_PS_SAMPLE_1:-}" ;; + 2) override="${MOLE_OPTIMIZE_PS_SAMPLE_2:-}" ;; + esac + + if [[ -n "$override" ]]; then + printf '%s\n' "$override" + return 0 + fi + + ps -Aceo pcpu=,command= 2> /dev/null || true +} + +opt_diag_get_spctl_status() { + if [[ -n "${MOLE_OPTIMIZE_SPCTL_STATUS:-}" ]]; then + printf '%s\n' "$MOLE_OPTIMIZE_SPCTL_STATUS" + return 0 + fi + + spctl --status 2> /dev/null || true +} + +opt_diag_get_hdiutil_info() { + if [[ -n "${MOLE_OPTIMIZE_HDIUTIL_INFO:-}" ]]; then + printf '%s\n' "$MOLE_OPTIMIZE_HDIUTIL_INFO" + return 0 + fi + + run_with_timeout 8 hdiutil info 2> /dev/null || true # 8s: hdiutil info, see lib/core/timeouts.sh +} + +opt_diag_family_totals() { + local raw="${1:-}" + awk ' + function classify(cmd, lower) { + lower = tolower(cmd) + if (lower ~ /cloudshell/ || lower ~ /alientsafe/ || lower ~ /aliedr/) return "cloudshell" + if (lower ~ /(^|\/)syspolicyd([[:space:]]|$)/) return "syspolicyd" + if (lower ~ /(^|\/)windowserver([[:space:]]|$)/) return "windowserver" + if (lower ~ /(^|\/)mds([[:space:]]|$)/ || lower ~ /mdworker/ || lower ~ /mds_stores/ || lower ~ /mdbulkimport/) return "spotlight" + if (lower ~ /diskimagesiod/ || lower ~ /simdiskimaged/) return "coresim_disk_images" + return "" + } + { + cpu = $1 + 0 + $1 = "" + sub(/^[[:space:]]+/, "", $0) + family = classify($0) + if (family != "") sums[family] += cpu + } + END { + printf "cloudshell\t%.1f\n", sums["cloudshell"] + 0 + printf "syspolicyd\t%.1f\n", sums["syspolicyd"] + 0 + printf "windowserver\t%.1f\n", sums["windowserver"] + 0 + printf "spotlight\t%.1f\n", sums["spotlight"] + 0 + printf "coresim_disk_images\t%.1f\n", sums["coresim_disk_images"] + 0 + } + ' <<< "$raw" +} + +opt_diag_family_total_for() { + local totals="${1:-}" + local family="$2" + awk -F '\t' -v family="$family" '$1 == family { print $2; found = 1; exit } END { if (!found) print "0.0" }' <<< "$totals" +} + +opt_diag_family_label() { + case "$1" in + cloudshell) printf '%s\n' "CloudShell / AliEntSafe" ;; + syspolicyd) printf '%s\n' "syspolicyd" ;; + windowserver) printf '%s\n' "WindowServer" ;; + spotlight) printf '%s\n' "Spotlight indexing" ;; + coresim_disk_images) printf '%s\n' "CoreSimulator disk images" ;; + *) printf '%s\n' "$1" ;; + esac +} + +opt_diag_family_note() { + case "$1" in + cloudshell) + printf '%s\n' "External enterprise agent pressure detected. Mole will not terminate enterprise security processes; restart or policy checks must happen outside Mole." + ;; + syspolicyd) + printf '%s\n' "Gatekeeper and code-signature assessment activity is elevated." + ;; + windowserver) + printf '%s\n' "Desktop composition is busy. When another family is higher, treat this as a likely symptom rather than the root cause." + ;; + spotlight) + printf '%s\n' "Metadata indexing or import work is consuming CPU." + ;; + coresim_disk_images) + printf '%s\n' "Simulator runtime disk-image services are active." + ;; + *) + printf '%s\n' "" + ;; + esac +} + +opt_diag_parse_image_mount_pairs() { + local info="${1:-}" + awk ' + function extract_mount(line) { + # Only /dev/disk* lines list real mount points. Other fields like + # image-alias / icon-path / shadow-path may contain absolute paths + # but are not mounts and previously produced phantom detach offers. + if (line !~ /^\/dev\/disk/) { + return "" + } + if (line ~ /[[:space:]]\/.*/) { + sub(/^.*[[:space:]]\//, "/", line) + return line + } + return "" + } + function flush_block( i) { + if (image == "") { + mount_count = 0 + delete mounts + return + } + for (i = 1; i <= mount_count; i++) { + if (mounts[i] != "") { + printf "%s\t%s\n", image, mounts[i] + } + } + mount_count = 0 + delete mounts + } + /^=+$/ { + flush_block() + image = "" + next + } + /^image-path[[:space:]]*:/ { + image = $0 + sub(/^image-path[[:space:]]*:[[:space:]]*/, "", image) + next + } + { + mount = extract_mount($0) + if (mount ~ /^\//) { + mounts[++mount_count] = mount + } + } + END { + flush_block() + } + ' <<< "$info" +} + +opt_diag_is_system_managed_mount() { + local image_path="$1" + local mount_path="$2" + + case "$image_path" in + /System/* | /Library/Apple/* | /private/var/run/com.apple.security.cryptexd/*) + return 0 + ;; + esac + + case "$mount_path" in + /Library/Developer/CoreSimulator/Volumes/* | /private/var/run/com.apple.security.cryptexd/*) + return 0 + ;; + esac + + return 1 +} + +opt_diag_is_mount_detach_candidate() { + local image_path="$1" + local mount_path="$2" + + if opt_diag_is_system_managed_mount "$image_path" "$mount_path"; then + return 1 + fi + + case "$mount_path" in + /Volumes/*) ;; + *) return 1 ;; + esac + + case "$image_path" in + *.dmg | *.iso | *.img | *.cdr | *.sparseimage | *.sparsebundle) ;; + *) return 1 ;; + esac + + if should_protect_path "$mount_path" || is_path_whitelisted "$mount_path"; then + return 1 + fi + if [[ -n "$image_path" ]] && (should_protect_path "$image_path" || is_path_whitelisted "$image_path"); then + return 1 + fi + + return 0 +} + +opt_diag_collect_detach_candidates() { + local pairs="${1:-}" + local image_path mount_path + + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$image_path" || -z "$mount_path" ]] && continue + if opt_diag_is_mount_detach_candidate "$image_path" "$mount_path"; then + printf '%s\t%s\n' "$image_path" "$mount_path" + fi + done <<< "$pairs" +} + +opt_diag_count_matches() { + local pairs="${1:-}" + local mode="$2" + local image_path mount_path count=0 + + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$image_path" || -z "$mount_path" ]] && continue + case "$mode" in + system_managed) + if opt_diag_is_system_managed_mount "$image_path" "$mount_path"; then + count=$((count + 1)) + fi + ;; + coresim_only) + if [[ "$mount_path" == /Library/Developer/CoreSimulator/Volumes/* ]]; then + count=$((count + 1)) + fi + ;; + esac + done <<< "$pairs" + + printf '%s\n' "$count" +} + +opt_diag_detach_candidates() { + local candidates="${1:-}" + local detached=0 + local failed=0 + local image_path mount_path + + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$mount_path" ]] && continue + if run_with_timeout 15 hdiutil detach "$mount_path" > /dev/null 2>&1; then # 15s: hdiutil detach, see lib/core/timeouts.sh + detached=$((detached + 1)) + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Detached ${mount_path}" + else + failed=$((failed + 1)) + echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to detach ${mount_path}" + fi + done <<< "$candidates" + + if [[ $detached -gt 0 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Detached ${detached} mounted image(s)" + fi + if [[ $failed -gt 0 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} ${failed} mounted image(s) still need manual review" + fi +} + +opt_diag_offer_detach_candidates() { + local candidates="${1:-}" + [[ -z "$candidates" ]] && return 0 + + local count=0 + local image_path mount_path + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$mount_path" ]] && continue + count=$((count + 1)) + done <<< "$candidates" + + echo -e " ${GRAY}${ICON_LIST}${NC} Mounted image detach candidates:" + while IFS=$'\t' read -r image_path mount_path; do + [[ -z "$mount_path" ]] && continue + echo -e " ${GRAY}${mount_path}${NC} ← ${image_path}" + done <<< "$candidates" + echo -e " ${GRAY}${ICON_SUBLIST} Keep one of these mounted: add its path via ${NC}mo optimize --whitelist${GRAY}${NC}" + + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Would offer detach for ${count} mounted image(s)" + return 0 + fi + + if [[ ! -t 1 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Review these mounted images and detach any you no longer need" + return 0 + fi + + echo -ne " ${GRAY}${ICON_REVIEW}${NC} ${YELLOW}Detach now?${NC} ${GRAY}Enter confirm / Space cancel${NC}: " + local key="" + if ! key=$(read_key); then + echo -e "\n ${GRAY}${ICON_WARNING}${NC} Mounted image detach skipped" + return 0 + fi + + if [[ "$key" == "ENTER" ]]; then + echo "" + opt_diag_detach_candidates "$candidates" + else + echo -e "\n ${GRAY}${ICON_WARNING}${NC} Mounted image detach skipped" + fi +} + +run_optimize_diagnostics() { + local sample1 sample2 totals1 totals2 threshold delay + sample1=$(opt_diag_get_ps_sample 1) + delay=$(opt_diag_sample_delay) + if [[ -z "${MOLE_OPTIMIZE_PS_SAMPLE_1:-}" || -z "${MOLE_OPTIMIZE_PS_SAMPLE_2:-}" ]]; then + sleep "$delay" + fi + sample2=$(opt_diag_get_ps_sample 2) + totals1=$(opt_diag_family_totals "$sample1") + totals2=$(opt_diag_family_totals "$sample2") + threshold=$(opt_diag_cpu_threshold) + + echo "" + echo -e "${BLUE}PERFORMANCE DIAGNOSIS${NC}" + + local families="cloudshell syspolicyd windowserver spotlight coresim_disk_images" + local sustained_count=0 + local primary_family="" + local primary_avg="0.0" + local sustained_details="" + local family cpu1 cpu2 avg label + + for family in $families; do + cpu1=$(opt_diag_family_total_for "$totals1" "$family") + cpu2=$(opt_diag_family_total_for "$totals2" "$family") + if opt_diag_float_ge "$cpu1" "$threshold" && opt_diag_float_ge "$cpu2" "$threshold"; then + avg=$(opt_diag_float_avg "$cpu1" "$cpu2") + label=$(opt_diag_family_label "$family") + sustained_count=$((sustained_count + 1)) + sustained_details+="${family}"$'\t'"${avg}"$'\t'"${label}"$'\n' + if [[ -z "$primary_family" ]] || opt_diag_float_gt "$avg" "$primary_avg"; then + primary_family="$family" + primary_avg="$avg" + fi + fi + done + + if [[ -z "$primary_family" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} No obvious sustained high-CPU bottleneck detected" + else + label=$(opt_diag_family_label "$primary_family") + echo -e " ${YELLOW}${ICON_WARNING}${NC} Likely bottleneck: ${label} (~${primary_avg}% CPU sustained)" + echo -e " ${GRAY}${ICON_REVIEW}${NC} $(opt_diag_family_note "$primary_family")" + + if [[ $sustained_count -gt 1 ]]; then + echo -e " ${GRAY}${ICON_LIST}${NC} Additional sustained pressure:" + while IFS=$'\t' read -r family avg label; do + [[ -z "$family" || "$family" == "$primary_family" ]] && continue + echo -e " ${GRAY}${label}${NC} ~${avg}%" + done <<< "$sustained_details" + fi + fi + + local spctl_status hdiutil_info image_pairs detach_candidates + spctl_status=$(opt_diag_get_spctl_status) + hdiutil_info=$(opt_diag_get_hdiutil_info) + image_pairs=$(opt_diag_parse_image_mount_pairs "$hdiutil_info") + detach_candidates=$(opt_diag_collect_detach_candidates "$image_pairs") + + if [[ "$primary_family" == "syspolicyd" || "$sustained_details" == *$'syspolicyd\t'* ]]; then + local managed_count coresim_count detach_count + managed_count=$(opt_diag_count_matches "$image_pairs" system_managed) + coresim_count=$(opt_diag_count_matches "$image_pairs" coresim_only) + detach_count=$(printf '%s\n' "$detach_candidates" | awk 'NF { count++ } END { print count + 0 }') + + if [[ -n "$spctl_status" ]]; then + echo -e " ${GRAY}${ICON_LIST}${NC} Gatekeeper status: ${spctl_status}" + fi + if [[ "$managed_count" -gt 0 && "$managed_count" == "$coresim_count" && "$detach_count" -eq 0 ]]; then + echo -e " ${GRAY}${ICON_INFO}${NC} Only system-managed CoreSimulator images are mounted, informational only, not a detach target" + elif [[ "$detach_count" -gt 0 ]]; then + echo -e " ${GRAY}${ICON_INFO}${NC} User-mounted disk images may contribute to assessment overhead" + fi + fi + + opt_diag_offer_detach_candidates "$detach_candidates" +} diff --git a/Resources/mole/lib/optimize/maintenance.sh b/Resources/mole/lib/optimize/maintenance.sh index a81c9cf..d0b21ab 100644 --- a/Resources/mole/lib/optimize/maintenance.sh +++ b/Resources/mole/lib/optimize/maintenance.sh @@ -4,50 +4,75 @@ set -euo pipefail -# Remove corrupted preference files. -fix_broken_preferences() { - local prefs_dir="$HOME/Library/Preferences" - [[ -d "$prefs_dir" ]] || return 0 +_preference_plist_is_protected() { + local plist_file="$1" + local protect_loginwindow="${2:-false}" + local filename="${plist_file##*/}" - local broken_count=0 + case "$filename" in + com.apple.* | .GlobalPreferences*) + return 0 + ;; + loginwindow.plist) + [[ "$protect_loginwindow" == "true" ]] + return + ;; + esac + + return 1 +} + +_repair_preference_plists_in_dir() { + local search_dir="$1" + local maxdepth="$2" + local protect_loginwindow="${3:-false}" + [[ -d "$search_dir" ]] || { + echo "0" + return 0 + } + + local -a find_args=("$search_dir") + if [[ "$maxdepth" -gt 0 ]]; then + find_args+=("-maxdepth" "$maxdepth") + fi + find_args+=("-name" "*.plist" "-type" "f") + local broken_count=0 + local plist_file="" while IFS= read -r plist_file; do [[ -f "$plist_file" ]] || continue - - local filename - filename=$(basename "$plist_file") - case "$filename" in - com.apple.* | .GlobalPreferences* | loginwindow.plist) - continue - ;; - esac + _preference_plist_is_protected "$plist_file" "$protect_loginwindow" && continue + if declare -f should_protect_path > /dev/null 2>&1 && should_protect_path "$plist_file"; then + continue + fi + if declare -f is_path_whitelisted > /dev/null 2>&1 && is_path_whitelisted "$plist_file"; then + continue + fi plutil -lint "$plist_file" > /dev/null 2>&1 && continue - safe_remove "$plist_file" true > /dev/null 2>&1 || true - broken_count=$((broken_count + 1)) - done < <(command find "$prefs_dir" -maxdepth 1 -name "*.plist" -type f 2> /dev/null || true) + if safe_remove "$plist_file" true > /dev/null 2>&1; then + broken_count=$((broken_count + 1)) + fi + done < <(command find "${find_args[@]}" 2> /dev/null || true) - # Check ByHost preferences. - local byhost_dir="$prefs_dir/ByHost" - if [[ -d "$byhost_dir" ]]; then - while IFS= read -r plist_file; do - [[ -f "$plist_file" ]] || continue + echo "$broken_count" +} - local filename - filename=$(basename "$plist_file") - case "$filename" in - com.apple.* | .GlobalPreferences*) - continue - ;; - esac +# Remove corrupted preference files. +fix_broken_preferences() { + local prefs_dir="$HOME/Library/Preferences" + [[ -d "$prefs_dir" ]] || return 0 - plutil -lint "$plist_file" > /dev/null 2>&1 && continue + local broken_count=0 + local repaired_count=0 - safe_remove "$plist_file" true > /dev/null 2>&1 || true - broken_count=$((broken_count + 1)) - done < <(command find "$byhost_dir" -name "*.plist" -type f 2> /dev/null || true) - fi + repaired_count=$(_repair_preference_plists_in_dir "$prefs_dir" 1 true) + broken_count=$((broken_count + repaired_count)) + + # Check ByHost preferences recursively. + repaired_count=$(_repair_preference_plists_in_dir "$prefs_dir/ByHost" 0 false) + broken_count=$((broken_count + repaired_count)) echo "$broken_count" } diff --git a/Resources/mole/lib/optimize/tasks.sh b/Resources/mole/lib/optimize/tasks.sh index 0f69863..2281823 100644 --- a/Resources/mole/lib/optimize/tasks.sh +++ b/Resources/mole/lib/optimize/tasks.sh @@ -18,6 +18,33 @@ opt_msg() { fi } +opt_numeric_kb() { + local size_kb="${1:-0}" + [[ "$size_kb" =~ ^[0-9]+$ ]] && echo "$size_kb" || echo "0" +} + +# Whether the current optimize run can use sudo without re-prompting. +# Set by bin/optimize.sh after the upfront ensure_sudo_session call. +# Test-mode env vars hard-deny so ad-hoc task calls under MOLE_TEST_NO_AUTH=1 +# (e.g. ./scripts/test.sh, manual repro) cannot reach a real sudo invocation +# even when this helper is invoked outside the optimize entrypoint. +optimize_sudo_available() { + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 1 + fi + [[ "${MOLE_OPTIMIZE_SUDO_AVAILABLE:-true}" == "true" ]] +} + +opt_existing_path_size_kb() { + local path="$1" + [[ -e "$path" ]] || { + echo "0" + return 0 + } + + opt_numeric_kb "$(get_path_size_kb "$path" 2> /dev/null || echo "0")" +} + run_launchctl_unload() { local plist_file="$1" local need_sudo="${2:-false}" @@ -27,6 +54,12 @@ run_launchctl_unload() { fi if [[ "$need_sudo" == "true" ]]; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + return 0 + fi + if ! optimize_sudo_available; then + return 0 + fi sudo launchctl unload "$plist_file" 2> /dev/null || true else launchctl unload "$plist_file" 2> /dev/null || true @@ -55,20 +88,6 @@ needs_permissions_repair() { return 1 } -has_bluetooth_hid_connected() { - local bt_report - bt_report=$(system_profiler SPBluetoothDataType 2> /dev/null || echo "") - if ! echo "$bt_report" | grep -q "Connected: Yes"; then - return 1 - fi - - if echo "$bt_report" | grep -Eiq "Keyboard|Trackpad|Mouse|HID"; then - return 0 - fi - - return 1 -} - is_ac_power() { pmset -g batt 2> /dev/null | grep -q "AC Power" } @@ -87,12 +106,57 @@ is_memory_pressure_high() { return 1 } +has_active_vpn_interface() { + case "${MOLE_ASSUME_VPN_ACTIVE:-}" in + 1 | true | TRUE | yes | YES) + return 0 + ;; + 0 | false | FALSE | no | NO) + return 1 + ;; + esac + + # macOS creates utun* interfaces for many non-VPN features (iCloud + # Private Relay, Continuity, Handoff, AirDrop, Apple Watch sync, Personal + # Hotspot). Bare interface presence therefore over-reports active VPNs and + # caused the Network Stack Refresh skip in #959. Use two narrower signals: + # + # 1. scutil --nc list flags Connected for system-managed VPN connections + # (L2TP, IPsec, IKEv2, Cisco IPSec). + # 2. The default route's interface is utun* when a full-tunnel third-party + # VPN (WireGuard, OpenVPN, Tunnelblick, etc.) is routing all traffic. + # + # Split-tunnel third-party VPNs that do not own the default route will not + # be detected; route flushing may briefly disrupt their explicit routes, + # which the VPN client re-establishes on its next reconcile. + if command -v scutil > /dev/null 2>&1; then + if scutil --nc list 2> /dev/null | grep -Eq '^\* \(Connected\)'; then + return 0 + fi + fi + + if command -v route > /dev/null 2>&1; then + local default_iface + default_iface=$(route -n get default 2> /dev/null | + awk -F': ' '$1 ~ /^[[:space:]]*interface$/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}') + if [[ "$default_iface" =~ ^utun[0-9]+$ ]]; then + return 0 + fi + fi + + return 1 +} + flush_dns_cache() { if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then MOLE_DNS_FLUSHED=1 return 0 fi + if ! optimize_sudo_available; then + return 1 + fi + if sudo dscacheutil -flushcache 2> /dev/null && sudo killall -HUP mDNSResponder 2> /dev/null; then MOLE_DNS_FLUSHED=1 return 0 @@ -119,30 +183,16 @@ opt_system_maintenance() { opt_cache_refresh() { local total_cache_size=0 + local -a cache_targets=( + "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" + "$HOME/Library/Caches/com.apple.iconservices.store" + "$HOME/Library/Caches/com.apple.iconservices" + ) if [[ "${MO_DEBUG:-}" == "1" ]]; then debug_operation_start "Finder Cache Refresh" "Refresh QuickLook thumbnails and icon services" debug_operation_detail "Method" "Remove cache files and rebuild via qlmanage" debug_operation_detail "Expected outcome" "Faster Finder preview generation, fixed icon display issues" debug_risk_level "LOW" "Caches are automatically rebuilt" - - local -a cache_targets=( - "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - "$HOME/Library/Caches/com.apple.iconservices.store" - "$HOME/Library/Caches/com.apple.iconservices" - ) - - debug_operation_detail "Files to be removed" "" - for target_path in "${cache_targets[@]}"; do - if [[ -e "$target_path" ]]; then - local size_kb - size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") - local size_human="unknown" - if [[ "$size_kb" -gt 0 ]]; then - size_human=$(bytes_to_human "$((size_kb * 1024))") - fi - debug_file_action " Will remove" "$target_path" "$size_human" "" - fi - done fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then @@ -150,23 +200,40 @@ opt_cache_refresh() { qlmanage -r > /dev/null 2>&1 || true fi - local -a cache_targets=( - "$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" - "$HOME/Library/Caches/com.apple.iconservices.store" - "$HOME/Library/Caches/com.apple.iconservices" - ) + local -a removable_targets=() + local -a removable_sizes=() + local target_path="" for target_path in "${cache_targets[@]}"; do - if [[ -e "$target_path" ]]; then - if ! should_protect_path "$target_path"; then - local size_kb - size_kb=$(get_path_size_kb "$target_path" 2> /dev/null || echo "0") - if [[ "$size_kb" =~ ^[0-9]+$ ]]; then - total_cache_size=$((total_cache_size + size_kb)) + [[ -e "$target_path" ]] || continue + should_protect_path "$target_path" && continue + + local size_kb + size_kb=$(opt_existing_path_size_kb "$target_path") + removable_targets+=("$target_path") + removable_sizes+=("$size_kb") + total_cache_size=$((total_cache_size + size_kb)) + done + + if [[ "${MO_DEBUG:-}" == "1" ]]; then + if [[ ${#removable_targets[@]} -eq 0 ]]; then + debug_operation_detail "Files to be removed" "none" + else + debug_operation_detail "Files to be removed" "" + local index + for index in "${!removable_targets[@]}"; do + local size_human="unknown" + if [[ "${removable_sizes[$index]}" -gt 0 ]]; then + size_human=$(bytes_to_human "$((removable_sizes[index] * 1024))") fi - safe_remove "$target_path" true > /dev/null 2>&1 || true - fi + debug_file_action " Will remove" "${removable_targets[$index]}" "$size_human" "" + done fi + fi + + local index + for index in "${!removable_targets[@]}"; do + safe_remove "${removable_targets[$index]}" true "${removable_sizes[$index]}" > /dev/null 2>&1 || true done export OPTIMIZE_CACHE_CLEANED_KB="${total_cache_size}" @@ -209,6 +276,13 @@ opt_saved_state_cleanup() { # Removed: opt_local_snapshots - Deletes user Time Machine recovery points, breaks backup continuity opt_fix_broken_configs() { + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_start "Broken Config Repair" "Detect and reset corrupted preference files" + debug_operation_detail "Method" "Lint third-party plists in ~/Library/Preferences via plutil and remove corrupted ones" + debug_operation_detail "Expected outcome" "Apps reload with fresh preferences instead of failing on a corrupt plist" + debug_risk_level "LOW" "Apps regenerate their preference files on next launch" + fi + local spinner_started="false" if [[ -t 1 ]]; then MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking preferences..." @@ -221,6 +295,10 @@ opt_fix_broken_configs() { stop_inline_spinner fi + if [[ "${MO_DEBUG:-}" == "1" ]]; then + debug_operation_detail "Files repaired" "$broken_prefs" + fi + export OPTIMIZE_CONFIGS_REPAIRED="${broken_prefs}" if [[ $broken_prefs -gt 0 ]]; then opt_msg "Repaired $broken_prefs corrupted preference files" @@ -252,6 +330,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 "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" 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 "$MOLE_TIMEOUT_PKG_LIST_SEC" 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 @@ -306,9 +437,10 @@ opt_sqlite_vacuum() { should_protect_path "$db_file" && continue - if ! file "$db_file" 2> /dev/null | grep -q "SQLite"; then - continue - fi + case "$(file -b "$db_file" 2> /dev/null || true)" in + *SQLite*) ;; + *) continue ;; + esac # Skip large DBs (>100MB). local file_size @@ -320,11 +452,14 @@ opt_sqlite_vacuum() { # Skip if freelist is tiny (already compact). local page_info="" - page_info=$(run_with_timeout 5 sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null || echo "") + page_info=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sqlite3 "$db_file" "PRAGMA page_count; PRAGMA freelist_count;" 2> /dev/null || echo "") local page_count="" local freelist_count="" - page_count=$(echo "$page_info" | awk 'NR==1 {print $1}' 2> /dev/null || echo "") - freelist_count=$(echo "$page_info" | awk 'NR==2 {print $1}' 2> /dev/null || echo "") + page_count="${page_info%%$'\n'*}" + if [[ "$page_info" == *$'\n'* ]]; then + freelist_count="${page_info#*$'\n'}" + freelist_count="${freelist_count%%$'\n'*}" + fi if [[ "$page_count" =~ ^[0-9]+$ && "$freelist_count" =~ ^[0-9]+$ && "$page_count" -gt 0 ]]; then if ((freelist_count * 100 < page_count * 5)); then skipped=$((skipped + 1)) @@ -336,11 +471,11 @@ opt_sqlite_vacuum() { if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then local integrity_check="" set +e - integrity_check=$(run_with_timeout 10 sqlite3 "$db_file" "PRAGMA integrity_check;" 2> /dev/null) + integrity_check=$(run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sqlite3 "$db_file" "PRAGMA integrity_check;" 2> /dev/null) local integrity_status=$? set -e - if [[ $integrity_status -ne 0 ]] || ! echo "$integrity_check" | grep -q "ok"; then + if [[ $integrity_status -ne 0 || "$integrity_check" != "ok" ]]; then skipped=$((skipped + 1)) continue fi @@ -349,7 +484,7 @@ opt_sqlite_vacuum() { local exit_code=0 if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then set +e - run_with_timeout 20 sqlite3 "$db_file" "VACUUM;" 2> /dev/null + run_with_timeout "$MOLE_TIMEOUT_PKG_CLEANUP_SEC" sqlite3 "$db_file" "VACUUM;" 2> /dev/null exit_code=$? set -e @@ -444,69 +579,6 @@ opt_launch_services_rebuild() { fi } -# Font cache rebuild. -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 "Expected outcome" "Fixed font display issues, removed corrupted font cache" - debug_risk_level "LOW" "System automatically rebuilds font database" - fi - - 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. - 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=( - "Safari" - "Google Chrome" - "Chromium" - "Brave Browser" - "Microsoft Edge" - "Arc" - "Opera" - "Vivaldi" - "Zen Browser" - "Helium" - ) - for browser_name in "${browser_checks[@]}"; do - if pgrep -ix "$browser_name" > /dev/null 2>&1; then - running_browsers+=("$browser_name") - fi - done - - if [[ ${#running_browsers[@]} -gt 0 ]]; then - 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}" - return 0 - fi - - if sudo atsutil databases -remove > /dev/null 2>&1; then - success=true - fi - else - success=true - fi - - if [[ "$success" == "true" ]]; then - opt_msg "Font cache cleared" - opt_msg "System will rebuild font database automatically" - else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Failed to clear font cache" - fi -} - # Removed high-risk optimizations: # - opt_startup_items_cleanup: Risk of deleting legitimate app helpers # - opt_dyld_cache_update: Low benefit, time-consuming, auto-managed by macOS @@ -528,6 +600,11 @@ opt_memory_pressure_relief() { return 0 fi + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Memory pressure relief skipped · admin access required" + return 0 + fi + if sudo purge > /dev/null 2>&1; then opt_msg "Inactive memory released" opt_msg "System responsiveness improved" @@ -545,6 +622,11 @@ opt_network_stack_optimize() { local route_flushed="false" local arp_flushed="false" + if has_active_vpn_interface; then + opt_msg "Network stack refresh skipped, active VPN detected" + return 0 + fi + if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then local route_ok=true local dns_ok=true @@ -561,6 +643,11 @@ opt_network_stack_optimize() { return 0 fi + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Network stack refresh skipped · admin access required" + return 0 + fi + if sudo route -n flush > /dev/null 2>&1; then route_flushed="true" fi @@ -605,6 +692,11 @@ opt_disk_permissions_repair() { return 0 fi + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Disk permissions repair skipped · admin access required" + return 0 + fi + if [[ -t 1 ]]; then start_inline_spinner "Repairing disk permissions..." fi @@ -630,88 +722,6 @@ opt_disk_permissions_repair() { fi } -# Bluetooth reset (skip if HID/audio active). -opt_bluetooth_reset() { - if [[ "${MO_DEBUG:-}" == "1" ]]; then - debug_operation_start "Bluetooth Reset" "Restart Bluetooth daemon" - debug_operation_detail "Method" "Kill bluetoothd daemon (auto-restarts)" - debug_operation_detail "Safety" "Skips if active Bluetooth keyboard/mouse/audio detected" - debug_operation_detail "Expected outcome" "Fixed Bluetooth connectivity issues" - debug_risk_level "LOW" "Daemon auto-restarts, connections auto-reconnect" - fi - - local spinner_started="false" - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Bluetooth..." - spinner_started="true" - fi - - if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then - if has_bluetooth_hid_connected; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - return 0 - fi - - local bt_audio_active=false - - local audio_info - audio_info=$(system_profiler SPAudioDataType 2> /dev/null || echo "") - - local default_output - default_output=$(echo "$audio_info" | awk '/Default Output Device: Yes/,/^$/' 2> /dev/null || echo "") - - if echo "$default_output" | grep -qi "Transport:.*Bluetooth"; then - bt_audio_active=true - fi - - if [[ "$bt_audio_active" == "false" ]]; then - if system_profiler SPBluetoothDataType 2> /dev/null | grep -q "Connected: Yes"; then - local -a media_apps=("Music" "Spotify" "VLC" "QuickTime Player" "TV" "Podcasts" "Safari" "Google Chrome" "Chrome" "Firefox" "Arc" "IINA" "mpv") - for app in "${media_apps[@]}"; do - if pgrep -x "$app" > /dev/null 2>&1; then - bt_audio_active=true - break - fi - done - fi - fi - - if [[ "$bt_audio_active" == "true" ]]; then - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - return 0 - fi - - if sudo pkill -TERM bluetoothd > /dev/null 2>&1; then - 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 - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth already optimal" - fi - else - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - opt_msg "Bluetooth module restarted" - opt_msg "Connectivity issues resolved" - fi -} - # Spotlight index check/rebuild (only if slow). opt_spotlight_index_optimize() { local spotlight_status @@ -743,6 +753,10 @@ opt_spotlight_index_optimize() { fi if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then + if ! optimize_sudo_available; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Spotlight index rebuild skipped · admin access required" + return 0 + fi echo -e " ${BLUE}${ICON_INFO}${NC} Spotlight search is slow, rebuilding index, may take 1-2 hours" if sudo mdutil -E / > /dev/null 2>&1; then opt_msg "Spotlight index rebuild started" @@ -761,19 +775,72 @@ opt_spotlight_index_optimize() { fi } -# Dock cache refresh. -opt_dock_refresh() { - local dock_support="$HOME/Library/Application Support/Dock" - local refreshed=false +# Remove orphaned Spotlight search-rule entries. +# Uninstalling an app (especially Mac App Store apps that synced via iCloud) +# can leave its bundle id behind in com.apple.spotlight EnabledPreferenceRules, +# showing up as a dead row in System Settings > Spotlight (#1000). macOS never +# prunes these, so we drop entries whose app is no longer installed. +opt_prune_spotlight_orphan_rules() { + local domain="com.apple.spotlight" + local plist="$HOME/Library/Preferences/${domain}.plist" + + if ! defaults read "$domain" EnabledPreferenceRules &> /dev/null; then + opt_msg "Spotlight search rules already clean" + return 0 + fi - if [[ -d "$dock_support" ]]; then - while IFS= read -r db_file; do - if [[ -f "$db_file" ]]; then - safe_remove "$db_file" true > /dev/null 2>&1 && refreshed=true - fi - done < <(command find "$dock_support" -name "*.db" -type f 2> /dev/null || true) + local -a keep=() removed=() + local i=0 entry + while entry=$(/usr/libexec/PlistBuddy -c "Print :EnabledPreferenceRules:$i" "$plist" 2> /dev/null); do + case "$entry" in + # Never touch system or Apple rules (e.g. System.iphoneApps); these + # pass the reverse-DNS shape check but are not removable app bundles. + System.* | com.apple.*) + keep+=("$entry") + ;; + *) + # Only act on well-formed bundle ids; bundle_has_installed_app + # double-checks with mdfind and a filesystem scan, so a return of + # 1 means the app is genuinely gone. Anything else is kept. + if mole_is_reverse_dns_bundle_id "$entry" && ! bundle_has_installed_app "$entry"; then + removed+=("$entry") + else + keep+=("$entry") + fi + ;; + esac + i=$((i + 1)) + done + + if [[ ${#removed[@]} -eq 0 ]]; then + opt_msg "Spotlight search rules already clean" + return 0 fi + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + opt_msg "Would remove ${#removed[@]} orphan Spotlight rule(s)" + return 0 + fi + + # Rewrite the filtered array through cfprefsd (defaults), not by deleting + # plist indices in place: this avoids the cfprefsd cache overwriting a direct + # file edit, and ensures System Settings reflects the change and it persists. + if [[ ${#keep[@]} -gt 0 ]]; then + defaults write "$domain" EnabledPreferenceRules -array "${keep[@]}" 2> /dev/null || true + else + defaults delete "$domain" EnabledPreferenceRules 2> /dev/null || true + fi + + opt_msg "Removed ${#removed[@]} orphan Spotlight rule(s)" +} + +# Dock refresh (restart Dock so plist edits take effect). +# The previous implementation also wiped every "*.db" under +# ~/Library/Application Support/Dock, which deleted macOS's +# desktoppicture.db and reset the user's wallpaper (#995). No .db under +# that directory needs to be cleared for Dock to refresh — killall plus +# touching the plist is sufficient. +opt_dock_refresh() { local dock_plist="$HOME/Library/Preferences/com.apple.dock.plist" if [[ -f "$dock_plist" ]]; then touch "$dock_plist" 2> /dev/null || true @@ -783,32 +850,541 @@ opt_dock_refresh() { killall Dock 2> /dev/null || true fi - if [[ "$refreshed" == "true" ]]; then - opt_msg "Dock cache cleared" - fi 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 +} + +# True unless the path lives on an unmounted /Volumes/. A LaunchAgent +# program on an external or network volume is not broken while that volume is +# simply unplugged, so it must not be deleted. +launch_agent_volume_mounted() { + local path="$1" + case "$path" in + /Volumes/*) + local vol="${path#/Volumes/}" + vol="${vol%%/*}" + [[ -n "$vol" && -d "/Volumes/$vol" ]] + ;; + *) return 0 ;; + esac +} + +# 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 + + # Only an absolute path that is genuinely missing counts as broken. + # Bare names (node, python3) resolve via PATH at launch time, and a + # path on an unmounted /Volumes/ just means the drive is + # unplugged -- neither is a broken agent. + if [[ -n "$binary" && "$binary" == /* && ! -e "$binary" ]] && + launch_agent_volume_mounted "$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() { + # Check if periodic command exists (removed in macOS 26+) + if ! command -v periodic > /dev/null 2>&1; then + opt_msg "Periodic maintenance skipped (not available on this macOS version)" + return 0 + fi + + 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=$(get_file_mtime "$daily_log") + 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 [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! optimize_sudo_available; 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 ! -path "*ApplicationRecentDocuments*" 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=$(opt_existing_path_size_kb "$nc_db") + + # 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 "$MOLE_TIMEOUT_DISK_VERIFY_SEC" 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 + local -a knowledge_files=() + + for f in "$knowledge_db" "$wal_file" "$shm_file"; do + [[ -f "$f" ]] && knowledge_files+=("$f") + done + + if [[ ${#knowledge_files[@]} -gt 0 ]]; then + total_size=$(command du -skcP "${knowledge_files[@]}" 2> /dev/null | awk 'END {print $1 + 0}' || echo "0") + total_size=$(opt_numeric_kb "$total_size") + fi + + # 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. +# Return a tab-separated snapshot: login item display name, then best-effort +# POSIX path. Display names can differ from the on-disk bundle name, so the +# audit needs both pieces before deciding an item is broken. +_login_items_snapshot() { + osascript << 'APPLESCRIPT' +set oldDelimiters to AppleScript's text item delimiters +set tabChar to ASCII character 9 +set linefeedChar to ASCII character 10 +set outputLines to {} + +tell application "System Events" + repeat with loginItem in login items + set itemName to "" + set itemPath to "" + + try + set itemName to name of loginItem as text + end try + + try + set itemPath to POSIX path of (path of loginItem as alias) + on error + try + set itemPath to path of loginItem as text + end try + end try + + set end of outputLines to itemName & tabChar & itemPath + end repeat +end tell + +set AppleScript's text item delimiters to linefeedChar +set outputText to outputLines as text +set AppleScript's text item delimiters to oldDelimiters +return outputText +APPLESCRIPT +} + +_login_item_debug() { + if [[ "${MO_DEBUG:-}" == "1" ]] && declare -f debug_log > /dev/null 2>&1; then + debug_log "Login item audit: $*" + fi +} + +_login_item_name_matches() { + local actual="$1" + local expected="$2" + local expected_nospace="$3" + local expected_stripped="$4" + + [[ -z "$actual" ]] && return 1 + + local actual_nospace="${actual// /}" + [[ "$actual" == "$expected" ]] && return 0 + [[ "$actual_nospace" == "$expected_nospace" ]] && return 0 + [[ -n "$expected_stripped" && "$actual_nospace" == "$expected_stripped" ]] && return 0 + + return 1 +} + +_login_item_bundle_metadata_matches() { + local app_path="$1" + local name="$2" + local nospace="$3" + local stripped="$4" + local info="$app_path/Contents/Info.plist" + [[ -f "$info" ]] || return 1 + + local key value + for key in CFBundleDisplayName CFBundleName CFBundleExecutable; do + value=$(plutil -extract "$key" raw "$info" 2> /dev/null || echo "") + if _login_item_name_matches "$value" "$name" "$nospace" "$stripped"; then + _login_item_debug "'$name' matched $key '$value' at $app_path" + return 0 + fi + done + + return 1 +} + +# 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" + local item_path="${2:-}" + + if [[ -n "$item_path" ]]; then + if [[ -e "$item_path" || -L "$item_path" ]]; then + _login_item_debug "'$name' resolved by login item path: $item_path" + return 0 + fi + _login_item_debug "'$name' login item path is missing: $item_path" + else + _login_item_debug "'$name' has no login item path from System Events" + fi + + # 1. Exact match + if [[ "$name" != *"'"* ]] && mdfind "kMDItemFSName == '${name}.app'" 2> /dev/null | grep -q .; then + _login_item_debug "'$name' resolved by Spotlight exact app name" + return 0 + fi + # 2. Try without spaces (e.g. "Top Calendar" -> "TopCalendar") + local nospace="${name// /}" + if [[ "$name" != *"'"* && "$nospace" != "$name" ]] && mdfind "kMDItemFSName == '${nospace}.app'" 2> /dev/null | grep -q .; then + _login_item_debug "'$name' resolved by Spotlight no-space app name" + 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 [[ "$name" != *"'"* && "$stripped" != "$nospace" ]] && mdfind "kMDItemFSName == '${stripped}.app'" 2> /dev/null | grep -q .; then + _login_item_debug "'$name' resolved by Spotlight stripped helper name" + return 0 + fi + # 4. Recursive filesystem fallback for nested helper apps inside parent + # bundles. Spotlight often misses helpers under Contents/. + local candidate roots app_name app_path + local -a app_names=("${name}.app") + [[ "$nospace" != "$name" ]] && app_names+=("${nospace}.app") + [[ "$stripped" != "$nospace" ]] && app_names+=("${stripped}.app") + for roots in "/Applications" "$HOME/Applications"; do + [[ -d "$roots" ]] || continue + local -a name_expr=() + for app_name in "${app_names[@]}"; do + if [[ ${#name_expr[@]} -gt 0 ]]; then + name_expr+=("-o") + fi + name_expr+=("-name" "$app_name") + done + candidate=$(command find "$roots" -maxdepth 6 -type d \( "${name_expr[@]}" \) -print -quit 2> /dev/null || true) + if [[ -n "$candidate" && -d "$candidate" ]]; then + _login_item_debug "'$name' resolved by filesystem app name: $candidate" + return 0 + fi + + while IFS= read -r -d '' app_path; do + if _login_item_bundle_metadata_matches "$app_path" "$name" "$nospace" "$stripped"; then + return 0 + fi + done < <(command find "$roots" -maxdepth 6 -type d -name "*.app" -print0 2> /dev/null) + done + # 5. Fallback: check sfltool dumpbtm for the actual on-disk path. + # Nested helper apps (e.g. DBnginMenuHelper.app inside DBngin.app) are + # invisible to mdfind but still have a valid URL in the BTM database. + local btm_path + btm_path=$(sfltool dumpbtm 2> /dev/null | awk -v item="$name" ' + BEGIN { IGNORECASE = 1 } + index($0, item) { + if (match($0, "/.*\\.app")) { + print substr($0, RSTART, RLENGTH) + exit + } + } + ') + if [[ -n "$btm_path" ]] && [[ -e "$btm_path" ]]; then + _login_item_debug "'$name' resolved by sfltool BTM path: $btm_path" + return 0 + fi + _login_item_debug "'$name' unresolved after path, Spotlight, filesystem, and BTM checks" + 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=$(_login_items_snapshot 2> /dev/null || true) + + if [[ -z "$items_output" ]]; then + opt_msg "No login items found" + return 0 + fi + + local broken=0 + local checked=0 + local item item_path + while IFS=$'\t' read -r item item_path; do + [[ -z "$item" ]] && continue + checked=$((checked + 1)) + if _login_item_app_exists "$item" "$item_path"; then + continue + fi + echo -e " ${YELLOW}${ICON_WARNING}${NC} Broken login item: $item (app not found)" + broken=$((broken + 1)) + done <<< "$items_output" + + 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" local path="${2:-}" + if command -v is_whitelisted > /dev/null && is_whitelisted "$action"; then + opt_msg "Skipped (whitelisted): $action" + return 0 + fi + case "$action" in system_maintenance) opt_system_maintenance ;; cache_refresh) opt_cache_refresh ;; 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 ;; + spotlight_orphan_rules_cleanup) opt_prune_spotlight_orphan_rules ;; + 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..8179e8b 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" @@ -146,6 +155,8 @@ select_apps_for_uninstall() { fi fi + drain_pending_input 0.2 + # Expose metadata for the paginated menu (optional inputs) # - MOLE_MENU_META_EPOCHS: numeric last_used_epoch per item # - MOLE_MENU_META_SIZEKB: numeric size in KB per item @@ -161,6 +172,7 @@ select_apps_for_uninstall() { unset MOLE_MENU_META_SIZEKB fi export MOLE_MENU_FILTER_NAMES="$names_newline" + export MOLE_MENU_IGNORE_INITIAL_ENTER=1 # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT # Note: paginated_multi_select enters alternate screen and handles clearing @@ -169,7 +181,7 @@ select_apps_for_uninstall() { local exit_code=$? # Clean env leakage for safety - unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB MOLE_MENU_FILTER_NAMES + unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB MOLE_MENU_FILTER_NAMES MOLE_MENU_IGNORE_INITIAL_ENTER # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally if [[ $exit_code -ne 0 ]]; then diff --git a/Resources/mole/lib/ui/menu_paginated.sh b/Resources/mole/lib/ui/menu_paginated.sh index c241fc1..cdcf43b 100755 --- a/Resources/mole/lib/ui/menu_paginated.sh +++ b/Resources/mole/lib/ui/menu_paginated.sh @@ -91,6 +91,7 @@ paginated_multi_select() { local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" local filter_text="" # Filter keyword local filter_text_lower="" + local ignore_initial_enter="${MOLE_MENU_IGNORE_INITIAL_ENTER:-false}" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -100,13 +101,22 @@ paginated_multi_select() { local -a sizekb=() local -a filter_names=() local has_metadata="false" + local has_epoch_metadata="false" + local has_size_metadata="false" local has_filter_names="false" if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then - while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") - has_metadata="true" + while IFS= read -r v; do + epochs+=("${v:-0}") + [[ "${v:-0}" =~ ^[0-9]+$ && "${v:-0}" -gt 0 ]] && has_epoch_metadata="true" + done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS") fi if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then - while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") + while IFS= read -r v; do + sizekb+=("${v:-0}") + [[ "${v:-0}" =~ ^[0-9]+$ && "${v:-0}" -gt 0 ]] && has_size_metadata="true" + done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB") + fi + if [[ "$has_epoch_metadata" == "true" || "$has_size_metadata" == "true" ]]; then has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then @@ -114,9 +124,49 @@ paginated_multi_select() { has_filter_names="true" fi - # If no metadata, force name sorting and disable sorting controls - if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then + sort_mode_available() { + case "$1" in + date) [[ "$has_epoch_metadata" == "true" ]] ;; + size) [[ "$has_size_metadata" == "true" ]] ;; + name) return 0 ;; + *) return 1 ;; + esac + } + + normalize_sort_mode() { + sort_mode_available "$sort_mode" && return 0 + if [[ "$has_epoch_metadata" == "true" ]]; then + sort_mode="date" + elif [[ "$has_size_metadata" == "true" ]]; then + sort_mode="size" + else + sort_mode="name" + fi + } + + cycle_sort_mode() { + local candidate + case "$sort_mode" in + date) set -- name size date ;; + name) set -- size date name ;; + size) set -- date name size ;; + *) set -- date name size ;; + esac + + for candidate in "$@"; do + if sort_mode_available "$candidate"; then + sort_mode="$candidate" + return 0 + fi + done sort_mode="name" + } + + # If no metadata, force name sorting and disable sorting controls. + if [[ "$has_metadata" == "false" ]]; then + sort_mode="name" + else + normalize_sort_mode fi # Index mappings @@ -259,7 +309,7 @@ paginated_multi_select() { local -a filter_cache_indices=() ensure_sorted_indices() { - local requested_key="${sort_mode}:${sort_reverse}:${has_metadata}" + local requested_key="${sort_mode}:${sort_reverse}:${has_epoch_metadata}:${has_size_metadata}" if [[ "$requested_key" == "$sort_cache_key" && ${#sorted_indices_cache[@]} -gt 0 ]]; then return fi @@ -400,9 +450,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 @@ -444,7 +494,7 @@ paginated_multi_select() { for ((i = 0; i < items_per_page; i++)); do printf "${clear_line}\n" >&2 done - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter Save | Q Cancel${NC}\n" >&2 printf "${clear_line}" >&2 return fi @@ -502,16 +552,17 @@ paginated_multi_select() { # Common menu items local nav="${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN}${NC}" + local page_ctrl="${GRAY}h/l Page${NC}" local space_select="${GRAY}Space Select${NC}" - local enter="${GRAY}Enter${NC}" - local exit="${GRAY}Q Exit${NC}" + local enter="${GRAY}Enter Save${NC}" + local cancel_label="${GRAY}Q Cancel${NC}" local reverse_arrow="↑" [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" 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}") @@ -523,7 +574,7 @@ paginated_multi_select() { [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + local -a _segs=("$nav" "$page_ctrl" "$space_select" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$cancel_label") # Calculate width local total_len=0 seg_count=${#_segs[@]} @@ -534,7 +585,7 @@ paginated_multi_select() { # Level 1: Remove "Space Select" if too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$page_ctrl" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$cancel_label") total_len=0 seg_count=${#_segs[@]} @@ -543,16 +594,16 @@ paginated_multi_select() { [[ $i -lt $((seg_count - 1)) ]] && total_len=$((total_len + 3)) done - # Level 2: Remove sort label if still too wide + # Level 2: Remove sort label and page hint if still too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$enter" "$order_ctrl" "$filter_ctrl" "$cancel_label") fi fi _print_wrapped_controls "$sep" "${_segs[@]}" else # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$filter_ctrl" "$exit") + local -a _segs_simple=("$nav" "$page_ctrl" "$space_select" "$enter" "$filter_ctrl" "$cancel_label") _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 @@ -575,6 +626,12 @@ paginated_multi_select() { local key key=$(read_key) + if [[ "$ignore_initial_enter" == "true" || "$ignore_initial_enter" == "1" ]]; then + ignore_initial_enter=false + if [[ "$key" == "ENTER" ]]; then + continue + fi + fi case "$key" in "QUIT") @@ -707,6 +764,51 @@ paginated_multi_select() { fi fi ;; + "TOP") + if [[ ${#view_indices[@]} -gt 0 ]]; then + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "BOTTOM") + if [[ ${#view_indices[@]} -gt 0 ]]; then + local visible_total=${#view_indices[@]} + if [[ $visible_total -gt $items_per_page ]]; then + top_index=$((visible_total - items_per_page)) + cursor_pos=$((items_per_page - 1)) + else + top_index=0 + cursor_pos=$((visible_total - 1)) + fi + need_full_redraw=true + fi + ;; + "LEFT") + if [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $top_index -gt 0 ]]; then + top_index=$((top_index - items_per_page)) + [[ $top_index -lt 0 ]] && top_index=0 + fi + cursor_pos=0 + need_full_redraw=true + fi + ;; + "RIGHT") + if [[ ${#view_indices[@]} -gt 0 ]]; then + local visible_total=${#view_indices[@]} + if [[ $((top_index + items_per_page)) -lt $visible_total ]]; then + top_index=$((top_index + items_per_page)) + local _remaining=$((visible_total - top_index)) + if [[ $_remaining -lt $items_per_page ]]; then + top_index=$((visible_total - items_per_page)) + [[ $top_index -lt 0 ]] && top_index=0 + fi + fi + cursor_pos=0 + need_full_redraw=true + fi + ;; "SPACE") local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then @@ -738,11 +840,7 @@ paginated_multi_select() { if handle_filter_char "${key#CHAR:}"; then : # Handled as filter input elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac + cycle_sort_mode rebuild_view need_full_redraw=true fi diff --git a/Resources/mole/lib/ui/menu_simple.sh b/Resources/mole/lib/ui/menu_simple.sh index 0dd4607..ae01e7b 100755 --- a/Resources/mole/lib/ui/menu_simple.sh +++ b/Resources/mole/lib/ui/menu_simple.sh @@ -212,7 +212,7 @@ paginated_multi_select() { # Clear any remaining lines at bottom printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter | Q Exit${NC}\n" >&2 + printf "${clear_line}${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space | Enter Save | Q Cancel${NC}\n" >&2 # Clear one more line to ensure no artifacts printf "${clear_line}" >&2 @@ -279,6 +279,13 @@ paginated_multi_select() { selected[i]=false done ;; + "LEFT" | "RIGHT") + # menu_simple is non-paginated; LEFT/RIGHT (h/l) have no + # navigation meaning here. Swallow them silently so set -e in + # callers doesn't trip and so a stray Vim user doesn't see a + # spurious cursor jump. + : + ;; "ENTER") # Store result in global variable instead of returning via stdout local -a selected_indices=() diff --git a/Resources/mole/lib/uninstall/batch.sh b/Resources/mole/lib/uninstall/batch.sh index 8a22f9a..ad102a1 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() { @@ -77,13 +90,127 @@ decode_file_list() { echo "$decoded" return 0 } -# Note: find_app_files() and calculate_total_size() are in lib/core/common.sh. +# Note: find_app_files() is in lib/core/app_protection.sh, calculate_total_size() is in lib/core/file_ops.sh. + +# Match successfully-uninstalled apps against an sfltool dumpbtm output and emit +# the names of apps that still have a Background Items entry registered. +# Args: ... -- ... +# app_detail follows the pipe-encoded shape used inside batch_uninstall_applications. +_uninstall_match_btm_leftovers() { + local btm_dump="$1" + shift + + local -a details=() + local -a success_paths=() + local sep_seen=false + local arg + for arg in "$@"; do + if [[ "$sep_seen" == false ]]; then + if [[ "$arg" == "--" ]]; then + sep_seen=true + else + details+=("$arg") + fi + else + success_paths+=("$arg") + fi + done + + [[ -z "$btm_dump" ]] && return 0 + [[ ${#details[@]} -eq 0 || ${#success_paths[@]} -eq 0 ]] && return 0 + + local detail app_name app_path bundle_id sp matched + for detail in "${details[@]}"; do + IFS='|' read -r app_name app_path bundle_id _ _ _ _ _ _ _ _ _ <<< "$detail" + matched=false + for sp in "${success_paths[@]}"; do + [[ "$sp" == "$app_path" ]] && matched=true && break + done + [[ "$matched" != true ]] && continue + [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && continue + + if grep -qF "$bundle_id" <<< "$btm_dump"; then + printf '%s\n' "$app_name" + fi + done +} + +append_line() { + local current="$1" + local addition="$2" + [[ -z "$addition" ]] && { + printf '%s' "$current" + return 0 + } + if [[ -n "$current" ]]; then + printf '%s\n%s' "$current" "$addition" + else + printf '%s' "$addition" + fi +} + +discover_login_item_helper_bundle_ids() { + local app_path="$1" + local login_items_root="$app_path/Contents/Library/LoginItems" + [[ -d "$login_items_root" ]] || return 0 + + local helper info bundle_id + while IFS= read -r -d '' helper; do + info="$helper/Contents/Info.plist" + [[ -f "$info" ]] || continue + bundle_id=$(plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || true) + if mole_is_reverse_dns_bundle_id "$bundle_id"; then + printf '%s\n' "$bundle_id" + fi + done < <(find "$login_items_root" -maxdepth 1 -name "*.app" -print0 2> /dev/null || true) +} + +bootout_login_item_helpers() { + local helper_ids="$1" + [[ -n "$helper_ids" ]] || return 0 + if is_uninstall_dry_run || [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then + debug_log "[DRY RUN] Would bootout login item helpers" + return 0 + fi + + local uid helper_id + uid=$(id -u) + while IFS= read -r helper_id; do + [[ -n "$helper_id" ]] || continue + mole_is_reverse_dns_bundle_id "$helper_id" || continue + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" launchctl bootout "gui/$uid/$helper_id" > /dev/null 2>&1 || true + done <<< "$helper_ids" +} + +can_unload_launch_plist() { + local plist="$1" + [[ "$plist" == *.plist ]] || return 1 + case "$plist" in + "$HOME"/Library/LaunchAgents/*.plist | /Library/LaunchAgents/*.plist | /Library/LaunchDaemons/*.plist) ;; + *) return 1 ;; + esac + validate_path_for_deletion "$plist" > /dev/null 2>&1 +} + +unload_launch_plist() { + local plist="$1" + local needs_sudo="${2:-false}" + can_unload_launch_plist "$plist" || return 0 + if [[ "$needs_sudo" == "true" ]]; then + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" sudo launchctl unload "$plist" > /dev/null 2>&1 || true + else + run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" launchctl unload "$plist" > /dev/null 2>&1 || true + fi +} -# Stop Launch Agents/Daemons for an app. +# Unload Launch Agents/Daemons for an app. +# Plist deletion is owned by remove_file_list so every removal goes through the +# same validated path list and Trash/permanent deletion mode. # Security: bundle_id is validated to be reverse-DNS format before use in find patterns stop_launch_services() { local bundle_id="$1" local has_system_files="${2:-false}" + local app_path="${3:-}" if is_uninstall_dry_run; then debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id" @@ -94,27 +221,49 @@ stop_launch_services() { # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) # This prevents glob injection attacks if bundle_id contains special characters - if [[ ! "$bundle_id" =~ ^[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$ ]]; then + if ! mole_is_reverse_dns_bundle_id "$bundle_id"; then debug_log "Invalid bundle_id format for LaunchAgent search: $bundle_id" return 0 fi if [[ -d ~/Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - launchctl unload "$plist" 2> /dev/null || true - done < <(find ~/Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + unload_launch_plist "$plist" "false" + done < <(find ~/Library/LaunchAgents -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) fi - if [[ "$has_system_files" == "true" ]]; then + if [[ "$has_system_files" == "true" && "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then if [[ -d /Library/LaunchAgents ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchAgents -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + unload_launch_plist "$plist" "true" + done < <(find /Library/LaunchAgents -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) fi if [[ -d /Library/LaunchDaemons ]]; then while IFS= read -r -d '' plist; do - sudo launchctl unload "$plist" 2> /dev/null || true - done < <(find /Library/LaunchDaemons -maxdepth 1 -name "${bundle_id}*.plist" -print0 2> /dev/null) + unload_launch_plist "$plist" "true" + done < <(find /Library/LaunchDaemons -maxdepth 1 \( -name "${bundle_id}.plist" -o -name "${bundle_id}.*.plist" \) -print0 2> /dev/null) + fi + fi + + # Scan for LaunchAgents whose ProgramArguments reference the app path. + # Catches agents with bundle IDs that don't match the app's bundle ID. + if [[ -n "$app_path" ]]; then + if [[ -d ~/Library/LaunchAgents ]]; then + while IFS= read -r -d '' plist; do + unload_launch_plist "$plist" "false" + done < <(grep -rlZ "$app_path" ~/Library/LaunchAgents/ 2> /dev/null || true) + fi + if [[ "$has_system_files" == "true" && "${MOLE_TEST_MODE:-0}" != "1" && "${MOLE_TEST_NO_AUTH:-0}" != "1" ]]; then + if [[ -d /Library/LaunchAgents ]]; then + while IFS= read -r -d '' plist; do + unload_launch_plist "$plist" "true" + done < <(grep -rlZ "$app_path" /Library/LaunchAgents/ 2> /dev/null || true) + fi + if [[ -d /Library/LaunchDaemons ]]; then + while IFS= read -r -d '' plist; do + unload_launch_plist "$plist" "true" + done < <(grep -rlZ "$app_path" /Library/LaunchDaemons/ 2> /dev/null || true) + fi fi fi } @@ -150,17 +299,17 @@ refresh_launch_services_after_uninstall() { set +e # Add 10s timeout to prevent hanging (gc is usually fast) # run_with_timeout falls back to shell implementation if timeout command unavailable - run_with_timeout 10 "$lsregister" -gc > /dev/null 2>&1 || true - # Add 15s timeout for rebuild (can be slow on some systems) + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -gc > /dev/null 2>&1 || true + # 15s: lsregister rebuild can be slow on some systems, see lib/core/timeouts.sh run_with_timeout 15 "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1 success=$? # 124 = timeout exit code (from run_with_timeout or timeout command) if [[ $success -eq 124 ]]; then debug_log "LaunchServices rebuild timed out, trying lighter version" - run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 success=$? elif [[ $success -ne 0 ]]; then - run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 + run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 success=$? fi set -e @@ -186,35 +335,47 @@ 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 } # Remove files (handles symlinks, optional sudo). # Security: All paths pass validate_path_for_deletion() before any deletion. +# Performance: when MOLE_DELETE_MODE=trash and the batch is sudo-free and +# symlink-free, the eligible paths are sent to Trash in a single subprocess +# (one `trash` exec or one Finder AppleScript round-trip). This collapses the +# previous N-subprocess fan-out that caused the post-confirmation "frozen +# terminal" reported during `mo uninstall` on apps with many leftovers. remove_file_list() { local file_list="$1" local use_sudo="${2:-false}" local count=0 + local mode="${MOLE_DELETE_MODE:-permanent}" + + local -a trash_batch=() + local -a fallback_paths=() while IFS= read -r file; do [[ -n "$file" && -e "$file" ]] || continue @@ -223,81 +384,84 @@ remove_file_list() { continue fi - if [[ -L "$file" ]]; then - safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true - 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 - fi - done <<< "$file_list" - - echo "$count" -} - -# Batch uninstall with single confirmation. -batch_uninstall_applications() { - local total_size_freed=0 - - # shellcheck disable=SC2154 - if [[ ${#selected_apps[@]} -eq 0 ]]; then - log_warning "No applications selected for uninstallation" - return 0 - fi - - local old_trap_int old_trap_term - old_trap_int=$(trap -p INT) - old_trap_term=$(trap -p TERM) - - _cleanup_sudo_keepalive() { - if command -v stop_sudo_session > /dev/null 2>&1; then - stop_sudo_session + if [[ "$use_sudo" == "true" ]] && is_uninstall_dry_run; then + debug_log "[DRY RUN] Would sudo remove: $file" + ((++count)) + continue fi - } - _restore_uninstall_traps() { - _cleanup_sudo_keepalive - if [[ -n "$old_trap_int" ]]; then - eval "$old_trap_int" + # Symlinks and sudo-required paths stay on the per-file mole_delete + # path: safe_remove_symlink semantics differ from Trash, and AppleScript + # cannot run reliably as root for the batch fallback. + if [[ "$mode" == "trash" && "$use_sudo" != "true" && ! -L "$file" ]] && + ! is_uninstall_dry_run; then + trash_batch+=("$file") else - trap - INT + fallback_paths+=("$file") fi - if [[ -n "$old_trap_term" ]]; then - eval "$old_trap_term" + done <<< "$file_list" + + if [[ ${#trash_batch[@]} -gt 0 ]]; then + if _mole_move_to_trash_batch "${trash_batch[@]}"; then + local _bp _bsize + for _bp in "${trash_batch[@]}"; do + _bsize="unknown" + _mole_delete_log "trash" "$_bsize" "ok" "$_bp" + log_operation "${MOLE_CURRENT_COMMAND:-uninstall}" "TRASHED" "$_bp" "batch" + done + count=$((count + ${#trash_batch[@]})) else - trap - TERM + # Batch failed wholesale: route each path through mole_delete so + # per-file Trash handling fails closed and forensic logging stays + # intact. + fallback_paths+=("${trash_batch[@]}") fi - } + fi - # Trap to clean up spinner, sudo keepalive, and uninstall mode on interrupt - trap 'stop_inline_spinner 2>/dev/null; _cleanup_sudo_keepalive; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; return 130' INT TERM + if [[ ${#fallback_paths[@]} -gt 0 ]]; then + local fb + for fb in "${fallback_paths[@]}"; do + # mole_delete routes through Trash when MOLE_DELETE_MODE=trash + # (uninstall default) and only uses safe_* permanent removal when + # the caller explicitly selected permanent mode. See #723. + mole_delete "$fb" "$use_sudo" && ((++count)) || true + done + fi - # Pre-scan: running apps, sudo needs, size. - local -a running_apps=() - local -a sudo_apps=() - local total_estimated_size=0 - local -a app_details=() + echo "$count" +} +# Internal helpers for batch_uninstall_applications. They read and write +# locals declared in the orchestrator's scope via bash dynamic scoping; do +# not call them outside batch_uninstall_applications. + +# Phase 1: scan every selected app, classify into running/sudo/brew/blocked +# buckets, build pipe-encoded app_details records, accumulate the total +# estimated size, and warn about apps that require an official uninstaller. +# Reads: selected_apps +# Writes: running_apps, sudo_apps, brew_cask_apps, blocked_apps, app_details, +# total_estimated_size +_batch_scan_app_details() { # Cache current user outside loop local current_user=$(whoami) if [[ -t 1 ]]; then start_inline_spinner "Scanning files..."; fi + # shellcheck disable=SC2154 # selected_apps is provided by batch_uninstall_applications via dynamic scope. for selected_app in "${selected_apps[@]}"; do [[ -z "$selected_app" ]] && continue IFS='|' read -r _ app_path app_name bundle_id _ _ <<< "$selected_app" + local official_vendor="" + if official_vendor=$(official_uninstaller_vendor "$bundle_id" "$app_name" "$app_path" 2> /dev/null); then + blocked_apps+=("$app_name|$official_vendor") + continue + fi + # Check running app by bundle executable if available local exec_name="" local info_plist="$app_path/Contents/Info.plist" if [[ -e "$info_plist" ]]; then - exec_name=$(defaults read "$info_plist" CFBundleExecutable 2> /dev/null || echo "") + exec_name=$(plutil -extract CFBundleExecutable raw "$info_plist" 2> /dev/null || echo "") fi if pgrep -qx "${exec_name:-$app_name}" 2> /dev/null; then running_apps+=("$app_name") @@ -319,6 +483,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") @@ -342,17 +510,19 @@ batch_uninstall_applications() { local system_files=$(find_app_system_files "$bundle_id" "$app_name" || true) local diag_system diag_system=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "/Library/Logs/DiagnosticReports" || true) - # shellcheck disable=SC2128 - local system_size_kb=$(calculate_total_size "$system_files" || echo "0") - local diag_system_size_kb=$(calculate_total_size "$diag_system" || echo "0") - local total_kb=$((app_size_kb + related_size_kb + system_size_kb + diag_system_size_kb)) + local review_only_system_files="$system_files" + review_only_system_files=$(append_line "$review_only_system_files" "$diag_system") + # System-level remnants are review-only in the CLI: shown in the preview + # via review_only_system_files (encoded into encoded_review_system) but + # never deleted. Blanking system_files/diag_system here is what enforces + # that: _batch_execute_removals decodes the now-empty encoded_system_files + # and encoded_diag_system fields and therefore skips them. Do NOT remove + # this blanking, or system files would become deletable again. + system_files="" + diag_system="" + local total_kb=$((app_size_kb + related_size_kb)) total_estimated_size=$((total_estimated_size + total_kb)) - # shellcheck disable=SC2128 - if [[ -n "$system_files" || -n "$diag_system" ]]; then - needs_sudo=true - fi - if [[ "$needs_sudo" == "true" ]]; then sudo_apps+=("$app_name") fi @@ -363,6 +533,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,20 +545,41 @@ 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") + local encoded_review_system + encoded_review_system=$(printf '%s' "$review_only_system_files" | base64 | tr -d '\n' || echo "") + local login_item_helpers + login_item_helpers=$(discover_login_item_helper_bundle_ids "$app_path" || true) + local encoded_login_item_helpers + encoded_login_item_helpers=$(printf '%s' "$login_item_helpers" | 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|$has_local_network_usage|$encoded_review_system|$encoded_login_item_helpers") done if [[ -t 1 ]]; then stop_inline_spinner; fi + if [[ ${#blocked_apps[@]} -gt 0 ]]; then + local blocked_detail blocked_name blocked_vendor + for blocked_detail in "${blocked_apps[@]}"; do + IFS='|' read -r blocked_name blocked_vendor <<< "$blocked_detail" + log_warning "$blocked_name requires the official $blocked_vendor uninstaller" + done + fi +} + +# Phase 2+3: render the preview block listing every target with its size +# and per-file breakdown, prompt the user for confirmation, and establish +# a sudo session when admin access is needed. Returns: +# 0 - user confirmed and (if needed) sudo session established +# 2 - user cancelled (ESC / 'q' / unknown key) +# 1 - sudo authorization denied +# Reads: app_details, brew_cask_apps, running_apps, sudo_apps, +# total_estimated_size +_batch_preview_and_confirm() { local size_display=$(bytes_to_human "$((total_estimated_size * 1024))") echo -e "\n${PURPLE_BOLD}Files to be removed:${NC}" # 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 +588,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 encoded_review_system encoded_login_item_helpers <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -404,6 +600,8 @@ batch_uninstall_applications() { local system_files=$(decode_file_list "$encoded_system_files" "$app_name") local diag_system_display diag_system_display=$(decode_file_list "$encoded_diag_system" "$app_name") + local review_system_display + review_system_display=$(decode_file_list "$encoded_review_system" "$app_name") [[ -n "$diag_system_display" ]] && system_files=$( [[ -n "$system_files" ]] && echo "$system_files" echo "$diag_system_display" @@ -424,10 +622,16 @@ batch_uninstall_applications() { echo -e " ${BLUE}${ICON_WARNING}${NC} System: $file" fi done <<< "$system_files" + + while IFS= read -r file; do + if [[ -n "$file" && -e "$file" ]]; then + echo -e " ${YELLOW}${ICON_WARNING}${NC} Review only: $file" + fi + done <<< "$review_system_display" done # Confirmation before requesting sudo. - local app_total=${#selected_apps[@]} + local app_total=${#app_details[@]} local app_text="app" [[ $app_total -gt 1 ]] && app_text="apps" @@ -446,8 +650,7 @@ batch_uninstall_applications() { $'\e' | q | Q) echo "" echo "" - _restore_uninstall_traps - return 0 + return 2 ;; "" | $'\n' | $'\r' | y | Y) echo "" # Move to next line @@ -455,8 +658,7 @@ batch_uninstall_applications() { *) echo "" echo "" - _restore_uninstall_traps - return 0 + return 2 ;; esac @@ -464,30 +666,46 @@ 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 return 1 fi fi +} - # Perform uninstallations with per-app progress feedback - local success_count=0 failed_count=0 - local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove - local -a failed_items=() - local -a success_items=() +# Phase 4: iterate app_details and perform the actual removal for each. +# Tracks per-app failures, warnings (local network, system extensions, +# still-running processes, container leftovers), and the total bytes +# actually freed. Per-app failures do not halt the loop; the surrounding +# trap still terminates the whole pass on SIGINT/SIGTERM. +# Reads: app_details +# Writes: success_count, failed_count, failed_items, success_items, +# success_dock_targets, local_network_warning_apps, +# system_extension_warning_apps, running_at_uninstall_apps, +# total_size_freed, brew_apps_removed, +# files_cleaned, total_items (the latter two via dynamic scope) +_batch_execute_removals() { 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 encoded_review_system encoded_login_item_helpers <<< "$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") + local login_item_helpers=$(decode_file_list "$encoded_login_item_helpers" "$app_name") local reason="" local suggestion="" @@ -506,19 +724,34 @@ batch_uninstall_applications() { local has_system_files="false" [[ -n "$system_files" ]] && has_system_files="true" - stop_launch_services "$bundle_id" "$has_system_files" + stop_launch_services "$bundle_id" "$has_system_files" "$app_path" unregister_app_bundle "$app_path" # Remove from Login Items remove_login_item "$app_name" "$bundle_id" + # Best-effort termination. macOS allows removing a running app bundle + # (the running process keeps using its mmap'd code), so a stuck app + # process must NOT block the uninstall. Track it so we can surface a + # warning at the end without scaring the user with a "failed" status. if ! force_kill_app "$app_name" "$app_path"; then - reason="still running" + running_at_uninstall_apps+=("$app_name") fi - # Remove the application only if not running. - # Stop spinner before any removal attempt (avoids mixed output on errors) - [[ -t 1 ]] && stop_inline_spinner + # Keep the spinner alive through the heavy work. For large apps the + # main bundle delete alone can take many seconds; for apps with + # 50-200 leftover files the per-file Trash moves add even more. The + # message is updated so the user sees which phase is running rather + # than a single static spinner. + if [[ -t 1 && -z "$reason" ]]; then + local _phase_size + _phase_size=$(bytes_to_human "$((total_kb * 1024))") + local _phase_prefix="" + if [[ ${#app_details[@]} -gt 1 ]]; then + _phase_prefix="[$current_index/${#app_details[@]}] " + fi + start_inline_spinner "${_phase_prefix}Removing ${app_name} (${_phase_size})..." + fi local used_brew_successfully=false if [[ -z "$reason" ]]; then @@ -527,15 +760,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 +801,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 +827,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 @@ -592,8 +839,46 @@ batch_uninstall_applications() { # Remove related files if app removal succeeded. if [[ -z "$reason" ]]; then + if [[ -t 1 ]]; then + local _phase_prefix="" + if [[ ${#app_details[@]} -gt 1 ]]; then + _phase_prefix="[$current_index/${#app_details[@]}] " + fi + start_inline_spinner "${_phase_prefix}Cleaning files for ${app_name}..." + fi remove_file_list "$related_files" "false" > /dev/null + # Identify leftovers (silent rm failures, e.g. container directories + # macOS protects via com.apple.provenance xattr). Compute their + # total size in a single du invocation rather than walking each + # path; the source paths that DID move to Trash are already gone + # and would just produce stderr noise we discard. + local leftover_kb=0 + local -a leftover_paths=() + if ! is_uninstall_dry_run; then + 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") + done <<< "$related_files" + + if [[ ${#leftover_paths[@]} -gt 0 ]]; then + local _du_total + _du_total=$(command du -skcP "${leftover_paths[@]}" 2> /dev/null | awk 'END {print $1}') + if [[ "$_du_total" =~ ^[0-9]+$ ]]; then + leftover_kb=$_du_total + fi + fi + fi + + if [[ -t 1 ]]; then + start_inline_spinner "${_phase_prefix}Cleaning system files for ${app_name}..." + fi if [[ "$used_brew_successfully" == "true" ]]; then remove_file_list "$diag_system" "true" > /dev/null else @@ -608,7 +893,7 @@ batch_uninstall_applications() { fi # Defaults writes are side effects that should never run in dry-run mode. - if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then + if mole_is_reverse_dns_bundle_id "$bundle_id"; then if is_uninstall_dry_run; then debug_log "[DRY RUN] Would clear defaults domain: $bundle_id" else @@ -618,17 +903,21 @@ batch_uninstall_applications() { fi # ByHost preferences (machine-specific). + # User-owned plists, so route through user-mode mole_delete to + # avoid prompting for sudo when uninstalling a normal app. 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 - 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" - fi + while IFS= read -r -d '' plist_file; do + mole_delete "$plist_file" "false" || true + done < <(command find "$HOME/Library/Preferences/ByHost" -maxdepth 1 -type f -name "${bundle_id}.*.plist" -print0 2> /dev/null || true) fi fi + bootout_login_item_helpers "$login_item_helpers" + + # All per-app side effects done; tear the spinner down before + # any echo so the success line does not collide with the spinner. + [[ -t 1 ]] && stop_inline_spinner + # Show success if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then @@ -638,13 +927,44 @@ 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") + success_dock_targets+=("$app_path|$bundle_id") + 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 mole_is_reverse_dns_bundle_id "$bundle_id" && [[ -d /Library/SystemExtensions ]]; then + local system_extension_path="" + local has_bundle_system_extension=false + while IFS= read -r -d '' system_extension_path; do + if mole_name_starts_with_bundle_id_boundary "$system_extension_path" "$bundle_id"; then + has_bundle_system_extension=true + break + fi + done < <(command find /Library/SystemExtensions -maxdepth 3 -name "*.systemextension" -print0 2> /dev/null) + if [[ "$has_bundle_system_extension" == "true" ]]; then + system_extension_warning_apps+=("$app_name") + fi + fi else + # Stop spinner before printing the failure line so the error + # message is not painted over by the spinner's next tick. + [[ -t 1 ]] && stop_inline_spinner if [[ -t 1 ]]; then if [[ ${#app_details[@]} -gt 1 ]]; then echo -e "${ICON_ERROR} [$current_index/${#app_details[@]}] ${app_name} ${GRAY}, $reason${NC}" @@ -660,7 +980,16 @@ batch_uninstall_applications() { failed_items+=("$app_name:$reason:${suggestion:-}") fi done +} +# Phase 5+6: assemble the post-removal summary block (success line, failed +# apps, Local Network / system extension / Background Items / still-running +# warnings) and emit it as a single summary block. +# Reads: success_count, failed_count, failed_items, success_items, +# total_size_freed, local_network_warning_apps, +# system_extension_warning_apps, background_items_warning_apps, +# running_at_uninstall_apps +_batch_render_summary() { # Summary local freed_display freed_display=$(bytes_to_human "$((total_size_freed * 1024))") @@ -761,6 +1090,55 @@ 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 + + if [[ ${#background_items_warning_apps[@]} -gt 0 ]]; then + local bg_list="" + local idx + for ((idx = 0; idx < ${#background_items_warning_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && bg_list+=", " + bg_list+="${background_items_warning_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} Background items still registered: ${YELLOW}${bg_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Open ${GRAY}System Settings > General > Login Items & Extensions${NC} and toggle the entry off to clear it") + fi + + if [[ ${#running_at_uninstall_apps[@]} -gt 0 ]]; then + local running_list="" + local idx + for ((idx = 0; idx < ${#running_at_uninstall_apps[@]}; idx++)); do + [[ $idx -gt 0 ]] && running_list+=", " + running_list+="${running_at_uninstall_apps[idx]}" + done + + summary_details+=("${ICON_REVIEW} Still running during uninstall, files removed but process kept alive: ${YELLOW}${running_list}${NC}") + summary_details+=("${GRAY}${ICON_SUBLIST}${NC} Quit the app to free its in-memory copy; reinstalling before quitting may behave oddly") + fi + local title="Uninstall complete" if [[ "$summary_status" == "warn" ]]; then title="Uninstall incomplete" @@ -772,23 +1150,152 @@ batch_uninstall_applications() { echo "" print_summary_block "$title" "${summary_details[@]}" printf '\n' +} + +# Batch uninstall with single confirmation. Orchestrates the four phases +# (scan, preview/confirm, execute, summary) and manages the cross-phase +# shared state, the SIGINT/SIGTERM trap, sudo keepalive, and the deferred +# Dock / LaunchServices refresh. +batch_uninstall_applications() { + local total_size_freed=0 + + # shellcheck disable=SC2154 + if [[ ${#selected_apps[@]} -eq 0 ]]; then + log_warning "No applications selected for uninstallation" + return 0 + fi + + local old_trap_int old_trap_term + old_trap_int=$(trap -p INT) + old_trap_term=$(trap -p TERM) + + _cleanup_sudo_keepalive() { + if command -v stop_sudo_session > /dev/null 2>&1; then + stop_sudo_session + fi + } + + _restore_uninstall_traps() { + _cleanup_sudo_keepalive + if [[ -n "$old_trap_int" ]]; then + # eval: restore previous trap captured by $(trap -p INT) + eval "$old_trap_int" + else + trap - INT + fi + if [[ -n "$old_trap_term" ]]; then + # eval: restore previous trap captured by $(trap -p TERM) + eval "$old_trap_term" + else + trap - TERM + fi + } + + # SIGINT/SIGTERM during a phase helper would normally `return 130` out of + # the helper only; without an explicit signal flag the orchestrator would + # cheerfully run the next phase. The trap sets _batch_interrupted so the + # orchestrator can check after each helper and bail out the way the + # pre-refactor inline implementation did. + local _batch_interrupted=0 + + # Trap to clean up spinner, sudo keepalive, and uninstall mode on interrupt + trap 'stop_inline_spinner 2>/dev/null; _cleanup_sudo_keepalive; unset MOLE_UNINSTALL_MODE; echo ""; _restore_uninstall_traps; _batch_interrupted=1; return 130' INT TERM + + # Pre-scan: running apps, sudo needs, size. + local -a running_apps=() + local -a sudo_apps=() + local -a brew_cask_apps=() + local -a blocked_apps=() + local total_estimated_size=0 + local -a app_details=() + + _batch_scan_app_details + if [[ $_batch_interrupted -eq 1 ]]; then + _restore_uninstall_traps + return 130 + fi + + if [[ ${#app_details[@]} -eq 0 ]]; then + _restore_uninstall_traps + return 1 + fi + + local _confirm_rc=0 + _batch_preview_and_confirm || _confirm_rc=$? + if [[ $_batch_interrupted -eq 1 ]]; then + _restore_uninstall_traps + return 130 + fi + case $_confirm_rc in + 0) ;; + 2) + _restore_uninstall_traps + return 0 + ;; + *) + _restore_uninstall_traps + return 1 + ;; + esac + + # Perform uninstallations with per-app progress feedback + local success_count=0 failed_count=0 + local brew_apps_removed=0 # Track successful brew uninstalls for silent autoremove + local -a failed_items=() + local -a success_items=() + local -a success_dock_targets=() + local -a local_network_warning_apps=() + local -a system_extension_warning_apps=() + # Apps whose process was still running after the kill ladder. We do not + # abort the uninstall for these — macOS allows deleting a running bundle + # (the process keeps using its mmap'd code) — but we warn the user so they + # know to quit/relaunch the lingering process. + local -a running_at_uninstall_apps=() + + _batch_execute_removals + if [[ $_batch_interrupted -eq 1 ]]; then + _restore_uninstall_traps + return 130 + fi + + # Detect stale Background Items entries (System Settings > Login Items & Extensions). + # Modern SMAppService helpers are not removable via osascript and Apple has no + # public CLI to delete individual BTM records, so we only detect + warn. Single + # dumpbtm call per batch, gated by safety env vars and dry-run. + local -a background_items_warning_apps=() + local _btm_dump="" + if [[ ${#success_items[@]} -gt 0 ]] && + ! is_uninstall_dry_run && + [[ "${MOLE_TEST_NO_AUTH:-0}" != "1" && "${MOLE_TEST_MODE:-0}" != "1" ]] && + command -v sfltool > /dev/null 2>&1; then + _btm_dump=$(run_with_timeout "$MOLE_TIMEOUT_PKG_LIST_SEC" sfltool dumpbtm 2> /dev/null || true) + fi + + if [[ -n "$_btm_dump" ]]; then + local _bg_line + while IFS= read -r _bg_line; do + [[ -n "$_bg_line" ]] && background_items_warning_apps+=("$_bg_line") + done < <(_uninstall_match_btm_leftovers "$_btm_dump" "${app_details[@]}" -- "${success_items[@]}") + fi + + _batch_render_summary # Run brew autoremove silently in background to avoid interrupting UX. if [[ $brew_apps_removed -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then ( HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ - run_with_timeout 30 brew autoremove > /dev/null 2>&1 || true + run_with_timeout "$MOLE_TIMEOUT_DISK_VERIFY_SEC" brew autoremove > /dev/null 2>&1 || true ) & disown $! 2> /dev/null || true fi # Clean up Dock entries for uninstalled apps. - if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then + if [[ $success_count -gt 0 && ${#success_dock_targets[@]} -gt 0 ]]; then if is_uninstall_dry_run; then log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries" else ( - remove_apps_from_dock "${success_items[@]}" > /dev/null 2>&1 || true + remove_apps_from_dock "${success_dock_targets[@]}" > /dev/null 2>&1 || true refresh_launch_services_after_uninstall > /dev/null 2>&1 || true ) & disown $! 2> /dev/null || true diff --git a/Resources/mole/lib/uninstall/brew.sh b/Resources/mole/lib/uninstall/brew.sh index 012ca53..1000c69 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 @@ -42,9 +57,10 @@ is_homebrew_available() { _extract_cask_token_from_path() { local path="$1" - # Check if path is inside Caskroom + # Check if path is inside Caskroom. Quote literals so Homebrew bottle + # relocation cannot break parsing when the prefix contains spaces. case "$path" in - /opt/homebrew/Caskroom/* | /usr/local/Caskroom/*) ;; + "/opt/homebrew/Caskroom/"* | "/usr/local/Caskroom/"*) ;; *) return 1 ;; esac @@ -78,6 +94,7 @@ _detect_cask_via_resolved_path() { # Only succeeds if exactly one cask matches (avoids wrong uninstall) _detect_cask_via_caskroom_search() { local app_bundle_name="$1" + local app_path="${2:-}" [[ -z "$app_bundle_name" ]] && return 1 local -a tokens=() @@ -102,6 +119,14 @@ _detect_cask_via_caskroom_search() { # Only succeed if exactly one unique token found and it's installed if ((${#uniq[@]} == 1)) && [[ -n "${uniq[0]}" ]]; then HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -qxF "${uniq[0]}" || return 1 + local info_output + info_output=$(HOMEBREW_NO_ENV_HINTS=1 brew info --cask "${uniq[0]}" 2> /dev/null) || return 1 + if [[ -n "$app_path" ]] && + ! grep -qF "$app_path" <<< "$info_output" && + ! grep -qF "/Applications/$app_bundle_name" <<< "$info_output" && + ! grep -qF "$app_bundle_name" <<< "$info_output"; then + return 1 + fi echo "${uniq[0]}" return 0 fi @@ -129,8 +154,13 @@ _detect_cask_via_brew_list() { local cask_name cask_name=$(HOMEBREW_NO_ENV_HINTS=1 brew list --cask 2> /dev/null | grep -Fix "$app_name_lower") || return 1 - # Verify this cask actually owns this app path - HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null | grep -qF "$app_path" || return 1 + # Verify this cask actually owns this app path or app bundle. + local info_output + info_output=$(HOMEBREW_NO_ENV_HINTS=1 brew info --cask "$cask_name" 2> /dev/null) || return 1 + grep -qF "$app_path" <<< "$info_output" || + grep -qF "/Applications/$app_bundle_name" <<< "$info_output" || + grep -qF "$app_bundle_name" <<< "$info_output" || + return 1 echo "$cask_name" } @@ -154,7 +184,7 @@ get_brew_cask_name() { # Try each detection method in order (fast to slow) _detect_cask_via_resolved_path "$app_path" && return 0 - _detect_cask_via_caskroom_search "$app_bundle_name" && return 0 + _detect_cask_via_caskroom_search "$app_bundle_name" "$app_path" && return 0 _detect_cask_via_symlink_check "$app_path" && return 0 _detect_cask_via_brew_list "$app_path" "$app_bundle_name" && return 0 @@ -193,13 +223,23 @@ brew_uninstall_cask() { debug_log "App size: ${size_gb}GB, timeout: ${timeout}s" fi - # Run with timeout to prevent hangs from problematic cask scripts - local brew_exit=0 - if HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + # Run with timeout to prevent hangs from problematic cask scripts. + if [[ -n "${SUDO_USER:-}" ]]; then + if run_with_timeout "$timeout" sudo -u "$SUDO_USER" env \ + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + brew uninstall --cask --zap "$cask_name" 2>&1; then + uninstall_ok=true + else + brew_exit=$? + fi + elif HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ run_with_timeout "$timeout" brew uninstall --cask --zap "$cask_name" 2>&1; then uninstall_ok=true else brew_exit=$? + fi + + if [[ "$uninstall_ok" != "true" ]]; then debug_log "brew uninstall timeout or failed with exit code: $brew_exit" # Exit code 124 indicates timeout from run_with_timeout # On timeout, fail immediately without verification to avoid inconsistent state @@ -211,7 +251,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..457f828 100755 --- a/Resources/mole/mole +++ b/Resources/mole/mole @@ -7,18 +7,52 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +declare -a MOLE_CLI_ARGS=() +MOLE_CLI_DEBUG=false + +mole_collect_cli_args() { + local arg + + MOLE_CLI_ARGS=() + MOLE_CLI_DEBUG=false + for arg in "$@"; do + case "$arg" in + --debug) + MOLE_CLI_DEBUG=true + ;; + *) + MOLE_CLI_ARGS+=("$arg") + ;; + esac + done +} + +mole_dispatch_history_early() { + [[ "${MOLE_TEST_MODE:-0}" == "1" && "${MOLE_SKIP_MAIN:-0}" == "1" ]] && return 0 + + mole_collect_cli_args "$@" + [[ "${MOLE_CLI_ARGS[0]:-}" == "history" ]] || return 0 + + [[ "$MOLE_CLI_DEBUG" == "true" ]] && export MO_DEBUG=1 + if [[ "${#MOLE_CLI_ARGS[@]}" -gt 1 ]]; then + exec "$SCRIPT_DIR/bin/history.sh" "${MOLE_CLI_ARGS[@]:1}" + fi + exec "$SCRIPT_DIR/bin/history.sh" +} + +mole_dispatch_history_early "$@" + source "$SCRIPT_DIR/lib/core/common.sh" source "$SCRIPT_DIR/lib/core/commands.sh" trap cleanup_temp_files EXIT INT TERM # Version and update helpers -VERSION="1.30.0" +VERSION="1.43.1" 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() { @@ -37,13 +71,34 @@ get_latest_version_from_github() { echo "$version" } +run_brew_command() { + local timeout_seconds="$1" + shift + + HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_AUTO_UPDATE=1 NONINTERACTIVE=1 \ + run_with_timeout "$timeout_seconds" "$@" +} + +run_brew_detect() { + run_brew_command "${MOLE_HOMEBREW_DETECT_TIMEOUT:-2}" "$@" +} + +run_brew_query() { + run_brew_command "${MOLE_HOMEBREW_QUERY_TIMEOUT:-5}" "$@" +} + +brew_mole_formula_installed() { + local brew_cmd="${1:-brew}" + run_brew_detect "$brew_cmd" list mole > /dev/null 2>&1 +} + get_homebrew_latest_version() { command -v brew > /dev/null 2>&1 || return 1 local line candidate="" # Prefer local tap outdated info to avoid notifying before formula is available. - line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew outdated --formula --verbose mole 2> /dev/null | head -1 || true) + line=$(run_brew_query brew outdated --formula --verbose mole 2> /dev/null | head -1 || true) if [[ "$line" == *"< "* ]]; then candidate="${line##*< }" candidate="${candidate%% *}" @@ -51,7 +106,7 @@ get_homebrew_latest_version() { # Fallback for environments where outdated output is unavailable. if [[ -z "$candidate" ]]; then - line=$(HOMEBREW_NO_AUTO_UPDATE=1 brew info mole 2> /dev/null | awk 'NR==1 { print; exit }' || true) + line=$(run_brew_query brew info mole 2> /dev/null | awk 'NR==1 { print; exit }' || true) line="${line#==> }" line="${line#*: }" if [[ "$line" == stable* ]]; then @@ -61,32 +116,43 @@ get_homebrew_latest_version() { [[ -n "$candidate" ]] && printf '%s\n' "$candidate" } -# Install detection (Homebrew vs manual). -# Uses variable capture + string matching to avoid SIGPIPE under pipefail. -is_homebrew_install() { - local mole_path link_target has_brew=false - mole_path=$(command -v mole 2> /dev/null) || return 1 - - if command -v brew > /dev/null 2>&1; then - has_brew=true +resolve_mole_source_path() { + local mole_path="${BASH_SOURCE[0]:-$0}" + if [[ "$mole_path" != /* ]]; then + if [[ "$mole_path" == */* ]]; then + mole_path="$(cd "$(dirname "$mole_path")" 2> /dev/null && pwd)/${mole_path##*/}" + else + mole_path=$(command -v "$mole_path" 2> /dev/null || true) + fi fi + [[ -n "$mole_path" ]] && printf '%s\n' "$mole_path" +} + +is_homebrew_mole_path() { + local mole_path="$1" + local has_brew="$2" + local link_target="" + [[ -n "$mole_path" ]] || return 1 if [[ -L "$mole_path" ]]; then link_target=$(readlink "$mole_path" 2> /dev/null) || true if [[ "$link_target" == *"Cellar/mole"* ]]; then if $has_brew; then - brew list mole > /dev/null 2>&1 && return 0 + brew_mole_formula_installed brew && return 0 fi return 1 fi + return 1 fi if [[ -f "$mole_path" ]]; then + # Paths are quoted so Homebrew bottle relocation cannot break parsing + # when the prefix contains spaces (e.g. Applite under "Application Support"). case "$mole_path" in - /opt/homebrew/bin/mole | /usr/local/bin/mole) - if [[ -d /opt/homebrew/Cellar/mole ]] || [[ -d /usr/local/Cellar/mole ]]; then + "/opt/homebrew/bin/mole" | "/usr/local/bin/mole") + if [[ -d "/opt/homebrew/Cellar/mole" ]] || [[ -d "/usr/local/Cellar/mole" ]]; then if $has_brew; then - brew list mole > /dev/null 2>&1 && return 0 + brew_mole_formula_installed brew && return 0 else return 0 # Cellar exists, probably Homebrew install fi @@ -95,15 +161,21 @@ is_homebrew_install() { esac fi - if $has_brew; then - local brew_prefix - brew_prefix=$(brew --prefix 2> /dev/null) - if [[ -n "$brew_prefix" && "$mole_path" == "$brew_prefix/bin/mole" && -d "$brew_prefix/Cellar/mole" ]]; then - brew list mole > /dev/null 2>&1 && return 0 - fi + return 1 +} + +# Install detection (Homebrew vs manual). +# Always follows the invoked Mole script, never PATH, so update and remove act +# on the Mole the user actually ran instead of another copy earlier in PATH. +is_homebrew_install() { + local has_brew=false + if command -v brew > /dev/null 2>&1; then + has_brew=true fi - return 1 + local mole_path + mole_path=$(resolve_mole_source_path || true) + is_homebrew_mole_path "$mole_path" "$has_brew" } get_install_channel() { @@ -133,6 +205,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/') || sha="" + echo "$sha" +} + # Background update notice check_for_updates() { local msg_cache="$HOME/.cache/mole/update_message" @@ -141,41 +221,49 @@ 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 + + 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" + 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 & ) } -show_update_notification() { - local msg_cache="$HOME/.cache/mole/update_message" - if [[ -f "$msg_cache" && -s "$msg_cache" ]]; then - cat "$msg_cache" - echo - fi -} - # UI helpers show_brand_banner() { cat << EOF @@ -211,7 +299,7 @@ show_version() { fi local disk_free - disk_free=$(df -h / 2> /dev/null | awk 'NR==2 {print $4}' || echo "Unknown") + disk_free=$(get_free_space) local install_method="Manual" if is_homebrew_install; then @@ -260,6 +348,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall" + printf " %s%-28s%s %s\n" "$GREEN" "mo history --json" "$NC" "Export cleanup history" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge" printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup" printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup" @@ -290,25 +379,53 @@ update_mole() { if is_homebrew_install; then if [[ "$nightly_update" == "true" ]]; then - log_error "Nightly update is only available for script installations" - echo -e "${ICON_REVIEW} Homebrew installs follow stable releases." - echo -e "${ICON_REVIEW} Reinstall via script to use: ${GRAY}mo update --nightly${NC}" + local review_icon="${ICON_REVIEW:-☞}" + log_error "Nightly update is only available for script installations. Homebrew installs follow stable releases." + printf '%s Reinstall via script to use: mo update --nightly\n' "$review_icon" exit 1 fi update_via_homebrew "$VERSION" exit 0 fi + # Resolve the invoked Mole up front so the installer targets this manual + # install, not another mole earlier in PATH. Fail before any download. + local mole_path + if ! mole_path=$(resolve_mole_source_path); then + log_error "Unable to resolve current Mole path" + exit 1 + fi + local install_dir + if ! install_dir="$(cd "$(dirname "$mole_path")" && pwd)"; then + log_error "Unable to resolve current Mole install directory" + exit 1 + fi + local latest="" local download_label="Downloading latest version..." local install_label="Installing update..." local final_success_label="latest version" + local switch_to_stable_channel=false if [[ "$nightly_update" == "true" ]]; then latest="main" download_label="Downloading nightly installer..." install_label="Installing nightly update..." final_success_label="nightly build (main)" + + if [[ "$force_update" != "true" ]]; then + local installed_commit latest_commit + installed_commit=$(get_install_commit) + latest_commit=$(get_latest_commit_from_github) + + if [[ "$installed_commit" =~ ^[0-9a-f]{7,40}$ && "$latest_commit" =~ ^[0-9a-f]{40}$ && + "${installed_commit:0:7}" == "${latest_commit:0:7}" ]]; then + echo "" + echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest nightly, ${latest_commit:0:7}" + echo "" + exit 0 + fi + fi else latest=$(get_latest_version_from_github) [[ -z "$latest" ]] && latest=$(get_latest_version) @@ -319,8 +436,21 @@ update_mole() { echo -e "${ICON_REVIEW} Try again with: ${GRAY}mo update${NC}" exit 1 fi + if [[ ! "$latest" =~ ^[Vv]?[0-9]+(\.[0-9]+)*$ ]]; then + log_error "Invalid version response: $latest" + echo -e "${ICON_REVIEW} Try again later or use: ${GRAY}mo update --nightly${NC}" + exit 1 + fi + + local install_channel + install_channel=$(get_install_channel) + if [[ "$install_channel" == "nightly" || "$install_channel" == "dev" ]]; then + switch_to_stable_channel=true + fi - if [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then + if [[ "$switch_to_stable_channel" == "true" ]]; then + install_label="Switching to stable channel..." + elif [[ "$VERSION" == "$latest" && "$force_update" != "true" ]]; then echo "" echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version, ${VERSION}" echo "" @@ -334,7 +464,11 @@ update_mole() { echo "${download_label%...}" fi - local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh" + local installer_ref="main" + if [[ "$nightly_update" != "true" ]]; then + installer_ref="V${latest#V}" + fi + local installer_url="https://raw.githubusercontent.com/tw93/mole/${installer_ref}/install.sh" local tmp_installer tmp_installer="$(mktemp_file)" || { log_error "Update failed" @@ -379,11 +513,6 @@ update_mole() { if [[ -t 1 ]]; then stop_inline_spinner; fi chmod +x "$tmp_installer" - local mole_path - mole_path="$(command -v mole 2> /dev/null || echo "$0")" - local install_dir - install_dir="$(cd "$(dirname "$mole_path")" && pwd)" - local requires_sudo="false" if [[ ! -w "$install_dir" ]]; then requires_sudo="true" @@ -455,7 +584,7 @@ update_mole() { echo "$install_output" | tail -10 >&2 # Show last 10 lines of error exit 1 fi - elif [[ "$force_update" == "true" ]]; then + elif [[ "$force_update" == "true" || "$switch_to_stable_channel" == "true" ]]; then if install_output=$(MOLE_VERSION="$update_tag" "$tmp_installer" --prefix "$install_dir" --config "$config_dir" 2>&1); then process_install_output "$install_output" "$latest" "$final_success_label" else @@ -494,6 +623,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 +640,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_mole_formula_installed "$brew_cmd"; 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 +691,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 +753,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 +768,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="" @@ -656,7 +809,7 @@ remove_mole() { for install in "${manual_installs[@]}"; do if [[ -f "$install" ]]; then if [[ ! -w "$(dirname "$install")" ]]; then - if ! sudo rm -f "$install" 2> /dev/null; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! sudo rm -f "$install" 2> /dev/null; then has_error=true fi else @@ -671,7 +824,7 @@ remove_mole() { for alias in "${alias_installs[@]}"; do if [[ -f "$alias" ]]; then if [[ ! -w "$(dirname "$alias")" ]]; then - if ! sudo rm -f "$alias" 2> /dev/null; then + if [[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]] || ! sudo rm -f "$alias" 2> /dev/null; then has_error=true fi else @@ -688,6 +841,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 @@ -731,13 +887,13 @@ show_main_menu() { printf '\r\033[2K%s\n' "$(show_menu_option 1 "Clean Free up disk space" "$([[ $selected -eq 1 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 2 "Uninstall Remove apps completely" "$([[ $selected -eq 2 ]] && echo true || echo false)")" - printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Check and maintain system" "$([[ $selected -eq 3 ]] && echo true || echo false)")" + printf '\r\033[2K%s\n' "$(show_menu_option 3 "Optimize Refresh caches and services" "$([[ $selected -eq 3 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 4 "Analyze Explore disk usage" "$([[ $selected -eq 4 ]] && echo true || echo false)")" printf '\r\033[2K%s\n' "$(show_menu_option 5 "Status Monitor system health" "$([[ $selected -eq 5 ]] && echo true || echo false)")" 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 @@ -772,6 +928,12 @@ interactive_main_menu() { exit 0 } + launch_menu_command() { + show_cursor + drain_pending_input 0.1 + exec "$@" + } + trap cleanup_and_exit INT hide_cursor @@ -790,34 +952,28 @@ interactive_main_menu() { "UP") ((current_option > 1)) && ((current_option--)) ;; "DOWN") ((current_option < 5)) && ((current_option++)) ;; "ENTER") - show_cursor case $current_option in - 1) exec "$SCRIPT_DIR/bin/clean.sh" ;; - 2) exec "$SCRIPT_DIR/bin/uninstall.sh" ;; - 3) exec "$SCRIPT_DIR/bin/optimize.sh" ;; - 4) exec "$SCRIPT_DIR/bin/analyze.sh" ;; - 5) exec "$SCRIPT_DIR/bin/status.sh" ;; + 1) launch_menu_command "$SCRIPT_DIR/bin/clean.sh" ;; + 2) launch_menu_command "$SCRIPT_DIR/bin/uninstall.sh" ;; + 3) launch_menu_command "$SCRIPT_DIR/bin/optimize.sh" ;; + 4) launch_menu_command "$SCRIPT_DIR/bin/analyze.sh" ;; + 5) launch_menu_command "$SCRIPT_DIR/bin/status.sh" ;; esac ;; "CHAR:1") - show_cursor - exec "$SCRIPT_DIR/bin/clean.sh" + launch_menu_command "$SCRIPT_DIR/bin/clean.sh" ;; "CHAR:2") - show_cursor - exec "$SCRIPT_DIR/bin/uninstall.sh" + launch_menu_command "$SCRIPT_DIR/bin/uninstall.sh" ;; "CHAR:3") - show_cursor - exec "$SCRIPT_DIR/bin/optimize.sh" + launch_menu_command "$SCRIPT_DIR/bin/optimize.sh" ;; "CHAR:4") - show_cursor - exec "$SCRIPT_DIR/bin/analyze.sh" + launch_menu_command "$SCRIPT_DIR/bin/analyze.sh" ;; "CHAR:5") - show_cursor - exec "$SCRIPT_DIR/bin/status.sh" + launch_menu_command "$SCRIPT_DIR/bin/status.sh" ;; "MORE") show_cursor @@ -852,19 +1008,14 @@ interactive_main_menu() { # CLI dispatch main() { local -a args=() - for arg in "$@"; do - case "$arg" in - --debug) - export MO_DEBUG=1 - ;; - *) - args+=("$arg") - ;; - esac - done + mole_collect_cli_args "$@" + [[ "$MOLE_CLI_DEBUG" == "true" ]] && export MO_DEBUG=1 + if [[ "${#MOLE_CLI_ARGS[@]}" -gt 0 ]]; then + args=("${MOLE_CLI_ARGS[@]}") + fi case "${args[0]:-""}" in - "optimize") + "optimize" | "optimise") exec "$SCRIPT_DIR/bin/optimize.sh" "${args[@]:1}" ;; "clean") @@ -873,7 +1024,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/audit_bundle_drift.sh b/Resources/mole/scripts/audit_bundle_drift.sh new file mode 100755 index 0000000..e46c440 --- /dev/null +++ b/Resources/mole/scripts/audit_bundle_drift.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Bundle drift audit. +# +# Enumerates bundle IDs of all apps in /System/Applications and /Applications +# on the current macOS host, then reports any that are NOT covered by +# SYSTEM_CRITICAL_BUNDLES / DATA_PROTECTED_BUNDLES. +# +# Intent: when a new macOS major release adds a system component (e.g. Apple +# Intelligence introduced new daemons), this script surfaces it so we can +# decide whether to add it to the protection list. +# +# This is a HINT, not an enforcement. New bundle IDs in /Applications may be +# legitimately user-installed (Slack, Discord, etc.) and should NOT be in the +# critical list. Review each match by hand. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck source=lib/core/common.sh +source "$PROJECT_ROOT/lib/core/common.sh" + +list_bundle_ids() { + local dir="$1" + [[ -d "$dir" ]] || return 0 + find "$dir" -maxdepth 2 -name '*.app' -print 2> /dev/null | while IFS= read -r app; do + local info="$app/Contents/Info.plist" + [[ -f "$info" ]] || continue + plutil -extract CFBundleIdentifier raw "$info" 2> /dev/null || true + done +} + +is_covered() { + local bundle="$1" + local pattern + for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do + if bundle_matches_pattern "$bundle" "$pattern"; then + return 0 + fi + done + return 1 +} + +echo "Mole bundle drift audit" +echo "macOS: $(sw_vers -productVersion 2> /dev/null || echo unknown)" +echo + +declare -a system_uncovered=() +declare -a apps_uncovered=() + +while IFS= read -r bundle; do + [[ -n "$bundle" ]] || continue + if ! is_covered "$bundle"; then + system_uncovered+=("$bundle") + fi +done < <(list_bundle_ids /System/Applications | sort -u) + +while IFS= read -r bundle; do + [[ -n "$bundle" ]] || continue + if ! is_covered "$bundle"; then + apps_uncovered+=("$bundle") + fi +done < <(list_bundle_ids /Applications | sort -u) + +echo "=== /System/Applications NOT in protection lists ===" +if [[ ${#system_uncovered[@]} -gt 0 ]]; then + printf ' %s\n' "${system_uncovered[@]}" + echo + echo "ACTION: Add legitimately-system bundle IDs above to SYSTEM_CRITICAL_BUNDLES" + echo " in lib/core/app_protection_data.sh after review." +else + echo " (none -- all system apps covered)" +fi +echo + +echo "=== /Applications NOT in protection lists (informational) ===" +if [[ ${#apps_uncovered[@]} -gt 0 ]]; then + printf ' %s\n' "${apps_uncovered[@]}" + echo + echo "NOTE: These are user-installed apps. Most should NOT be protected." + echo " Only add if they hold sensitive data that must survive cleanup." +else + echo " (none)" +fi + +if [[ ${#system_uncovered[@]} -gt 0 ]]; then + exit 2 +fi diff --git a/Resources/mole/scripts/check.sh b/Resources/mole/scripts/check.sh index 20e3dff..d6d3d44 100755 --- a/Resources/mole/scripts/check.sh +++ b/Resources/mole/scripts/check.sh @@ -44,11 +44,20 @@ done cd "$PROJECT_ROOT" -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' +# Honor https://no-color.org: any non-empty NO_COLOR disables ANSI escapes. +if [[ -n "${NO_COLOR:-}" ]]; then + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +else + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' +fi readonly ICON_SUCCESS="✓" readonly ICON_ERROR="☻" @@ -76,11 +85,11 @@ if [[ "$MODE" == "format" ]]; then if command -v goimports > /dev/null 2>&1; then echo -e "${YELLOW}Formatting Go code, goimports...${NC}" - goimports -w -local github.com/tw93/Mole ./cmd + goimports -w -local github.com/tw93/mole ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" elif command -v go > /dev/null 2>&1; then echo -e "${YELLOW}Formatting Go code, gofmt...${NC}" - gofmt -w ./cmd + gofmt -w ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting complete${NC}\n" else echo -e "${YELLOW}${ICON_WARNING} go not installed, skipping gofmt${NC}\n" @@ -101,11 +110,11 @@ if [[ "$MODE" != "check" ]]; then if command -v goimports > /dev/null 2>&1; then echo -e "${YELLOW}2. Formatting Go code, goimports...${NC}" - goimports -w -local github.com/tw93/Mole ./cmd + goimports -w -local github.com/tw93/mole ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" elif command -v go > /dev/null 2>&1; then echo -e "${YELLOW}2. Formatting Go code, gofmt...${NC}" - gofmt -w ./cmd + gofmt -w ./cmd ./internal echo -e "${GREEN}${ICON_SUCCESS} Go formatting applied${NC}\n" fi fi @@ -116,15 +125,17 @@ if command -v golangci-lint > /dev/null 2>&1; then echo -e "${RED}${ICON_ERROR} golangci-lint config invalid${NC}\n" exit 1 fi - if golangci-lint run ./cmd/...; then + if golangci-lint run ./...; then echo -e "${GREEN}${ICON_SUCCESS} golangci-lint passed${NC}\n" else echo -e "${RED}${ICON_ERROR} golangci-lint failed${NC}\n" + echo -e "${YELLOW}If the output points to deleted temporary worktrees or non-existent paths, run:${NC}" + echo -e "${YELLOW} golangci-lint cache clean && golangci-lint run ./...${NC}\n" exit 1 fi elif command -v go > /dev/null 2>&1; then echo -e "${YELLOW}${ICON_WARNING} golangci-lint not installed, falling back to go vet${NC}" - if go vet ./cmd/...; then + if go vet ./...; then echo -e "${GREEN}${ICON_SUCCESS} go vet passed${NC}\n" else echo -e "${RED}${ICON_ERROR} go vet failed${NC}\n" @@ -136,7 +147,7 @@ fi echo -e "${YELLOW}4. Running ShellCheck...${NC}" if command -v shellcheck > /dev/null 2>&1; then - if shellcheck mole bin/*.sh lib/*/*.sh scripts/*.sh; then + if shellcheck mole install.sh bin/*.sh lib/*/*.sh scripts/*.sh; then echo -e "${GREEN}${ICON_SUCCESS} ShellCheck passed${NC}\n" else echo -e "${RED}${ICON_ERROR} ShellCheck failed${NC}\n" @@ -151,12 +162,22 @@ if ! bash -n mole; then echo -e "${RED}${ICON_ERROR} Syntax check failed, mole${NC}\n" exit 1 fi +if ! bash -n install.sh; then + echo -e "${RED}${ICON_ERROR} Syntax check failed, install.sh${NC}\n" + exit 1 +fi for script in bin/*.sh; do if ! bash -n "$script"; then echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" exit 1 fi done +for script in scripts/*.sh; do + if ! bash -n "$script"; then + echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" + exit 1 + fi +done find lib -name "*.sh" | while read -r script; do if ! bash -n "$script"; then echo -e "${RED}${ICON_ERROR} Syntax check failed, $script${NC}\n" @@ -165,57 +186,4 @@ find lib -name "*.sh" | while read -r script; do done echo -e "${GREEN}${ICON_SUCCESS} Syntax check passed${NC}\n" -echo -e "${YELLOW}6. Checking optimizations...${NC}" -OPTIMIZATION_SCORE=0 -TOTAL_CHECKS=0 - -((TOTAL_CHECKS++)) -if grep -q "read -r -s -n 1 -t 1" lib/core/ui.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Keyboard timeout configured${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Keyboard timeout may be misconfigured${NC}" -fi - -((TOTAL_CHECKS++)) -DRAIN_PASSES=$(grep -c "while IFS= read -r -s -n 1" lib/core/ui.sh 2> /dev/null || true) -DRAIN_PASSES=${DRAIN_PASSES:-0} -if [[ $DRAIN_PASSES -eq 1 ]]; then - echo -e "${GREEN} ${ICON_SUCCESS} drain_pending_input optimized${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} drain_pending_input has multiple passes${NC}" -fi - -((TOTAL_CHECKS++)) -if grep -q "rotate_log_once" lib/core/log.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Log rotation optimized${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Log rotation not optimized${NC}" -fi - -((TOTAL_CHECKS++)) -if ! grep -q "cache_meta\|cache_dir_mtime" bin/uninstall.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Cache validation simplified${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Cache still uses redundant metadata${NC}" -fi - -((TOTAL_CHECKS++)) -if grep -q "Consecutive slashes" bin/clean.sh; then - echo -e "${GREEN} ${ICON_SUCCESS} Path validation enhanced${NC}" - ((OPTIMIZATION_SCORE++)) -else - echo -e "${YELLOW} ${ICON_WARNING} Path validation not enhanced${NC}" -fi - -echo -e "${BLUE} Optimization score: $OPTIMIZATION_SCORE/$TOTAL_CHECKS${NC}\n" - echo -e "${GREEN}=== Checks Completed ===${NC}" -if [[ $OPTIMIZATION_SCORE -eq $TOTAL_CHECKS ]]; then - echo -e "${GREEN}${ICON_SUCCESS} All optimizations applied${NC}" -else - echo -e "${YELLOW}${ICON_WARNING} Some optimizations missing${NC}" -fi diff --git a/Resources/mole/scripts/check_release_minos.sh b/Resources/mole/scripts/check_release_minos.sh new file mode 100755 index 0000000..a5b9bbc --- /dev/null +++ b/Resources/mole/scripts/check_release_minos.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Verify release binaries keep an intentional minimum macOS version. + +set -euo pipefail + +MAX_MINOS="${MAX_RELEASE_MINOS:-13.0}" + +if [[ "${1:-}" == "--max" ]]; then + if [[ $# -lt 2 ]]; then + echo "--max requires a version value" >&2 + exit 1 + fi + MAX_MINOS="$2" + shift 2 +fi + +if [[ -z "$MAX_MINOS" ]]; then + echo "MAX_RELEASE_MINOS must not be empty" >&2 + exit 1 +fi + +if ! command -v otool > /dev/null 2>&1; then + echo "otool is required to inspect Mach-O release binaries" >&2 + exit 1 +fi + +version_gt() { + awk -v left="$1" -v right="$2" ' + BEGIN { + split(left, l, ".") + split(right, r, ".") + for (i = 1; i <= 3; i++) { + lv = (l[i] == "" ? 0 : l[i] + 0) + rv = (r[i] == "" ? 0 : r[i] + 0) + if (lv > rv) exit 0 + if (lv < rv) exit 1 + } + exit 1 + } + ' +} + +extract_minos() { + otool -l "$1" | awk ' + $1 == "cmd" && $2 == "LC_BUILD_VERSION" { + in_build = 1 + next + } + in_build && $1 == "minos" { + print $2 + exit + } + in_build && $1 == "cmd" { + in_build = 0 + } + ' +} + +if [[ $# -eq 0 ]]; then + set -- bin/analyze-darwin-* bin/status-darwin-* +fi + +checked=0 +failed=0 + +for binary in "$@"; do + if [[ ! -f "$binary" ]]; then + continue + fi + + minos=$(extract_minos "$binary") + if [[ -z "$minos" ]]; then + echo "ERROR: $binary has no LC_BUILD_VERSION minos" >&2 + failed=1 + continue + fi + + checked=$((checked + 1)) + if version_gt "$minos" "$MAX_MINOS"; then + echo "ERROR: $binary minos $minos exceeds allowed $MAX_MINOS" >&2 + failed=1 + else + echo "OK: $binary minos $minos <= $MAX_MINOS" + fi +done + +if [[ $checked -eq 0 ]]; then + echo "ERROR: no release binaries found to inspect" >&2 + exit 1 +fi + +exit "$failed" diff --git a/Resources/mole/scripts/test.sh b/Resources/mole/scripts/test.sh index 2cf6fab..33fe4d2 100755 --- a/Resources/mole/scripts/test.sh +++ b/Resources/mole/scripts/test.sh @@ -10,6 +10,63 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" +# Sweep orphaned per-test HOME dirs left behind by killed bats runs. +# Normal teardown removes them; this only catches the ones that escaped. +# 60-minute threshold avoids racing with a long-running test in progress. +if [[ -d "$PROJECT_ROOT/tests" ]]; then + find "$PROJECT_ROOT/tests" -maxdepth 1 -type d -name 'tmp-*' -mmin +60 \ + -exec rm -rf {} + 2> /dev/null || true # SAFE: confined to tests/tmp-* +fi + +# Never allow the scripted test run to trigger real sudo or Touch ID prompts. +export MOLE_TEST_NO_AUTH=1 + +# Tests assert deterministic ANSI escape output. Strip any NO_COLOR the +# developer has set in their shell so the test color-escape assertions +# match regardless of the host environment. +unset NO_COLOR + +TEST_SYSTEM_STUB_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mole-test-stubs.XXXXXX")" +TEST_GO_HELPER_DIR="" +# shellcheck disable=SC2329 # Invoked by trap. +cleanup_test_stubs() { + rm -rf "$TEST_SYSTEM_STUB_DIR" + if [[ -n "$TEST_GO_HELPER_DIR" ]]; then + rm -rf "$TEST_GO_HELPER_DIR" + fi +} +trap cleanup_test_stubs EXIT + +cat > "$TEST_SYSTEM_STUB_DIR/sudo" << 'EOF' +#!/bin/bash +case "${1:-}" in + -k) + exit 0 + ;; + -n) + exit 1 + ;; +esac + +printf 'mole test blocked sudo: %s\n' "$*" >&2 +exit 1 +EOF + +cat > "$TEST_SYSTEM_STUB_DIR/osascript" << 'EOF' +#!/bin/bash +printf 'mole test blocked osascript: %s\n' "$*" >&2 +exit 1 +EOF + +cat > "$TEST_SYSTEM_STUB_DIR/launchctl" << 'EOF' +#!/bin/bash +printf 'mole test blocked launchctl: %s\n' "$*" >&2 +exit 0 +EOF + +chmod +x "$TEST_SYSTEM_STUB_DIR/sudo" "$TEST_SYSTEM_STUB_DIR/osascript" "$TEST_SYSTEM_STUB_DIR/launchctl" +export PATH="$TEST_SYSTEM_STUB_DIR:$PATH" + # shellcheck source=lib/core/file_ops.sh source "$PROJECT_ROOT/lib/core/file_ops.sh" @@ -20,6 +77,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 +100,39 @@ report_unit_result() { fi } +enforce_timeout_dependency_in_ci + +GO_TEST_CACHE="${MOLE_GO_TEST_CACHE:-/tmp/mole-go-build-cache}" +export MOLE_GO_TEST_CACHE="$GO_TEST_CACHE" + +test_selection_needs_go_helpers() { + local test_file + for test_file in "$@"; do + case "$test_file" in + tests | ./tests | */tests | tests/cli.bats | ./tests/cli.bats | */tests/cli.bats) + return 0 + ;; + esac + done + return 1 +} + +prepare_go_test_helpers() { + command -v go > /dev/null 2>&1 || return 0 + + TEST_GO_HELPER_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mole-go-helpers.XXXXXX")" + mkdir -p "$GO_TEST_CACHE" + + if GOCACHE="$GO_TEST_CACHE" go build -o "$TEST_GO_HELPER_DIR/analyze-go" ./cmd/analyze > /dev/null 2>&1 && + GOCACHE="$GO_TEST_CACHE" go build -o "$TEST_GO_HELPER_DIR/status-go" ./cmd/status > /dev/null 2>&1; then + export MOLE_TEST_ANALYZE_BIN="$TEST_GO_HELPER_DIR/analyze-go" + export MOLE_TEST_STATUS_BIN="$TEST_GO_HELPER_DIR/status-go" + else + rm -rf "$TEST_GO_HELPER_DIR" + TEST_GO_HELPER_DIR="" + fi +} + echo "1. Linting test scripts..." if command -v shellcheck > /dev/null 2>&1; then TEST_FILES=() @@ -94,55 +198,118 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then set -- tests fi fi + if test_selection_needs_go_helpers "$@"; then + prepare_go_test_helpers + fi use_color=false 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 + + bats_help="$(bats --help 2>&1 || true)" + bats_has_jobs=false + bats_has_formatter=false + if grep -q -- "--jobs" <<< "$bats_help"; then + bats_has_jobs=true + fi + if grep -q -- "--formatter" <<< "$bats_help"; then + bats_has_formatter=true + fi + + # Enable parallel execution across test files when Bats and its backend support it. + # Cap at 6 jobs by default to balance speed vs. system load during CI. + bats_opts=() + if $bats_has_jobs && { command -v parallel > /dev/null 2>&1 || command -v rush > /dev/null 2>&1; }; then + _ncpu="$(sysctl -n hw.logicalcpu 2> /dev/null || nproc 2> /dev/null || echo 4)" + if [[ "${MOLE_TEST_JOBS:-}" =~ ^[0-9]+$ && "${MOLE_TEST_JOBS:-0}" -gt 0 ]]; then + _jobs="$MOLE_TEST_JOBS" + else + _jobs="$((_ncpu > 6 ? 6 : (_ncpu < 2 ? 2 : _ncpu)))" + fi + # --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 + if [[ "${MOLE_TEST_TIMING:-0}" == "1" ]]; then + bats_opts+=("--timing") + fi + + # Some test files include wall-clock timing assertions that are skewed by + # CPU contention from parallel test workers. When parallel mode is active, + # split them out to run sequentially after the parallel batch completes. + _sequential_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 | *regression.bats) _sequential_files+=("$_f") ;; + *) _rest+=("$_f") ;; + esac + done < <(find "${_all[0]}" -type f -name '*.bats' | sort) + else + for _f in "${_all[@]}"; do + case "$_f" in + *core_performance.bats | *regression.bats) _sequential_files+=("$_f") ;; + *) _rest+=("$_f") ;; + esac + done + fi + 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_has_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 - report_unit_result 1 + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" || _unit_rc=1 fi else - if bats --formatter tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi + # Pretty format for local development + bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter "$formatter" "$@" || _unit_rc=1 fi else - # Pretty format for local development - if bats --formatter "$formatter" "$@"; 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 - 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 - else - report_unit_result 1 - fi + fi + + # Post-run: timing-sensitive tests run after parallel workers have finished + # so CPU contention does not skew wall-clock assertions. + for _pf in ${_sequential_files[@]+"${_sequential_files[@]}"}; do + if [[ "${MOLE_TEST_TIMING:-0}" == "1" ]]; then + bats --timing "$_pf" || _unit_rc=1 else - if bats --tap "$@"; then - report_unit_result 0 - else - report_unit_result 1 - fi + bats "$_pf" || _unit_rc=1 fi - fi + done + unset _sequential_files _pf + + report_unit_result "$_unit_rc" else printf "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n" fi @@ -150,11 +317,10 @@ echo "" echo "3. Running Go tests..." if command -v go > /dev/null 2>&1; then - GO_TEST_CACHE="${MOLE_GO_TEST_CACHE:-/tmp/mole-go-build-cache}" mkdir -p "$GO_TEST_CACHE" if GOCACHE="$GO_TEST_CACHE" go build ./... > /dev/null 2>&1 && - GOCACHE="$GO_TEST_CACHE" go vet ./cmd/... > /dev/null 2>&1 && - GOCACHE="$GO_TEST_CACHE" go test ./cmd/... > /dev/null 2>&1; then + GOCACHE="$GO_TEST_CACHE" go vet ./... > /dev/null 2>&1 && + GOCACHE="$GO_TEST_CACHE" go test ./... > /dev/null 2>&1; then printf "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n" else printf "${RED}${ICON_ERROR} Go tests failed${NC}\n" @@ -185,37 +351,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/.gitignore b/Resources/mole/tests/.gitignore new file mode 100644 index 0000000..233d20b --- /dev/null +++ b/Resources/mole/tests/.gitignore @@ -0,0 +1,5 @@ +# Per-test temporary HOME directories created by bats setup_file. +# These are cleaned in teardown_file but get left behind when bats is +# killed mid-run (Ctrl-C, OOM, kill -9). scripts/test.sh removes +# orphaned ones older than 60 minutes at startup. +tmp-*/ diff --git a/Resources/mole/tests/brew_uninstall.bats b/Resources/mole/tests/brew_uninstall.bats index d3aab74..1d1388e 100644 --- a/Resources/mole/tests/brew_uninstall.bats +++ b/Resources/mole/tests/brew_uninstall.bats @@ -9,14 +9,27 @@ 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() { - rm -rf "$HOME" - export HOME="$ORIGINAL_HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi mkdir -p "$HOME/Applications" mkdir -p "$HOME/Library/Caches" # Create fake Caskroom @@ -74,6 +87,37 @@ EOF [[ "$result" == "not_found" ]] } +@test "brew list fallback requires brew info to mention the app" { + mkdir -p "$HOME/Applications/Owned.app" "$HOME/Applications/Other.app" + + 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" + +brew() { + case "$*" in + "list --cask") + printf '%s\n' "owned" + ;; + "info --cask owned") + printf '%s\n' "app \"/Applications/Owned.app\"" + ;; + *) + return 1 + ;; + esac +} +export -f brew + +owned=$(_detect_cask_via_brew_list "$HOME/Applications/Owned.app" "Owned.app") +[[ "$owned" == "owned" ]] +! _detect_cask_via_brew_list "$HOME/Applications/Other.app" "Other.app" +EOF + + [ "$status" -eq 0 ] +} + @test "batch_uninstall_applications uses brew uninstall for casks (mocked)" { # Setup fake app local app_bundle="$HOME/Applications/BrewApp.app" @@ -97,6 +141,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 +165,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 +194,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 +214,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,9 +242,14 @@ 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; } +brew() { + echo "BREW_CALL:$*" >> "$HOME/timeout_calls.log" + return 0 +} run_with_timeout() { local duration="$1" @@ -224,37 +279,192 @@ 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" -debug_log() { :; } -get_path_size_kb() { echo "0"; } -run_with_timeout() { local _timeout="$1"; shift; "$@"; } +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 + +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 +} +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 + +! grep -q "UNEXPECTED_ENSURE_SUDO:" "$HOME/dry_run.log" 2> /dev/null +EOF + + [ "$status" -eq 0 ] +} + +@test "brew_uninstall_cask passes cask token as argv without shell evaluation" { + 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" + +debug_log() { :; } +get_path_size_kb() { echo "100"; } +run_with_timeout() { shift; "$@"; } +is_brew_cask_installed() { return 1; } + +brew() { + printf '<%s>\n' "$@" >> "$HOME/brew_argv.log" return 0 - fi - return 0 } -export -f sudo brew +export -f brew + +cask_name='bad"; touch "$HOME/pwned"; #' +brew_uninstall_cask "$cask_name" -brew_uninstall_cask "mock-cask" -echo "DONE" +[[ ! -e "$HOME/pwned" ]] +grep -Fx '' "$HOME/brew_argv.log" 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..ca0ff93 --- /dev/null +++ b/Resources/mole/tests/bundle_resolver.bats @@ -0,0 +1,214 @@ +#!/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 + # Safety: refuse to operate if mktemp failed. + if [[ "$FAKE_HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: FAKE_HOME is not a test temp dir: %s\n' "$FAKE_HOME" >&2 + return 1 + fi + 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() { + if [[ "$FAKE_HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$FAKE_HOME" + fi +} + +# 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 < "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code old version|$claude_support/claude-code/2.1.140" + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code VM old version|$claude_support/claude-code-vm/2.1.140" + assert_output_not_contains "$claude_support/claude-code/2.1.142" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.142" + assert_output_not_contains "$claude_support/claude-code/2.1.150" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.150" +} + +@test "clean_dev_ai_agents keeps active Claude Desktop bundled version even when it is older" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.140" > "$claude_support/claude-code-vm/.sdk-version" + touch -t 202604010000 "$claude_support/claude-code/2.1.140" "$claude_support/claude-code-vm/2.1.140" + touch -t 202604150000 "$claude_support/claude-code/2.1.142" "$claude_support/claude-code-vm/2.1.142" + touch -t 202604250000 "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code old version|$claude_support/claude-code/2.1.142" + assert_output_contains "SAFE_CLEAN:Claude Desktop bundled Claude Code VM old version|$claude_support/claude-code-vm/2.1.142" + assert_output_not_contains "$claude_support/claude-code/2.1.140" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.140" + assert_output_not_contains "$claude_support/claude-code/2.1.150" + assert_output_not_contains "$claude_support/claude-code-vm/2.1.150" +} + +@test "clean_dev_ai_agents leaves single Claude Desktop bundled version alone" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.150" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_not_contains "Claude Desktop bundled Claude Code" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop bundled versions when active version is unknown" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "active version unknown · skipping cleanup" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop bundled versions when sdk version is path-like" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + echo "../2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "active version unknown · skipping cleanup" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop cleanup when active version is missing from one bundled root" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.142" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.142" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "active version unknown · skipping cleanup" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop cleanup when only one bundled root can identify active version" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "active version unknown · skipping cleanup" + assert_output_not_contains "SAFE_CLEAN:" +} + +@test "clean_dev_ai_agents skips Claude Desktop bundled versions while Claude Desktop is running" { + local claude_support="$HOME/Library/Application Support/Claude" + mkdir -p "$claude_support/claude-code/2.1.140" "$claude_support/claude-code/2.1.150" + mkdir -p "$claude_support/claude-code-vm/2.1.140" "$claude_support/claude-code-vm/2.1.150" + echo "2.1.150" > "$claude_support/claude-code-vm/.sdk-version" + + 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 "SAFE_CLEAN:$2|$1"; } +pgrep() { + [[ "$1" == "-x" && "$2" == "Claude" ]] +} +clean_dev_ai_agents +EOF + + assert_run_success + assert_output_contains "Claude Desktop bundled Claude Code cleanup skipped · Claude Desktop is running" + assert_output_not_contains "SAFE_CLEAN:" +} diff --git a/Resources/mole/tests/clean_app_caches.bats b/Resources/mole/tests/clean_app_caches.bats index 067664f..fa0a7a3 100644 --- a/Resources/mole/tests/clean_app_caches.bats +++ b/Resources/mole/tests/clean_app_caches.bats @@ -10,11 +10,17 @@ 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" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -33,11 +39,10 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Xcode is running"* ]] [[ "$output" != *"derived data"* ]] - [[ "$output" != *"archives"* ]] [[ "$output" != *"documentation cache"* ]] } -@test "clean_xcode_tools cleans documentation caches when Xcode is not running" { +@test "clean_xcode_tools cleans documentation caches but not archives when Xcode is not running" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" @@ -49,25 +54,41 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Xcode derived data"* ]] - [[ "$output" == *"Xcode archives"* ]] + [[ "$output" != *"Xcode archives"* ]] [[ "$output" == *"Xcode documentation cache"* ]] [[ "$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,26 +101,130 @@ 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_final_cut_pro_generated_caches targets only safe generated media in Movies libraries" { + 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" + +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/Proxy Media" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/High Quality Media" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Analysis Files/Stabilization" +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Original Media/Render Files/High Quality Media" +mkdir -p "$HOME/Documents/Other.fcpbundle/Event/Render Files/High Quality Media" + +touch "$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media/render.mov" +touch "$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/Proxy Media/proxy.mov" + +pgrep() { return 1; } +safe_clean() { + local arg + for arg in "$@"; do + printf 'CLEAN:%s\n' "$arg" + done +} + +clean_final_cut_pro_generated_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media"* ]] + [[ "$output" == *"CLEAN:$HOME/Movies/Project.fcpbundle/Event/Transcoded Media/Proxy Media"* ]] + [[ "$output" == *"CLEAN:Final Cut Pro generated cache"* ]] + [[ "$output" != *"Transcoded Media/High Quality Media"* ]] + [[ "$output" != *"Analysis Files"* ]] + [[ "$output" != *"Original Media"* ]] + [[ "$output" != *"Documents/Other.fcpbundle"* ]] +} + +@test "clean_final_cut_pro_generated_caches skips while Final Cut Pro 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" + +mkdir -p "$HOME/Movies/Project.fcpbundle/Event/Render Files/High Quality Media" +pgrep() { return 0; } +safe_clean() { + echo "unexpected safe_clean" + return 1 +} + +clean_final_cut_pro_generated_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Final Cut Pro is running"* ]] + [[ "$output" != *"unexpected safe_clean"* ]] +} + +@test "is_final_cut_pro_generated_cache_target rejects protected sibling 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/app_caches.sh" + +library="$HOME/Movies/Project.fcpbundle" +mkdir -p "$library/Event/Render Files/High Quality Media" +mkdir -p "$library/Event/Original Media/Render Files/High Quality Media" +mkdir -p "$library/Event/Transcoded Media/High Quality Media" + +is_final_cut_pro_generated_cache_target "$library" "$library/Event/Render Files/High Quality Media" +! is_final_cut_pro_generated_cache_target "$library" "$library/Event/Original Media/Render Files/High Quality Media" +! is_final_cut_pro_generated_cache_target "$library" "$library/Event/Transcoded Media/High Quality Media" +EOF + + [ "$status" -eq 0 ] } @test "clean_ai_apps calls expected caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } +note_activity() { :; } clean_ai_apps EOF [ "$status" -eq 0 ] [[ "$output" == *"ChatGPT cache"* ]] [[ "$output" == *"Claude desktop cache"* ]] + [[ "$output" == *"Google Clearcut logs"* ]] + [[ "$output" != *"Codex"* ]] +} + +@test "clean_ai_apps skips Codex Desktop state by default" { + mkdir -p "$HOME/Library/Application Support/Codex/Cache" "$HOME/Library/Logs/com.openai.codex" + + 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" +safe_clean() { echo "$2"; } +note_activity() { echo "NOTE_ACTIVITY"; } +clean_ai_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex Desktop state · skipped by default"* ]] + [[ "$output" == *"NOTE_ACTIVITY"* ]] + [[ "$output" != *"Codex cache"* ]] + [[ "$output" != *"Codex CLI logs"* ]] } @test "clean_design_tools calls expected caches" { @@ -118,6 +243,7 @@ EOF @test "clean_dingtalk calls expected caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail +mkdir -p ~/Library/Application\ Support/iDingTalk source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } clean_dingtalk @@ -184,6 +310,7 @@ EOF @test "clean_communication_apps includes Microsoft Teams legacy caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail +mkdir -p ~/Library/Application\ Support/Microsoft/Teams source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } clean_communication_apps @@ -197,6 +324,9 @@ EOF @test "clean_gaming_platforms includes steam and minecraft related caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail +mkdir -p ~/Library/Application\ Support/Steam ~/Library/Application\ Support/Battle.net +mkdir -p ~/Library/Application\ Support/minecraft ~/.lunarclient +mkdir -p ~/Library/Application\ Support/PCSX2 ~/Library/Application\ Support/rpcs3 source "$PROJECT_ROOT/lib/clean/app_caches.sh" safe_clean() { echo "$2"; } clean_gaming_platforms @@ -208,3 +338,444 @@ 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_code_editors includes VS Code WebStorage CacheStorage only" { + mkdir -p "$HOME/Library/Application Support/Code/WebStorage/29/CacheStorage/uuid-1" + mkdir -p "$HOME/Library/Application Support/Code/WebStorage/29/Local Storage" + touch "$HOME/Library/Application Support/Code/WebStorage/29/QuotaManager" + + 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 "CLEAN:$1|$2"; } +clean_code_editors +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Application Support/Code/WebStorage/29/CacheStorage/uuid-1|VS Code webview cache"* ]] + [[ "$output" != *"Local Storage"* ]] + [[ "$output" != *"QuotaManager"* ]] +} + +@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 +mkdir -p ~/Library/Application\ Support/stremio +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"* ]] +} + +@test "clean_video_players cleans SenPlayer videoCache but not sibling data (#1070)" { + local sen="$HOME/Library/Containers/com.wuziqi.SenPlayer/Data" + mkdir -p "$sen/tmp/videoCache" "$sen/Documents" + touch "$sen/tmp/videoCache/segment.mp4" "$sen/Documents/saved.mp4" + + 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() { local arg; for arg in "$@"; do printf 'CLEAN:%s\n' "$arg"; done; } +clean_video_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Containers/com.wuziqi.SenPlayer/Data/tmp/videoCache/segment.mp4"* && + "$output" != *"SenPlayer/Data/Documents"* ]] +} + +@test "clean_productivity_apps cleans Folo Cache_Data but not sibling data (#1070)" { + local folo="$HOME/Library/Containers/is.follow/Data/Library/Application Support/Folo" + mkdir -p "$folo/Cache/Cache_Data" + touch "$folo/Cache/Cache_Data/blob" "$folo/Cache/other.bin" "$folo/db.sqlite" + + 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() { local arg; for arg in "$@"; do printf 'CLEAN:%s\n' "$arg"; done; } +clean_productivity_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/Library/Containers/is.follow/Data/Library/Application Support/Folo/Cache/Cache_Data/blob"* && + "$output" != *"Folo/Cache/other.bin"* && + "$output" != *"db.sqlite"* ]] +} + +@test "clean_editor_obsolete_extensions removes only dirs listed in .obsolete (#910)" { + local ext_root="$HOME/.vscode/extensions" + mkdir -p "$ext_root/pub.ext-old-1.0.0" "$ext_root/pub.ext-new-1.1.0" + cat > "$ext_root/.obsolete" << 'JSON' +{ + "pub.ext-old-1.0.0": true +} +JSON + + 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 "CLEAN:$1"; } +clean_editor_obsolete_extensions +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$HOME/.vscode/extensions/pub.ext-old-1.0.0"* ]] + [[ "$output" != *"pub.ext-new-1.1.0"* ]] +} + +@test "clean_editor_obsolete_extensions rejects path-traversal keys in .obsolete (#910)" { + rm -rf "$HOME/.vscode" "$HOME/.vscode-insiders" "$HOME/.cursor" + local ext_root="$HOME/.cursor/extensions" + mkdir -p "$ext_root" + mkdir -p "$HOME/obsolete-victim" + cat > "$ext_root/.obsolete" << 'JSON' +{ + "../../obsolete-victim": true, + "..": true +} +JSON + + 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 "CLEAN:$1"; } +clean_editor_obsolete_extensions +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"CLEAN:"* ]] +} + +@test "clean_code_editors includes CodeBuddy Extension caches when directory exists" { + mkdir -p "$HOME/Library/Application Support/CodeBuddyExtension" + + 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" == *"CodeBuddy Extension cache"* ]] + [[ "$output" == *"CodeBuddy Extension logs"* ]] +} + +@test "clean_code_editors includes CodeBuddy CN caches when directory exists" { + mkdir -p "$HOME/Library/Application Support/CodeBuddy CN" + + 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" == *"CodeBuddy CN cache"* ]] + [[ "$output" == *"CodeBuddy CN logs"* ]] + [[ "$output" == *"CodeBuddy CN GPU cache"* ]] +} + +@test "clean_code_editors skips CodeBuddy when directories are absent" { + rm -rf "$HOME/Library/Application Support/CodeBuddyExtension" "$HOME/Library/Application Support/CodeBuddy CN" + + 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" != *"CodeBuddy"* ]] +} + +@test "clean_media_players includes QQ Music Mac container caches" { + mkdir -p "$HOME/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac" + + 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_media_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QQ Music Mac cache"* ]] + [[ "$output" == *"QQ Music streaming cache"* ]] + [[ "$output" == *"QQ Music logs"* ]] + [[ "$output" == *"QQ Music container cache"* ]] +} + +@test "clean_media_players does not reference iDownloadProxy" { + mkdir -p "$HOME/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac" + + 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 "$1 $2"; } +clean_media_players +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"iDownloadProxy"* ]] +} + +@test "clean_video_players includes Tencent Video container caches" { + mkdir -p "$HOME/Library/Containers/com.tencent.tenvideo/Data/Library/Application Support" + + 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" == *"Tencent Video old installer"* ]] + [[ "$output" == *"Tencent Video native cache"* ]] + [[ "$output" == *"Tencent Video document cache"* ]] +} + +@test "clean_productivity_apps includes Spacedrive thumbnail cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +safe_clean() { echo "$2"; } +clean_productivity_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Spacedrive thumbnail cache"* ]] +} + +@test "clean_neatdm_stale_segments removes segments older than threshold" { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + rm -rf "$neatdm_dir" + mkdir -p "$neatdm_dir/12345" + touch "$neatdm_dir/12345/seg.x0" + # Set mtime to 31 days ago + touch -t "$(date -v-31d '+%Y%m%d%H%M.%S')" "$neatdm_dir/12345/seg.x0" + + 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" +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"NeatDM stale downloads"* ]] + [[ "$output" == *"1 items"* ]] +} + +@test "clean_neatdm_stale_segments skips recent segments" { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + rm -rf "$neatdm_dir" + mkdir -p "$neatdm_dir/67890" + touch "$neatdm_dir/67890/seg.x0" + + 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" +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"NeatDM stale downloads"* ]] +} + +@test "clean_neatdm_stale_segments skips non-numeric segment-like directories" { + local neatdm_dir="$HOME/Library/Application Support/com.NeatDownloadManager" + rm -rf "$neatdm_dir" + mkdir -p "$neatdm_dir/history-backup" + touch "$neatdm_dir/history-backup/seg.x0" + touch -t "$(date -v-31d '+%Y%m%d%H%M.%S')" "$neatdm_dir/history-backup/seg.x0" + + 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" +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"NeatDM stale downloads"* ]] +} + +@test "clean_neatdm_stale_segments skips when directory absent" { + rm -rf "$HOME/Library/Application Support/com.NeatDownloadManager" + + 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" +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_neatdm_stale_segments +EOF + + [ "$status" -eq 0 ] + [[ -z "$output" ]] +} + +@test "clean_launcher_apps does not touch Raycast cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" /bin/bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/app_caches.sh" +mkdir -p "$HOME/Library/Caches/com.raycast.macos/urlcache" +mkdir -p "$HOME/Library/Caches/com.raycast.macos/fsCachedData" +safe_clean() { echo "CLEAN:$2|$1"; } +clean_launcher_apps +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Raycast"* ]] && [[ "$output" != *"raycast"* ]] +} diff --git a/Resources/mole/tests/clean_apps.bats b/Resources/mole/tests/clean_apps.bats index 0fafe10..0e07d83 100644 --- a/Resources/mole/tests/clean_apps.bats +++ b/Resources/mole/tests/clean_apps.bats @@ -10,11 +10,17 @@ 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" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -28,8 +34,8 @@ 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 @@ -40,6 +46,30 @@ 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 +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;32m✓\033[0m'* ]] } @test "scan_installed_apps uses cache when fresh" { @@ -59,6 +89,97 @@ 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 "scan_installed_apps keeps find traversal options before predicates" { + rm -f "$HOME/.cache/mole/installed_apps_cache" + 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" + +stub_dir="$HOME/stub-bin" +mkdir -p "$stub_dir" "$HOME/Applications/Ordered.app/Contents" +cat > "$stub_dir/find" <<'SH' +#!/bin/sh +root="$1" +shift +if [ "${1:-}" != "-maxdepth" ] || + [ "${2:-}" != "3" ] || + [ "${3:-}" != "-type" ] || + [ "${4:-}" != "d" ] || + [ "${5:-}" != "-name" ] || + [ "${6:-}" != "*.app" ]; then + exit 64 +fi + +if [ "$root" = "$HOME/Applications" ]; then + printf '%s\n' "$HOME/Applications/Ordered.app" +fi +SH +chmod +x "$stub_dir/find" + +cat > "$HOME/Applications/Ordered.app/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Ordered + + +PLIST + +debug_log() { :; } +export PATH="$stub_dir:$PATH" +scan_installed_apps "$HOME/installed.txt" +cat "$HOME/installed.txt" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"com.example.Ordered"* ]] +} + @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 +461,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 +557,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" @@ -385,6 +575,7 @@ sudo() { if [[ "$1" == "-n" && "$2" == "true" ]]; then return 0 fi + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "find" ]]; then printf '%s\0' "$tmp_plist" return 0 @@ -412,143 +603,572 @@ EOF [[ "$output" != *"launchctl-called"* ]] } -@test "is_launch_item_orphaned detects orphan when program missing" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_system_services reads unreadable plists through sudo PlistBuddy" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 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" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.orphan.plist" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } +should_protect_path() { return 1; } -cat > "$tmp_plist" << 'PLIST' +tmp_dir="$(mktemp -d)" +tmp_binary="$tmp_dir/live-helper" +tmp_plist="$tmp_dir/com.example.live-helper.plist" +touch "$tmp_binary" +cat > "$tmp_plist" < Label - com.test.orphan - ProgramArguments - - /nonexistent/app/program - + com.example.live-helper + Program + $tmp_binary PLIST +chmod 000 "$tmp_plist" -run_with_timeout() { shift; "$@"; } +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "/usr/libexec/PlistBuddy" ]]; then + case "$3" in + "Print :ProgramArguments:0") return 1 ;; + "Print :Program") printf '%s\n' "$tmp_binary"; return 0 ;; + esac + return 1 + fi + command "$@" +} -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" -fi +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Found 1 orphaned"* ]] || return 1 + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 +} + +@test "clean_orphaned_system_services does not count protected skips as cleaned" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false MOLE_DRY_RUN=0 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" -rm -rf "$tmp_dir" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +should_protect_path() { return 0; } +safe_sudo_remove() { + echo "unexpected-remove" + return 0 +} + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.sogou.test.plist" +touch "$tmp_plist" + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "unexpected-launchctl" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services EOF [ "$status" -eq 0 ] - [[ "$output" == *"orphan"* ]] + [[ "$output" == *"skipped 1 protected, failed 0"* ]] + [[ "$output" != *"Cleaned 1 orphaned services"* ]] + [[ "$output" != *"unexpected-remove"* ]] + [[ "$output" != *"unexpected-launchctl"* ]] } -@test "is_launch_item_orphaned protects when program exists" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_system_services protects AmneziaWG helpers" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false MOLE_DRY_RUN=0 bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } +bundle_has_installed_app() { return 1; } +safe_sudo_remove() { + echo "unexpected-remove" + return 0 +} + tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.active.plist" -tmp_program="$tmp_dir/program" -touch "$tmp_program" +tmp_helper="$tmp_dir/org.amnezia.awg" +touch "$tmp_helper" -cat > "$tmp_plist" << PLIST - - - - - Label - com.test.active - ProgramArguments - - $tmp_program - - - -PLIST +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/PrivilegedHelperTools) printf '%s\0' "$tmp_helper" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_helper" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + echo "unexpected-launchctl" + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + [ "$status" -eq 0 ] + [[ "$output" == *"skipped 1 protected, failed 0"* ]] + [[ "$output" != *"unexpected-remove"* ]] + [[ "$output" != *"unexpected-launchctl"* ]] +} + +@test "clean_orphaned_system_services removes orphaned helper despite data protection (#1082)" { + # The Docker leftover in #1082 survived because should_protect_data matches + # com.docker.* and blocked cleanup. com.getpostman.* hits the exact same + # should_protect_data branch; orphan cleanup must call should_protect_path in + # uninstall mode so a verified orphan is not blocked by data protection. + # Routed through /Library/LaunchDaemons (always present) rather than + # /Library/PrivilegedHelperTools (absent on CI runners). + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=false MOLE_DRY_RUN=0 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { :; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.getpostman.helper.plist" +# Program points at a missing binary, so the plist is a genuine orphan. +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" 2> /dev/null || true + +removed_marker="$tmp_dir/removed" +safe_sudo_remove() { + echo "removed:$1" + printf '%s\n' "$1" >> "$removed_marker" + return 0 +} + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + if [[ "$1" == "du" ]]; then + echo "4 $tmp_plist" + return 0 + fi + if [[ "$1" == "launchctl" ]]; then + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"Cleaned 1 orphaned"* ]] || return 1 + [[ "$output" == *"removed:"* ]] || return 1 + [[ "$output" != *"skipped 1 protected"* ]] || return 1 +} + +@test "clean_orphaned_system_services dry-run skips protected paths (#886)" { + # MOLE_TEST_NO_AUTH=0 overrides the CI default (=1) so the function actually + # runs past the auth-skip guard in apps.sh; the sudo() mock satisfies the + # `sudo -n true` probe. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } + +should_protect_path() { return 0; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.microsoft.office.licensingV2.helper.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-protected-helper" "$tmp_plist" 2>/dev/null || true + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + # `|| return 1` after each assertion ensures bats fails as soon as one fails + # (bare `[[ ]]` in the middle of a test body gets swallowed by the next + # passing command — see #886 review notes). + [ "$status" -eq 0 ] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"skipped 1 protected"* ]] || return 1 + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 +} + +@test "clean_orphaned_system_services dry-run reports unprotected orphans (#886)" { + # MOLE_TEST_NO_AUTH=0 overrides CI default so the function executes. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } + +should_protect_path() { return 1; } + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.example.unprotected.orphan.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" 2>/dev/null || true + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + command "$@" +} + +clean_orphaned_system_services +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Found 1 orphaned"* ]] || return 1 + [[ "$output" == *"Would remove orphaned service"* ]] || return 1 + [[ "$output" != *"Skipping protected"* ]] || return 1 +} + +@test "clean_orphaned_container_stubs removes stub container when app is uninstalled" { + 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/apps.sh" + +# Stub container: only the metadata plist, no Data/ subdir +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" + +# Canonical app path does not exist (uninstalled) +# mdfind returns nothing (uninstalled) +mdfind() { echo ""; return 0; } run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ ! -d "$stub" ]]; then + echo "PASS: stub removed" else - echo "not-orphan" + echo "FAIL: stub still exists" + exit 1 fi - -rm -rf "$tmp_dir" EOF [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] + [[ "$output" == *"PASS: stub removed"* ]] + [[ "$output" == *"Orphaned app container stubs"* ]] } -@test "is_launch_item_orphaned protects when app support active" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_container_stubs preserves content that appears during removal" { + 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/apps.sh" -tmp_dir="$(mktemp -d)" -tmp_plist="$tmp_dir/com.test.appsupport.plist" +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" -mkdir -p "$HOME/Library/Application Support/TestApp" -touch "$HOME/Library/Application Support/TestApp/recent.txt" +fake_bin="$(mktemp -d "$HOME/fake-bin.XXXXXX")" +cat > "$fake_bin/rm" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +target="" +for arg in "$@"; do + target="$arg" +done +if [[ -n "$target" ]]; then + if [[ -d "$target" ]]; then + touch "$target/raced-content" + else + parent=$(dirname "$target") + touch "$parent/raced-content" + fi +fi +exec /bin/rm "$@" +SH +chmod +x "$fake_bin/rm" +PATH="$fake_bin:$PATH" +export PATH +hash -r + +mdfind() { echo ""; return 0; } +run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } -cat > "$tmp_plist" << 'PLIST' - - - - - Label - com.test.appsupport - ProgramArguments - - $HOME/Library/Application Support/TestApp/Current/app - - - -PLIST +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ -f "$stub/raced-content" ]]; then + echo "PASS: race content preserved" +else + echo "FAIL: race content was deleted" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: race content preserved"* ]] + [[ "$output" == *"could not be removed"* ]] +} +@test "clean_orphaned_container_stubs preserves container when app is installed" { + 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/apps.sh" + +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" + +# Simulate the app installed in a user-level Applications directory. +mkdir -p "$HOME/Applications/CleanMyMac X.app" + +mdfind() { echo ""; return 0; } run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } +files_cleaned=0 +total_items=0 +total_size_cleaned=0 -if is_launch_item_orphaned "$tmp_plist"; then - echo "orphan" +clean_orphaned_container_stubs + +if [[ -d "$stub" ]]; then + echo "PASS: stub preserved" else - echo "not-orphan" + echo "FAIL: stub was wrongly removed" + exit 1 fi -rm -rf "$tmp_dir" -rm -rf "$HOME/Library/Application Support/TestApp" EOF [ "$status" -eq 0 ] - [[ "$output" == *"not-orphan"* ]] + [[ "$output" == *"PASS: stub preserved"* ]] } -@test "clean_orphaned_launch_agents skips when no orphans" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "clean_orphaned_container_stubs preserves container with Data subdirectory" { + 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/apps.sh" + +# Container has a Data/ subtree — real sandbox data, must NOT be deleted +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub/Data/Library/Preferences" +touch "$stub/.com.apple.containermanagerd.metadata.plist" +touch "$stub/Data/Library/Preferences/settings.plist" + +mdfind() { echo ""; return 0; } +run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } + +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ -d "$stub/Data" ]]; then + echo "PASS: data container preserved" +else + echo "FAIL: data container was wrongly removed" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: data container preserved"* ]] +} + +@test "clean_orphaned_container_stubs preserves non-metadata-only container" { + 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/apps.sh" -mkdir -p "$HOME/Library/LaunchAgents" +stub="$HOME/Library/Containers/com.macpaw.CleanMyMac-mas" +mkdir -p "$stub" +touch "$stub/.com.apple.containermanagerd.metadata.plist" +touch "$stub/session.lock" + +mdfind() { echo ""; return 0; } +run_with_timeout() { shift; "$@"; } +note_activity() { :; } +debug_log() { :; } +is_path_whitelisted() { return 1; } + +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +clean_orphaned_container_stubs + +if [[ -f "$stub/session.lock" ]]; then + echo "PASS: non-stub container preserved" +else + echo "FAIL: non-stub container was wrongly removed" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: non-stub container preserved"* ]] +} + +@test "clean_orphaned_system_services tolerates all-whitelisted orphans on /bin/bash 3.2 (#1127)" { + # macOS ships /bin/bash 3.2 (Apple does not upgrade past it, GPLv3) and + # lib/clean/apps.sh runs under `set -u`, where bash 3.2 treats "${empty[@]}" + # as an unbound variable rather than an empty expansion. When orphans are + # found but every one is whitelisted, kept_files ends up empty and the + # whitelist filter's `orphaned_files=("${kept_files[@]}")` aborted the whole + # clean run with "kept_files[@]: unbound variable". Force /bin/bash so the + # 3.2 expansion behaviour is exercised regardless of any newer bash on PATH. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" start_section_spinner() { :; } stop_section_spinner() { :; } note_activity() { :; } -get_path_size_kb() { echo "1"; } -run_with_timeout() { shift; "$@"; } +debug_log() { :; } + +should_protect_path() { return 1; } +# Every detected orphan is whitelisted, so kept_files stays empty. +is_path_whitelisted() { return 0; } +WHITELIST_PATTERNS=("com.example.*") -clean_orphaned_launch_agents +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.example.whitelisted.orphan.plist" +/usr/libexec/PlistBuddy -c "Add :Program string $tmp_dir/missing-binary" "$tmp_plist" 2> /dev/null || true + +sudo() { + if [[ "$1" == "-n" && "$2" == "true" ]]; then + return 0 + fi + [[ "${1:-}" == "-n" ]] && shift + if [[ "$1" == "find" ]]; then + case "$2" in + /Library/LaunchDaemons) printf '%s\0' "$tmp_plist" ;; + *) : ;; + esac + return 0 + fi + command "$@" +} + +clean_orphaned_system_services EOF [ "$status" -eq 0 ] + [[ "$output" != *"unbound variable"* ]] || return 1 + # Whitelisted orphan must be filtered out, so nothing is reported for removal. + [[ "$output" != *"Would remove orphaned service"* ]] || return 1 } diff --git a/Resources/mole/tests/clean_browser_versions.bats b/Resources/mole/tests/clean_browser_versions.bats index b90350a..d5f75de 100644 --- a/Resources/mole/tests/clean_browser_versions.bats +++ b/Resources/mole/tests/clean_browser_versions.bats @@ -1,27 +1,33 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-browser-cleanup.XXXXXX")" + export HOME - mkdir -p "$HOME" + # Prevent AppleScript permission dialogs during tests + 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 + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } @test "clean_chrome_old_versions skips when Chrome is running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + 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" @@ -33,13 +39,35 @@ export -f pgrep clean_chrome_old_versions EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Google Chrome running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Google Chrome running"* ]] + [[ "$output" == *"old versions cleanup skipped"* ]] +} + +@test "clean_chrome_old_versions skips when only Chrome 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/clean/user.sh" + +pgrep() { + case "$*" in + *"Google Chrome Helper"*) return 0 ;; + *) return 1 ;; + esac +} +export -f pgrep + +clean_chrome_old_versions +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Google Chrome running"* ]] + [[ "$output" == *"old versions cleanup skipped"* ]] } @test "clean_chrome_old_versions removes old versions but keeps current" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + 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" @@ -52,6 +80,7 @@ export -f pgrep CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" # Create Current symlink pointing to 130.0.0.0 ln -s "130.0.0.0" "$VERSIONS_DIR/Current" @@ -74,14 +103,14 @@ clean_chrome_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Chrome old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome old versions"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"Cleaned: 2 items"* ]] } @test "clean_chrome_old_versions respects whitelist" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + 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" @@ -94,6 +123,7 @@ export -f pgrep CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" # Create Current symlink pointing to 130.0.0.0 ln -s "130.0.0.0" "$VERSIONS_DIR/Current" @@ -119,12 +149,50 @@ clean_chrome_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Cleaned: 1 items"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Cleaned: 1 items"* ]] +} + +@test "clean_chrome_old_versions keeps newest version even when Current points older" { + rm -rf "$HOME/Applications/Google Chrome.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" + +pgrep() { return 1; } +export -f pgrep + +CHROME_APP="$HOME/Applications/Google Chrome.app" +VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" +mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0,130.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" +touch -t 202601010000 "$VERSIONS_DIR/128.0.0.0" +touch -t 202602010000 "$VERSIONS_DIR/129.0.0.0" +touch -t 202603010000 "$VERSIONS_DIR/130.0.0.0" +ln -s "129.0.0.0" "$VERSIONS_DIR/Current" + +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "10240"; } +bytes_to_human() { echo "10M"; } +note_activity() { :; } +export -f is_path_whitelisted get_path_size_kb bytes_to_human note_activity + +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +clean_chrome_old_versions +echo "Cleaned: $files_cleaned items" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Cleaned: 1 items"* ]] } @test "clean_edge_updater_old_versions keeps latest version" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + 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" @@ -150,26 +218,27 @@ clean_edge_updater_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Edge updater old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Edge updater old versions"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"Cleaned: 2 items"* ]] } @test "clean_chrome_old_versions DRY_RUN mode does not delete files" { - # Create test directory - CHROME_APP="$HOME/Applications/Google Chrome.app" - VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" - mkdir -p "$VERSIONS_DIR"/{128.0.0.0,130.0.0.0} + # Create test directory + CHROME_APP="$HOME/Applications/Google Chrome.app" + VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR"/{128.0.0.0,130.0.0.0} + export MOLE_CHROME_APP_PATHS="$CHROME_APP" - # Remove Current if it exists as a directory, then create symlink - rm -rf "$VERSIONS_DIR/Current" - ln -s "130.0.0.0" "$VERSIONS_DIR/Current" + # Remove Current if it exists as a directory, then create symlink + rm -rf "$VERSIONS_DIR/Current" + ln -s "130.0.0.0" "$VERSIONS_DIR/Current" - # Create a marker file in old version - touch "$VERSIONS_DIR/128.0.0.0/marker.txt" + # Create a marker file in old version + touch "$VERSIONS_DIR/128.0.0.0/marker.txt" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' + 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" @@ -188,17 +257,17 @@ total_items=0 clean_chrome_old_versions EOF - [ "$status" -eq 0 ] - [[ "$output" == *"dry"* ]] - # Verify marker file still exists (not deleted in dry run) - [ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ] + [ "$status" -eq 0 ] + [[ "$output" == *"dry"* ]] + # Verify marker file still exists (not deleted in dry run) + [ -f "$VERSIONS_DIR/128.0.0.0/marker.txt" ] } @test "clean_chrome_old_versions handles missing Current symlink gracefully" { - # Use a fresh temp directory for this test - TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test5.XXXXXX")" + # Use a fresh temp directory for this test + TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test5.XXXXXX")" - run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -219,18 +288,19 @@ total_items=0 CHROME_APP="$HOME/Applications/Google Chrome.app" VERSIONS_DIR="$CHROME_APP/Contents/Frameworks/Google Chrome Framework.framework/Versions" mkdir -p "$VERSIONS_DIR"/{128.0.0.0,129.0.0.0} +export MOLE_CHROME_APP_PATHS="$CHROME_APP" # No Current symlink created clean_chrome_old_versions EOF - rm -rf "$TEST_HOME" - [ "$status" -eq 0 ] - # Should exit gracefully with no output + rm -rf "$TEST_HOME" + [ "$status" -eq 0 ] + # Should exit gracefully with no output } @test "clean_edge_old_versions skips when Edge is running" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + 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" @@ -242,20 +312,20 @@ export -f pgrep clean_edge_old_versions EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Microsoft Edge running"* ]] - [[ "$output" == *"old versions cleanup skipped"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Microsoft Edge running"* ]] + [[ "$output" == *"old versions cleanup skipped"* ]] } @test "clean_edge_old_versions removes old versions but keeps current" { - # Create mock Edge directory structure - local EDGE_APP="$HOME/Applications/Microsoft Edge.app" - local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" - mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} - ln -s "122.0.0.0" "$VERSIONS_DIR/Current" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true \ - MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' + # Create mock Edge directory structure + local EDGE_APP="$HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR"/{120.0.0.0,121.0.0.0,122.0.0.0} + ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true \ + MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -276,24 +346,24 @@ clean_edge_old_versions echo "Cleaned: $files_cleaned items" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Edge old versions"* ]] - [[ "$output" == *"dry"* ]] - [[ "$output" == *"Cleaned: 2 items"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Edge old versions"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"Cleaned: 2 items"* ]] } @test "clean_edge_old_versions handles no old versions gracefully" { - # Use a fresh temp directory for this test - TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")" + # Use a fresh temp directory for this test + TEST_HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-test8.XXXXXX")" - # Create Edge with only current version - local EDGE_APP="$TEST_HOME/Applications/Microsoft Edge.app" - local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" - mkdir -p "$VERSIONS_DIR/122.0.0.0" - ln -s "122.0.0.0" "$VERSIONS_DIR/Current" + # Create Edge with only current version + local EDGE_APP="$TEST_HOME/Applications/Microsoft Edge.app" + local VERSIONS_DIR="$EDGE_APP/Contents/Frameworks/Microsoft Edge Framework.framework/Versions" + mkdir -p "$VERSIONS_DIR/122.0.0.0" + ln -s "122.0.0.0" "$VERSIONS_DIR/Current" - run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" \ - MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' + run env HOME="$TEST_HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_EDGE_APP_PATHS="$EDGE_APP" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" @@ -312,8 +382,8 @@ total_items=0 clean_edge_old_versions EOF - rm -rf "$TEST_HOME" - [ "$status" -eq 0 ] - # Should exit gracefully with no cleanup output - [[ "$output" != *"Edge old versions"* ]] + rm -rf "$TEST_HOME" + [ "$status" -eq 0 ] + # Should exit gracefully with no cleanup output + [[ "$output" != *"Edge old versions"* ]] } 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..25a3745 --- /dev/null +++ b/Resources/mole/tests/clean_cached_device_firmware.bats @@ -0,0 +1,261 @@ +#!/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() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + 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..993f2bd 100644 --- a/Resources/mole/tests/clean_core.bats +++ b/Resources/mole/tests/clean_core.bats @@ -10,17 +10,28 @@ 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" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" rm -rf "${HOME:?}"/* rm -rf "$HOME/Library" "$HOME/.config" @@ -72,11 +83,29 @@ run_clean_dry_run() { [[ "$output" != *"system preview included"* ]] } -@test "mo clean --dry-run includes system preview when sudo is cached" { +@test "mo clean --dry-run does not probe sudo in test mode" { set_mock_sudo_cached + cat > "$TEST_MOCK_BIN/sudo" << 'MOCK' +#!/bin/bash +echo "sudo should not be called" >&2 +exit 99 +MOCK + chmod +x "$TEST_MOCK_BIN/sudo" + run_clean_dry_run [ "$status" -eq 0 ] - [[ "$output" == *"system preview included"* ]] + [[ "$output" == *"sudo -v && mo clean --dry-run"* ]] + [[ "$output" != *"sudo should not be called"* ]] +} + +@test "mo clean rejects removed cleanup selection flags" { + local removed_flag + for removed_flag in "--select" "--categories" "--exclude"; do + run env HOME="$HOME" MOLE_TEST_MODE=1 "$PROJECT_ROOT/mole" clean "$removed_flag" + [ "$status" -eq 1 ] + [[ "$output" == *"was removed in this release"* ]] + [[ "$output" == *"mo clean --dry-run"* ]] + done } @test "mo clean --dry-run shows hint when sudo is not cached" { @@ -87,6 +116,211 @@ run_clean_dry_run() { [[ "$output" == *"full preview"* ]] } +@test "mo clean adopts cached sudo before system cleanup (#1084)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +TRACE="$HOME/sudo-adopt.log" +> "$TRACE" + +source "$PROJECT_ROOT/bin/clean.sh" + +DRY_RUN=false +EXTERNAL_VOLUME_TARGET="" + +sudo() { + printf 'sudo %s\n' "$*" >> "$TRACE" + [[ "${1:-}" == "-n" && "${2:-}" == "-v" ]] +} +_start_sudo_keepalive() { + printf 'keepalive\n' >> "$TRACE" + echo "keepalive-pid" +} +_stop_sudo_keepalive() { :; } + +start_cleanup +cat "$TRACE" +printf 'SYSTEM_CLEAN=%s\n' "$SYSTEM_CLEAN" +printf 'MOLE_SUDO_ESTABLISHED=%s\n' "$MOLE_SUDO_ESTABLISHED" +printf 'MOLE_SUDO_KEEPALIVE_PID=%s\n' "$MOLE_SUDO_KEEPALIVE_PID" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"sudo -n -v"* ]] + [[ "$output" == *"keepalive"* ]] + [[ "$output" == *"SYSTEM_CLEAN=true"* ]] + [[ "$output" == *"MOLE_SUDO_ESTABLISHED=true"* ]] + [[ "$output" == *"MOLE_SUDO_KEEPALIVE_PID=keepalive-pid"* ]] +} + +@test "mo clean sudo prompt preserves a directly typed password (#1059)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +ensure_sudo_session() { + echo "ENSURE_PLAIN" + return 0 +} +ensure_sudo_session_with_password() { + echo "ENSURE_PASSWORD=$1" + [[ "$1" == "secret" ]] +} +drain_pending_input() { :; } +# A user who expects a password prompt may start typing immediately. The first +# printable key and the rest of the line must reach authentication together. +read_key() { + echo "CHAR:s" +} +read_clean_sudo_password_remainder() { + printf -v "$1" '%s' "ecret" +} + +prompt_for_system_clean +printf '\nSYSTEM_CLEAN=%s\n' "$SYSTEM_CLEAN" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"continue"* ]] + [[ "$output" != *"Enter"*"password"* ]] + [[ "$output" == *"ENSURE_PASSWORD=secret"* ]] + [[ "$output" != *"ENSURE_PLAIN"* ]] + [[ "$output" == *"SYSTEM_CLEAN=true"* ]] + [[ "$output" != *"Skipped"* ]] +} + +@test "mo clean sudo prompt still skips on explicit Space (#1059)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +ensure_sudo_session() { + echo "ENSURE_SUDO" + return 0 +} +drain_pending_input() { :; } +read_key() { + echo "SPACE" +} + +prompt_for_system_clean +printf '\nSYSTEM_CLEAN=%s\n' "$SYSTEM_CLEAN" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped"* ]] + [[ "$output" != *"ENSURE_SUDO"* ]] + [[ "$output" == *"SYSTEM_CLEAN=false"* ]] +} + +@test "cloud and office timeout path uses helper function instead of bash -c" { + run bash -c "grep -Eq 'run_with_shell_timeout 300 run_cloud_and_office_cleanup' '$PROJECT_ROOT/bin/clean.sh'" + [ "$status" -eq 0 ] + + run bash -c "! grep -Eq 'run_with_timeout 300[[:space:]]+bash[[:space:]]+-c' '$PROJECT_ROOT/bin/clean.sh'" + [ "$status" -eq 0 ] +} + +@test "mo clean summary separates tracked cleanup from free space change" { + local mock_bin="$HOME/bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/df" <<'MOCK' +#!/bin/bash +count_file="${MOLE_DF_COUNT:?}" +count=0 +if [[ -f "$count_file" ]]; then + count=$(cat "$count_file") +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$count_file" + +available=73400320 +if [[ "$count" -ge 2 ]]; then + available=74400320 +fi + +printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' +printf '/dev/disk1 200000000 126599680 %s 64%% /\n' "$available" +MOCK + chmod +x "$mock_bin/df" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$mock_bin:$PATH" MOLE_DF_COUNT="$HOME/df.count" MOLE_TEST_MODE=0 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" + +DRY_RUN=false +SYSTEM_CLEAN=false +EXTERNAL_VOLUME_TARGET="" +WHITELIST_PATTERNS=() +WHITELIST_WARNINGS=() + +check_tcc_permissions() { :; } +start_section() { :; } +end_section() { :; } +log_operation_session_end() { :; } +run_with_shell_timeout() { shift; "$@"; } + +clean_user_essentials() { + total_size_cleaned=$((total_size_cleaned + 1000000)) + files_cleaned=$((files_cleaned + 1)) + total_items=$((total_items + 1)) +} +clean_finder_metadata() { :; } +clean_app_caches() { :; } +clean_browsers() { :; } +run_cloud_and_office_cleanup() { :; } +clean_developer_tools() { :; } +clean_user_gui_applications() { :; } +clean_virtualization_tools() { :; } +clean_application_support_logs() { :; } +clean_orphaned_app_data() { :; } +clean_orphaned_system_services() { :; } +clean_orphaned_container_stubs() { :; } +clean_stale_launch_services_registrations() { :; } +show_user_launch_agent_hint_notice() { :; } +show_orphan_dotdir_hint_notice() { :; } +clean_apple_silicon_caches() { :; } +clean_cached_device_firmware() { :; } +check_ios_device_backups() { :; } +clean_time_machine_failed_backups() { :; } +check_large_file_candidates() { :; } +show_system_data_hint_notice() { :; } +show_project_artifact_hint_notice() { :; } + +perform_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Free space: 75.16GB"* ]] + [[ "$output" == *"Tracked cleanup:"* ]] + [[ "$output" == *"1.02GB"* ]] + [[ "$output" == *"Free space change: +1.02GB"* ]] + [[ "$output" == *"Free space now: 76.19GB"* ]] + [[ "$output" != *"Space freed:"* ]] + [ "$(cat "$HOME/df.count")" = "2" ] +} + +@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 +332,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" @@ -216,7 +485,11 @@ EOF touch "$HOME/Library/Mail Downloads/old.pdf" touch -t 202301010000 "$HOME/Library/Mail Downloads/old.pdf" - dd if=/dev/zero of="$HOME/Library/Mail Downloads/dummy.dat" bs=1024 count=6000 2>/dev/null + if command -v mkfile > /dev/null 2>&1; then + mkfile -n 6000k "$HOME/Library/Mail Downloads/dummy.dat" + else + truncate -s 6000k "$HOME/Library/Mail Downloads/dummy.dat" + fi [ -f "$HOME/Library/Mail Downloads/old.pdf" ] @@ -322,4 +595,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..b9f3511 100644 --- a/Resources/mole/tests/clean_dev_caches.bats +++ b/Resources/mole/tests/clean_dev_caches.bats @@ -14,13 +14,15 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } -@test "clean_dev_npm cleans orphaned pnpm store" { +@test "clean_dev_npm prunes pnpm store without deleting orphaned global store" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" @@ -47,7 +49,9 @@ clean_dev_npm EOF [ "$status" -eq 0 ] - [[ "$output" == *"Orphaned pnpm store"* ]] + [[ "$output" == *"pnpm cache"* ]] + [[ "$output" != *"Orphaned pnpm store"* ]] + [[ "$output" != *"pnpm store"* ]] } @test "clean_dev_npm cleans default npm residual directories" { @@ -78,6 +82,25 @@ EOF [[ "$output" == *"npm prebuilds|$HOME/.npm/_prebuilds/*"* ]] } +@test "clean_conda_metadata_caches honors package cache whitelist before conda clean" { + 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" +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +WHITELIST_PATTERNS=("$HOME/anaconda3/pkgs") +conda() { echo "conda called"; return 0; } +export -f conda +clean_conda_metadata_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"conda index/tarball/log caches · skipped (whitelist)"* ]] + [[ "$output" != *"conda called"* ]] +} + @test "clean_dev_npm cleans custom npm cache path when detected" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -159,25 +182,406 @@ 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 daemon not running"* ]] - [[ "$output" != *"Docker build cache"* ]] + [[ "$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 BuildX cache|$HOME/.docker/buildx/cache/*"* ]] +} + +@test "clean_dev_docker reports OrbStack data without deleting disk images" { + local orb_data="$HOME/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data" + mkdir -p "$orb_data" + touch "$orb_data/data.img.raw" "$orb_data/swap.img" + + 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" +safe_clean() { printf '%s|%s\n' "$2" "$1"; } +note_activity() { :; } +debug_log() { :; } +get_path_size_kb() { echo "4096"; } +bytes_to_human() { echo "4M"; } +clean_dev_docker +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"OrbStack container data · skipped by default (4M)"* ]] + [[ "$output" == *"Review: docker system df"* ]] + [[ "$output" == *"Prune: docker system prune --filter until=720h"* ]] + [[ "$output" == *"Docker BuildX cache|$HOME/.docker/buildx/cache/*"* ]] + [[ "$output" != *"data.img.raw"* ]] + [[ "$output" != *"swap.img"* ]] +} + +@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_codex_runtimes reports active runtime for manual review" { + mkdir -p "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/runtime.json" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" + + 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" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · manual review"* ]] + [[ "$output" != *"SAFE_CLEAN:Codex CLI runtimes|$HOME/.cache/codex-runtimes/codex-primary-runtime"* ]] +} + +@test "clean_codex_runtimes cleans only stale incomplete runtime dirs" { + mkdir -p "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin" + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/runtime.json" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python" + + 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" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Codex CLI runtimes|$HOME/.cache/codex-runtimes/incomplete-old"* ]] + [[ "$output" != *"SAFE_CLEAN:Codex CLI runtimes|$HOME/.cache/codex-runtimes/codex-primary-runtime"* ]] +} + +@test "clean_codex_runtimes skips all runtimes while Codex is running" { + mkdir -p "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin" + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/runtime.json" + touch "$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" + + 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" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 0; } +is_path_whitelisted() { return 1; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · skipped (Codex running)"* ]] + [[ "$output" != *"SAFE_CLEAN:"* ]] +} + +@test "clean_codex_runtimes respects whitelist" { + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + + 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" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { [[ "$1" == "$HOME/.cache/codex-runtimes"* || "$1" == "$HOME/.cache/codex-runtimes/incomplete-old" ]]; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · skipped (whitelist)"* ]] + [[ "$output" != *"SAFE_CLEAN:"* ]] +} + +@test "clean_codex_runtimes respects child runtime whitelist" { + mkdir -p "$HOME/.cache/codex-runtimes/incomplete-old" + + 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" +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +pgrep() { return 1; } +is_path_whitelisted() { [[ "$1" == "$HOME/.cache/codex-runtimes/incomplete-old" ]]; } +get_path_size_kb() { echo "1024"; } +bytes_to_human() { echo "1M"; } +note_activity() { :; } +clean_codex_runtimes +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Codex runtimes · manual review"* ]] + [[ "$output" == *"Codex runtimes · skipped (whitelist)"* ]] + [[ "$output" != *"SAFE_CLEAN:"* ]] +} + +@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,8 +595,11 @@ clean_homebrew() { echo "brew"; } clean_project_caches() { :; } clean_dev_python() { :; } clean_dev_go() { :; } +clean_dev_mise() { echo "mise"; } clean_dev_rust() { :; } check_rust_toolchains() { :; } +clean_dev_ruby() { :; } +clean_dev_perl() { :; } check_android_ndk() { :; } clean_dev_docker() { :; } clean_dev_cloud() { :; } @@ -211,7 +618,6 @@ clean_dev_misc() { :; } clean_dev_elixir() { :; } clean_dev_haskell() { :; } clean_dev_ocaml() { :; } -clean_dev_editors() { :; } clean_code_editors() { :; } clean_dev_jetbrains_toolbox() { :; } clean_xcode_tools() { :; } @@ -222,9 +628,53 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"npm"* ]] + [[ "$output" == *"mise"* ]] [[ "$output" == *"brew"* ]] } +@test "clean_dev_ruby cleans rbenv, gem, and bundler 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/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_ruby +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"rbenv download cache|"* ]] + [[ "$output" == *"gem spec cache|"* ]] + [[ "$output" == *"gem package cache|"* ]] + [[ "$output" == *"Ruby Bundler cache|"* ]] +} + +@test "clean_dev_perl cleans CPAN build and source 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/dev.sh" +safe_clean() { echo "$2|$1"; } +clean_dev_perl +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CPAN build artifacts|"* ]] + [[ "$output" == *"CPAN source cache|"* ]] +} + +@test "clean_dev_other_langs no longer includes Ruby Bundler cache" { + 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() { echo "$2|$1"; } +clean_dev_other_langs +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Ruby Bundler cache"* ]] +} + @test "clean_project_caches cleans flutter .dart_tool and build directories" { mkdir -p "$HOME/Code/flutter_app/.dart_tool" "$HOME/Code/flutter_app/build" touch "$HOME/Code/flutter_app/.dart_tool/cache.bin" @@ -246,3 +696,184 @@ EOF [[ "$output" == *"Flutter build cache (.dart_tool)"* ]] [[ "$output" == *"Flutter build cache (build/)"* ]] } + +@test "clean_dev_misc includes Chrome DevTools MCP cache when server not running" { + mkdir -p "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache" + touch "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache/data" + + 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() { :; } +note_activity() { :; } +pgrep() { return 1; } +safe_clean() { echo "$2"; } +safe_find_delete() { :; } +clean_service_worker_cache() { :; } +clean_dev_misc +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome DevTools MCP browser cache"* ]] + [[ "$output" != *"Chrome DevTools MCP cache"* ]] +} + +@test "clean_dev_misc skips Chrome DevTools MCP cache when server is running" { + mkdir -p "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache" + touch "$HOME/.cache/chrome-devtools-mcp/chrome-profile/Default/Cache/data" + + 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() { :; } +note_activity() { :; } +pgrep() { return 0; } +safe_clean() { echo "$2"; } +safe_find_delete() { :; } +clean_service_worker_cache() { :; } +clean_dev_misc +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome DevTools MCP caches · skipped"* ]] + [[ "$output" != *"Chrome DevTools MCP browser cache"* ]] +} + +@test "clean_chrome_devtools_mcp_caches preserves profile state" { + profile="$HOME/.cache/chrome-devtools-mcp/chrome-profile" + mkdir -p "$profile/Default/Cache" "$profile/Default/Code Cache" "$profile/Default/GPUCache" + mkdir -p "$profile/Default/Service Worker/CacheStorage" + mkdir -p "$profile/Default/Local Storage/leveldb" + touch "$profile/Default/Cache/data" "$profile/Default/Code Cache/data" "$profile/Default/GPUCache/data" + touch "$profile/Default/Service Worker/CacheStorage/data" + touch "$profile/Default/Cookies" "$profile/Default/Local Storage/leveldb/state" + touch "$profile/Local State" + + 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() { :; } +pgrep() { return 1; } +safe_clean() { echo "SAFE_CLEAN:$2|$1"; } +clean_service_worker_cache() { echo "SWC:$1|$2"; } +clean_chrome_devtools_mcp_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP browser cache|$profile/Default/Cache/"* ]] + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP code cache|$profile/Default/Code Cache/"* ]] + [[ "$output" == *"SAFE_CLEAN:Chrome DevTools MCP GPU cache|$profile/Default/GPUCache/"* ]] + [[ "$output" == *"SWC:Chrome DevTools MCP|$profile/Default/Service Worker/CacheStorage"* ]] + [[ "$output" != *"Cookies"* ]] + [[ "$output" != *"Local Storage"* ]] + [[ "$output" != *"Local State"* ]] +} + +@test "clean_dev_agent_worktrees skips agent worktrees by default and reports size" { + mkdir -p "$HOME/code/proj/.claude/worktrees/wt-one" + mkdir -p "$HOME/code/proj/.claude/worktrees/wt-two" + echo "data" > "$HOME/code/proj/.claude/worktrees/wt-one/file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_AGENT_WORKTREE_PATHS="$HOME/code" \ + bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +safe_clean() { echo "SHOULD_NOT_DELETE:$1"; } +clean_dev_agent_worktrees +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"AI agent worktrees · skipped by default (2 in .claude/worktrees"* ]] + [[ "$output" == *"MOLE_AGENT_WORKTREES=1 mo clean"* ]] + [[ "$output" != *"SHOULD_NOT_DELETE"* ]] +} + +@test "clean_dev_agent_worktrees removes clean worktrees but keeps dirty ones when opted in" { + local origin="$HOME/origin.git" + local proj="$HOME/code/proj" + git init --bare -q "$origin" + git -c init.defaultBranch=main init -q "$proj" + ( + cd "$proj" + git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init + git remote add origin "$origin" + git push -q origin HEAD:main + git worktree add -q .claude/worktrees/clean-one HEAD + git worktree add -q .claude/worktrees/dirty-one HEAD + # Agents lock their worktrees; a plain prune would skip the stale entry. + git worktree lock .claude/worktrees/clean-one + ) + echo "uncommitted" > "$proj/.claude/worktrees/dirty-one/scratch.txt" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_AGENT_WORKTREES=1 MOLE_AGENT_WORKTREE_PATHS="$HOME/code" \ + bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +safe_clean() { echo "REMOVED:$1"; rm -rf "$1"; } +clean_dev_agent_worktrees +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"REMOVED:$proj/.claude/worktrees/clean-one"* ]] + [[ "$output" == *"Kept agent worktree (unsaved work)"* ]] + [[ "$output" == *"dirty-one"* ]] + [[ "$output" != *"REMOVED:$proj/.claude/worktrees/dirty-one"* ]] + [ ! -d "$proj/.claude/worktrees/clean-one" ] + [ -d "$proj/.claude/worktrees/dirty-one" ] + # The stale (locked) registry entry must be reaped, the dirty one kept. + run git -C "$proj" worktree list + [[ "$output" != *"clean-one"* ]] + [[ "$output" == *"dirty-one"* ]] +} + +@test "clean_dev_agent_worktrees keeps worktrees with stashed work when opted in" { + local origin="$HOME/origin-stash.git" + local proj="$HOME/code/proj-stash" + git init --bare -q "$origin" + git -c init.defaultBranch=main init -q "$proj" + ( + cd "$proj" + git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init + git remote add origin "$origin" + git push -q origin HEAD:main + git worktree add -q .claude/worktrees/stashed-one HEAD + cd .claude/worktrees/stashed-one + echo "stashed work" > scratch.txt + git add scratch.txt + git -c user.email=t@t -c user.name=t stash push -q -m "agent scratch" + ) + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_AGENT_WORKTREES=1 MOLE_AGENT_WORKTREE_PATHS="$HOME/code" \ + bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +safe_clean() { echo "REMOVED:$1"; rm -rf "$1"; } +clean_dev_agent_worktrees +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Kept agent worktree (unsaved work)"* ]] + [[ "$output" == *"stashed-one"* ]] + [[ "$output" != *"REMOVED:$proj/.claude/worktrees/stashed-one"* ]] + [ -d "$proj/.claude/worktrees/stashed-one" ] + run git -C "$proj/.claude/worktrees/stashed-one" stash list + [ "$status" -eq 0 ] + [[ "$output" == *"agent scratch"* ]] +} diff --git a/Resources/mole/tests/clean_hints.bats b/Resources/mole/tests/clean_hints.bats index 04ab24f..5c2055d 100644 --- a/Resources/mole/tests/clean_hints.bats +++ b/Resources/mole/tests/clean_hints.bats @@ -12,17 +12,29 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "${HOME:?}"/* + rm -rf "${HOME:?}"/.[!.]* "${HOME:?}"/..?* 2> /dev/null || true 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" @@ -72,6 +84,121 @@ EOT2 [[ "$output" == *"Review: mo purge"* ]] } +@test "show_project_artifact_hint_notice points zero-size samples to include-empty (#869)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOT2B' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +probe_project_artifact_hints() { + PROJECT_ARTIFACT_HINT_DETECTED=true + PROJECT_ARTIFACT_HINT_COUNT=1 + PROJECT_ARTIFACT_HINT_TRUNCATED=false + PROJECT_ARTIFACT_HINT_EXAMPLES=("~/www/demo/node_modules") + PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0 + PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=1 + PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false +} +bytes_to_human() { echo "0B"; } +note_activity() { :; } +show_project_artifact_hint_notice +EOT2B + + [ "$status" -eq 0 ] + [[ "$output" == *"sampled 0B"* ]] + [[ "$output" == *"Review: mo purge --include-empty"* ]] +} + +@test "show_project_artifact_hint_notice reports skipped slow project artifact scans (#1053)" { + local root="$HOME/Library/CloudStorage" + mkdir -p "$root" + printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOT2C' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +run_with_timeout() { + shift + return 124 +} +note_activity() { :; } +show_project_artifact_hint_notice +EOT2C + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped slow project artifact scan"* ]] + [[ "$output" == *"Review: mo purge"* ]] +} + +@test "probe_project_artifact_hints stops at the wall-clock budget (#1053)" { + local root="$HOME/hints-root" + mkdir -p "$root/proj/node_modules" + touch "$root/proj/package.json" + printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TIMEOUT_HINT_SCAN_SEC=0 \ + bash --noprofile --norc << 'EOT2D' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +run_with_timeout() { shift; "$@"; } +probe_project_artifact_hints +printf 'count=%s\n' "$PROJECT_ARTIFACT_HINT_COUNT" +printf 'skipped=%s\n' "$PROJECT_ARTIFACT_HINT_SCAN_SKIPPED" +EOT2D + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] + [[ "$output" == *"skipped=true"* ]] +} + +@test "probe_project_artifact_hints respects budget inside nested-dir loop (#1053)" { + # Regression: old code had no deadline check inside the nested-dir while loop. + # When a single scan root is used the outer-root deadline guard never fires for + # the second time (the loop ends before the next iteration), so the nested loop + # could run unchecked after SECONDS crossed the deadline. + # + # Setup: one root with one project containing two nested sub-projects, each + # with a build/ artifact. hint_collect_child_dirs_with_timeout sleeps 2s on + # the nested call so SECONDS advances past the 1s budget before the nested-dir + # while loop starts. + # + # New code: deadline fires on the FIRST nested-dir iteration → count=0, skipped=true. + # Old code: nested loop runs without a deadline check → count=2, skipped=false. + local root="$HOME/hints-deadline-nested" + mkdir -p "$root/bigproject/sub1/build" + mkdir -p "$root/bigproject/sub2/build" + touch "$root/bigproject/package.json" + printf '%s\n' "$root" > "$HOME/.config/mole/purge_paths" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TIMEOUT_HINT_SCAN_SEC=1 \ + HINTS_ROOT="$root" \ + bash --noprofile --norc << 'EOT_NESTED' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +run_with_timeout() { shift; "$@"; } +hint_collect_child_dirs_with_timeout() { + local dir="$1" out="$2" + if [[ "$dir" == "$HINTS_ROOT" ]]; then + printf '%s\0' "$HINTS_ROOT/bigproject" >> "$out" + else + # Simulate a slow nested find that lets SECONDS cross the 1s budget. + sleep 2 + printf '%s\0' "$HINTS_ROOT/bigproject/sub1" "$HINTS_ROOT/bigproject/sub2" >> "$out" + fi +} +probe_project_artifact_hints +printf 'count=%s\n' "$PROJECT_ARTIFACT_HINT_COUNT" +printf 'skipped=%s\n' "$PROJECT_ARTIFACT_HINT_SCAN_SKIPPED" +EOT_NESTED + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] + [[ "$output" == *"skipped=true"* ]] +} + @test "show_system_data_hint_notice reports large clue paths" { mkdir -p "$HOME/Library/Developer/Xcode/DerivedData" @@ -97,3 +224,436 @@ 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"* ]] +} + +@test "show_user_launch_agent_hint_notice skips MachServices-only plists" { + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$HOME/Library/LaunchAgents/com.google.keystone.agent.plist" <<'PLIST' + + + + + Label + com.google.keystone.agent + MachServices + + com.google.Keystone.Agent + + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOT6' +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 +EOT6 + + [ "$status" -eq 0 ] + [[ "$output" != *"Potential stale login item:"* ]] + [[ "$output" != *"Associated app not found"* ]] +} + +# ---- Orphan dotfile hint tests ---- + +@test "show_orphan_dotdir_hint_notice skips known-safe directories" { + mkdir -p "$HOME/.ssh" "$HOME/.config" "$HOME/.npm" "$HOME/.cargo" "$HOME/.putty" + touch -t 202401010000 "$HOME/.ssh" "$HOME/.config" "$HOME/.npm" "$HOME/.cargo" "$HOME/.putty" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *"Potential orphan dotfile"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips whitelisted directories" { + mkdir -p "$HOME/.custom-orphan-keep" + touch -t 202401010000 "$HOME/.custom-orphan-keep" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +WHITELIST_PATTERNS=("$HOME/.custom-orphan-keep") +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".custom-orphan-keep"* ]] + [[ "$output" != *"Potential orphan dotfile"* ]] +} + +@test "show_orphan_dotdir_hint_notice reports dir with no matching binary" { + mkdir -p "$HOME/.fakecli-test-orphan" + touch -t 202401010000 "$HOME/.fakecli-test-orphan" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *"Potential orphan dotfile"* ]] + [[ "$output" == *".fakecli-test-orphan"* ]] + [[ "$output" == *"No matching binary in PATH"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dotdir owned by installed GUI app (#872)" { + mkdir -p "$HOME/.bridge" + touch -t 202401010000 "$HOME/.bridge" + + local app_path="$HOME/Applications/Proton Mail Bridge.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + ch.protonmail.bridge + CFBundleName + Proton Mail Bridge + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bridge"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips state dir owned by an enabled Claude Code plugin (#889)" { + mkdir -p "$HOME/.cc-safety-net" + touch -t 202401010000 "$HOME/.cc-safety-net" + + mkdir -p "$HOME/.claude" + cat > "$HOME/.claude/settings.json" <<'JSON' +{ + "enabledPlugins": { + "safety-net@cc-marketplace": true + } +} +JSON + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".cc-safety-net"* ]] +} + +@test "show_orphan_dotdir_hint_notice still flags a plugin-shaped dir with no enabled plugin (#889)" { + mkdir -p "$HOME/.cc-safety-net" + touch -t 202401010000 "$HOME/.cc-safety-net" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *".cc-safety-net"* ]] +} + +@test "show_orphan_dotdir_hint_notice survives Claude config that has no plugins (#889)" { + mkdir -p "$HOME/.fakecli-test-orphan" + touch -t 202401010000 "$HOME/.fakecli-test-orphan" + + # Claude Code installed but no plugins: settings.json without + # enabledPlugins and an installed_plugins.json with no plugin tokens. + # The token-collection greps match nothing here, which must not abort + # the hint under `set -euo pipefail`. + mkdir -p "$HOME/.claude/plugins" + echo '{"theme":"dark"}' > "$HOME/.claude/settings.json" + echo '{"marketplaces":{}}' > "$HOME/.claude/plugins/installed_plugins.json" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "1024"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *".fakecli-test-orphan"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dir with existing binary" { + mkdir -p "$HOME/.bash" + touch -t 202401010000 "$HOME/.bash" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bash"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dirs younger than threshold" { + mkdir -p "$HOME/.youngcli-test" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".youngcli-test"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dotdir whose name matches an installed .app token (#872)" { + mkdir -p "$HOME/.bridge" + touch -t 202401010000 "$HOME/.bridge" + + local fake_apps_root="$HOME/fake-Applications" + mkdir -p "$fake_apps_root/Proton Mail Bridge.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" FAKE_APPS_ROOT="$fake_apps_root" \ + bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { return 0; } +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$FAKE_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bridge"* ]] + [[ "$output" != *"Potential orphan dotfile"* ]] +} + +@test "show_orphan_dotdir_hint_notice skips dotdir whose name matches a brew cask token (#872)" { + mkdir -p "$HOME/.bridge" + touch -t 202401010000 "$HOME/.bridge" + + local empty_apps_root="$HOME/empty-Applications" + mkdir -p "$empty_apps_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" EMPTY_APPS_ROOT="$empty_apps_root" \ + bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { + if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then + printf '%s\n' "proton-mail-bridge" "1password" + return 0 + fi + return 0 +} +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$EMPTY_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" != *".bridge"* ]] + [[ "$output" != *"Potential orphan dotfile"* ]] +} + +@test "show_orphan_dotdir_hint_notice still flags dotdir whose name has no matching app or cask (#872)" { + mkdir -p "$HOME/.fakeorphan42xyz" + touch -t 202401010000 "$HOME/.fakeorphan42xyz" + + local empty_apps_root="$HOME/empty-Applications2" + mkdir -p "$empty_apps_root" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" EMPTY_APPS_ROOT="$empty_apps_root" \ + bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { + if [[ "${1:-}" == "list" && "${2:-}" == "--cask" ]]; then + printf '%s\n' "1password" "rectangle" + return 0 + fi + return 0 +} +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$EMPTY_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *"Potential orphan dotfile"* ]] + [[ "$output" == *".fakeorphan42xyz"* ]] +} + +@test "show_orphan_dotdir_hint_notice ignores short app-name tokens (<4 chars) to avoid false matches (#872)" { + # `.ai-old` — token `ai` is 2 chars; an `AI.app` should NOT exempt it. + mkdir -p "$HOME/.ai-old" + touch -t 202401010000 "$HOME/.ai-old" + + local fake_apps_root="$HOME/fake-Applications-short" + mkdir -p "$fake_apps_root/AI.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" FAKE_APPS_ROOT="$fake_apps_root" \ + bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +brew() { return 0; } +export -f brew +_MOLE_DOTDIR_OWNER_APP_ROOTS=("$FAKE_APPS_ROOT") +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + [[ "$output" == *"Potential orphan dotfile"* ]] + [[ "$output" == *".ai-old"* ]] +} + +@test "show_orphan_dotdir_hint_notice limits output to max 5 candidates" { + for i in $(seq 1 8); do + mkdir -p "$HOME/.orphantest${i}" + touch -t 202401010000 "$HOME/.orphantest${i}" + done + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOTD' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/hints.sh" +note_activity() { :; } +run_with_timeout() { shift; "$@"; } +hint_get_path_size_kb_with_timeout() { echo "100"; } +show_orphan_dotdir_hint_notice +EOTD + + [ "$status" -eq 0 ] + local count + count=$(echo "$output" | grep -c "Potential orphan dotfile" || true) + [ "$count" -le 5 ] +} diff --git a/Resources/mole/tests/clean_launch_services.bats b/Resources/mole/tests/clean_launch_services.bats new file mode 100644 index 0000000..446ecf6 --- /dev/null +++ b/Resources/mole/tests/clean_launch_services.bats @@ -0,0 +1,217 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-launch-services-home.XXXXXX")" + TEST_ROOT="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-launch-services-case.XXXXXX")" + export HOME TEST_ROOT +} + +teardown() { + case "${HOME:-}" in + "${BATS_TEST_DIRNAME}/tmp-launch-services-home."*) rm -rf "$HOME" ;; + esac + case "${TEST_ROOT:-}" in + "${BATS_TEST_DIRNAME}/tmp-launch-services-case."*) rm -rf "$TEST_ROOT" ;; + esac +} + +write_lsregister_stub() { + local bin_path="$1" + mkdir -p "$(dirname "$bin_path")" + cat > "$bin_path" <<'SCRIPT' +#!/usr/bin/env bash +{ + printf 'argc=%s\n' "$#" + for arg in "$@"; do + printf 'arg=%s\n' "$arg" + done +} >> "$LSREGISTER_LOG" + +case "${1:-}" in + -dump) + cat "$LSREGISTER_DUMP" + exit 0 + ;; + -u) + exit 0 + ;; +esac +exit 2 +SCRIPT + chmod +x "$bin_path" +} + +@test "clean_stale_launch_services_registrations dry-run reports missing apps without unregistering" { + local lsregister="$TEST_ROOT/bin/lsregister" + local dump_file="$TEST_ROOT/lsregister.dump" + local log_file="$TEST_ROOT/lsregister.log" + local missing_app="$TEST_ROOT/Missing App.app" + local unrelated_missing_app="$TEST_ROOT/Unrelated Missing.app" + local existing_app="$TEST_ROOT/Existing.app" + mkdir -p "$existing_app" + write_lsregister_stub "$lsregister" + + cat > "$dump_file" < "$log_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" LSREGISTER_BIN="$lsregister" LSREGISTER_DUMP="$dump_file" LSREGISTER_LOG="$log_file" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" +get_lsregister_path() { printf '%s\n' "$LSREGISTER_BIN"; } +run_with_timeout() { shift; "$@"; } +note_activity() { printf 'activity\n'; } +DRY_RUN=true +clean_stale_launch_services_registrations +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"LaunchServices stale app registrations"* ]] + [[ "$output" == *"would unregister 1"* ]] + [[ "$output" == *"Missing App.app"* ]] + grep -q 'arg=-dump' "$log_file" + if grep -q 'arg=-u' "$log_file"; then + return 1 + fi +} + +@test "clean_stale_launch_services_registrations unregisters only targeted missing app records" { + local lsregister="$TEST_ROOT/bin/lsregister" + local dump_file="$TEST_ROOT/lsregister.dump" + local log_file="$TEST_ROOT/lsregister.log" + local missing_app="$TEST_ROOT/Missing App.app" + local unrelated_missing_app="$TEST_ROOT/Unrelated Missing.app" + local existing_app="$TEST_ROOT/Existing.app" + mkdir -p "$existing_app" + write_lsregister_stub "$lsregister" + + cat > "$dump_file" < "$log_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" LSREGISTER_BIN="$lsregister" LSREGISTER_DUMP="$dump_file" LSREGISTER_LOG="$log_file" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" +get_lsregister_path() { printf '%s\n' "$LSREGISTER_BIN"; } +run_with_timeout() { shift; "$@"; } +note_activity() { printf 'activity\n'; } +DRY_RUN=false +MOLE_DRY_RUN=0 +clean_stale_launch_services_registrations +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"LaunchServices stale app registrations, 1 removed"* ]] + grep -q 'arg=-dump' "$log_file" + grep -q 'argc=2' "$log_file" + grep -q 'arg=-u' "$log_file" + grep -q "arg=$missing_app" "$log_file" + if grep -q "arg=$existing_app" "$log_file"; then + return 1 + fi + if grep -q "arg=$unrelated_missing_app" "$log_file"; then + return 1 + fi + if grep -q 'arg=-r' "$log_file" || grep -q 'arg=-f' "$log_file"; then + return 1 + fi +} + +@test "launch_services_stale_app_path_is_safe rejects unsafe, live, and malformed paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TEST_ROOT="$TEST_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" + +fail=0 +expect_reject() { + if launch_services_stale_app_path_is_safe "$1"; then + printf 'UNEXPECTED_ACCEPT: %q\n' "$1" + fail=1 + fi +} +expect_accept() { + if ! launch_services_stale_app_path_is_safe "$1"; then + printf 'UNEXPECTED_REJECT: %q\n' "$1" + fail=1 + fi +} + +# Genuinely missing, absolute, .app bundle is the only case that may unregister. +expect_accept "$TEST_ROOT/Gone.app" + +# A live bundle on disk must never be unregistered. +mkdir -p "$TEST_ROOT/Live.app" +expect_reject "$TEST_ROOT/Live.app" + +# Format, protected-root, traversal, and injection rejections. +expect_reject "" +expect_reject "relative/Path.app" +expect_reject "$TEST_ROOT/NotAnApp" +expect_reject "/System/Applications/Gone.app" +expect_reject "/Library/Apple/Gone.app" +expect_reject "$TEST_ROOT/../Gone.app" +expect_reject "$(printf '/tmp/Bad\nName.app')" +expect_reject "$(printf '/tmp/Bad\rName.app')" + +exit $fail +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_stale_launch_services_registrations ignores dump failures" { + local lsregister="$TEST_ROOT/bin/lsregister" + local dump_file="$TEST_ROOT/missing.dump" + local log_file="$TEST_ROOT/lsregister.log" + write_lsregister_stub "$lsregister" + : > "$log_file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" LSREGISTER_BIN="$lsregister" LSREGISTER_DUMP="$dump_file" LSREGISTER_LOG="$log_file" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/launch_services.sh" +get_lsregister_path() { printf '%s\n' "$LSREGISTER_BIN"; } +run_with_timeout() { shift; "$@"; } +note_activity() { printf 'activity\n'; } +DRY_RUN=false +clean_stale_launch_services_registrations +EOF + + [ "$status" -eq 0 ] + [[ "$output" == "" ]] + grep -q 'arg=-dump' "$log_file" +} diff --git a/Resources/mole/tests/clean_misc.bats b/Resources/mole/tests/clean_misc.bats index 31282bb..2ce8536 100644 --- a/Resources/mole/tests/clean_misc.bats +++ b/Resources/mole/tests/clean_misc.bats @@ -10,11 +10,17 @@ 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" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -41,6 +47,29 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/user.sh" stop_section_spinner() { :; } +pgrep() { return 1; } +safe_clean() { echo "$2|$1"; } +clean_virtualization_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"VMware Fusion cache"* ]] + [[ "$output" == *"Parallels cache"* ]] + [[ "$output" == *"UTM app cache|$HOME/Library/Caches/com.utmapp.UTM/"* ]] + [[ "$output" == *"UTM sandbox cache|$HOME/Library/Containers/com.utmapp.UTM/Data/Library/Caches/"* ]] + [[ "$output" == *"UTM temporary files|$HOME/Library/Containers/com.utmapp.UTM/Data/tmp/"* ]] +} + +@test "clean_virtualization_tools skips UTM caches while UTM is running" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +debug_log() { :; } +pgrep() { + [[ "${1:-}" == "-x" && "${2:-}" == "UTM" ]] +} safe_clean() { echo "$2"; } clean_virtualization_tools EOF @@ -48,6 +77,8 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"VMware Fusion cache"* ]] [[ "$output" == *"Parallels cache"* ]] + [[ "$output" != *"UTM app cache"* ]] + [[ "$output" != *"UTM sandbox cache"* ]] } @test "clean_email_clients calls expected caches" { @@ -63,6 +94,20 @@ EOF [[ "$output" == *"Airmail cache"* ]] } +@test "clean_virtualization_tools includes Lima download cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +safe_clean() { echo "$2|$1"; } +clean_virtualization_tools +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Lima download cache|$HOME/Library/Caches/lima/download/by-url-sha256/"* ]] +} + @test "clean_note_apps calls expected caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -89,26 +134,6 @@ EOF [[ "$output" == *"Any.do cache"* ]] } -@test "scan_external_volumes skips when no volumes" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -export DRY_RUN="false" -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/clean/user.sh" -run_with_timeout() { return 1; } -# Mock missing dependencies and UI to ensure test passes regardless of volumes -clean_ds_store_tree() { :; } -start_section_spinner() { :; } -stop_section_spinner() { :; } -is_path_whitelisted() { return 1; } -WHITELIST_PATTERNS=() -PROTECT_FINDER_METADATA="false" -scan_external_volumes -EOF - - [ "$status" -eq 0 ] -} - @test "clean_video_tools calls expected caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail diff --git a/Resources/mole/tests/clean_system_caches.bats b/Resources/mole/tests/clean_system_caches.bats index 0275c70..ba81916 100644 --- a/Resources/mole/tests/clean_system_caches.bats +++ b/Resources/mole/tests/clean_system_caches.bats @@ -17,13 +17,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" @@ -71,11 +78,11 @@ setup() { local test_cache="$HOME/test_sw_cache" mkdir -p "$test_cache" - run bash -c " - run_with_timeout() { shift; \"\$@\"; } - export -f run_with_timeout + run /bin/bash --noprofile --norc -c " 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 +96,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 +116,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" @@ -240,14 +469,14 @@ for arg in "\$@"; do done if [[ "\$root" == "$HOME/SlowProjects" ]]; then trap "" TERM - sleep 30 + sleep 5 exit 0 fi exit 0 EOF chmod +x "$fake_bin/find" - run /usr/bin/perl -e 'alarm 8; exec @ARGV' env -i HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH:/usr/bin:/bin:/usr/sbin:/sbin" TERM="${TERM:-xterm-256color}" bash --noprofile --norc <<'EOF' + run /usr/bin/perl -e 'alarm 5; exec @ARGV' env -i HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH:/usr/bin:/bin:/usr/sbin:/sbin" TERM="${TERM:-xterm-256color}" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/caches.sh" @@ -268,6 +497,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 <> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } get_file_mtime() { echo 0; } get_path_size_kb() { echo 0; } find() { return 0; } @@ -218,6 +224,7 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/system.sh" sudo() { + [[ "${1:-}" == "-n" ]] && shift if [[ "$1" == "test" ]]; then return 0 fi @@ -246,7 +253,6 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } get_file_mtime() { echo 0; } get_path_size_kb() { echo 0; } find() { return 0; } @@ -406,293 +412,109 @@ EOF [[ "$output" == *"Homebrew cleanup"* ]] } -@test "check_appstore_updates is skipped for performance" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -check_appstore_updates -echo "COUNT=$APPSTORE_UPDATE_COUNT" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"COUNT=0"* ]] -} - -@test "check_homebrew_updates reports counts and exports update variables" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -run_with_timeout() { - local timeout="${1:-}" - shift - "$@" -} - -brew() { - if [[ "$1" == "outdated" && "$2" == "--formula" && "$3" == "--quiet" ]]; then - printf "wget\njq\n" - return 0 - fi - if [[ "$1" == "outdated" && "$2" == "--cask" && "$3" == "--quiet" ]]; then - printf "iterm2\n" - return 0 - fi - return 0 -} - -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"2 formula, 1 cask available"* ]] - [[ "$output" == *"COUNTS=3:2:1"* ]] -} - -@test "check_homebrew_updates shows timeout warning when brew query times out" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -run_with_timeout() { return 124; } -brew() { return 0; } -rm -f "$HOME/.cache/mole/brew_updates" - -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"Check timed out"* ]] - [[ "$output" == *"COUNTS=0:0:0"* ]] -} - -@test "check_homebrew_updates shows failure warning when brew query fails" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -run_with_timeout() { return 1; } -brew() { return 0; } -rm -f "$HOME/.cache/mole/brew_updates" - -check_homebrew_updates -echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Homebrew"* ]] - [[ "$output" == *"Check failed"* ]] - [[ "$output" == *"COUNTS=0:0:0"* ]] -} - -@test "check_macos_update avoids slow softwareupdate scans" { +@test "clean_homebrew prevents cleanup from implicitly autoremoving formulae" { 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"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - cat <<'OUT' -Software Update Tool +source "$PROJECT_ROOT/lib/clean/brew.sh" -Software Update found the following new or updated software: -* Label: macOS 99 -OUT - return 0 - fi - return 124 -} +mkdir -p "$HOME/.cache/mole" "$HOME/Library/Caches/Homebrew" +rm -f "$HOME/.cache/mole/brew_last_cleanup" +calls="$HOME/brew_calls.log" +: > "$calls" start_inline_spinner(){ :; } stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update clears update flag when softwareupdate reports no updates" { - 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"; } - +note_activity(){ :; } run_with_timeout() { - local timeout="${1:-}" + local duration="$1" shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - cat <<'OUT' -Software Update Tool - -Finding available software -No new software available. -OUT + printf 'CALL:%s env_no_autoremove=%s\n' "$*" "${HOMEBREW_NO_AUTOREMOVE:-}" >> "$calls" + if [[ "$1" == "du" ]]; then + echo "51201 $3" return 0 fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + "$@" } -@test "check_macos_update keeps update flag when softwareupdate times out" { - 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"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 124 - fi - return 124 +brew() { + case "$*" in + "cleanup --prune=30") + echo "Removing: package" + return 0 + ;; + "autoremove --dry-run") + echo "==> Would autoremove 1 unneeded formula:" + echo "python@3.14" + return 0 + ;; + "autoremove") + echo "REAL_AUTOREMOVE" + return 0 + ;; + *) + return 0 + ;; + esac } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" +clean_homebrew +cat "$calls" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] + [[ "$output" == *"CALL:brew cleanup --prune=30 env_no_autoremove=1"* ]] + [[ "$output" == *"Homebrew autoremove would remove"* ]] + [[ "$output" == *"python@3.14"* ]] + [[ "$output" == *"Homebrew autoremove skipped"* ]] + [[ "$output" == *"CALL:brew autoremove --dry-run"* ]] + [[ "$output" != *"REAL_AUTOREMOVE"* ]] } -@test "check_macos_update keeps update flag when softwareupdate returns empty output" { +@test "clean_homebrew dry-run shows brew autoremove preview without removing formulae" { run bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" +source "$PROJECT_ROOT/lib/clean/brew.sh" -defaults() { echo "1"; } +calls="$HOME/brew_dry_run_calls.log" +: > "$calls" +DRY_RUN=true run_with_timeout() { - local timeout="${1:-}" + local duration="$1" shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 0 - fi - return 124 -} - -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update skips softwareupdate when defaults shows no updates" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "0"; } - -run_with_timeout() { - echo "SHOULD_NOT_CALL_SOFTWAREUPDATE" - return 0 -} - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] - [[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]] + printf 'CALL:%s\n' "$*" >> "$calls" + "$@" } - -@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" - -defaults() { echo "1"; } - -export MO_DEBUG=1 - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - echo "No new software available." - return 0 - fi - return 124 +brew() { + case "$*" in + "autoremove --dry-run") + echo "==> Would autoremove 1 unneeded formula:" + echo "python@3.14" + return 0 + ;; + "autoremove") + echo "REAL_AUTOREMOVE" + return 0 + ;; + *) + return 0 + ;; + esac } -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update 2>&1 +clean_homebrew +cat "$calls" EOF [ "$status" -eq 0 ] - [[ "$output" == *"[DEBUG] softwareupdate exit status:"* ]] + [[ "$output" == *"Homebrew · would cleanup"* ]] + [[ "$output" == *"Homebrew autoremove would remove"* ]] + [[ "$output" == *"python@3.14"* ]] + [[ "$output" == *"CALL:brew autoremove --dry-run"* ]] + [[ "$output" != *"CALL:brew cleanup --prune=30"* ]] + [[ "$output" != *"REAL_AUTOREMOVE"* ]] } @test "run_with_timeout succeeds without GNU timeout" { @@ -712,7 +534,7 @@ EOF PATH="/usr/bin:/bin" unset MO_TIMEOUT_INITIALIZED MO_TIMEOUT_BIN source "'"$PROJECT_ROOT"'/lib/core/common.sh" - run_with_timeout 1 sleep 5 + run_with_timeout 1 sleep 3 ' [ "$status" -eq 124 ] } @@ -881,7 +703,6 @@ safe_sudo_find_delete() { } safe_sudo_remove() { return 0; } log_success() { :; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { shift; "$@"; } @@ -927,7 +748,6 @@ safe_sudo_find_delete() { safe_sudo_remove() { return 0; } log_success() { :; } log_info() { echo "$*"; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { shift; "$@"; } @@ -968,7 +788,6 @@ safe_sudo_find_delete() { } safe_sudo_remove() { return 0; } log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { shift; "$@"; } @@ -1014,7 +833,6 @@ safe_sudo_remove() { log_success() { :; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { shift; "$@"; } @@ -1053,7 +871,6 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { local _timeout="$1" @@ -1099,7 +916,6 @@ safe_sudo_remove() { log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } start_section_spinner() { :; } stop_section_spinner() { :; } -is_sip_enabled() { return 1; } find() { return 0; } run_with_timeout() { local _timeout="$1" @@ -1120,6 +936,141 @@ EOF [[ "$output" != *"SUCCESS:Browser code signature caches"* ]] } +@test "clean_deep_system cleans CleanMyMac-observed rebuildable system caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/rebuildable_cache_calls.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +sudo() { + if [[ "$1" == "test" ]]; then + case "$3" in + /Library/Caches/com.apple.iconservices.store) + return 0 + ;; + esac + return 1 + fi + if [[ "$1" == "find" ]]; then + return 0 + fi + return 0 +} +safe_sudo_find_delete() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +find() { return 0; } +run_with_timeout() { shift; "$@"; } + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"safe_sudo_remove:/Library/Caches/com.apple.iconservices.store"* ]] + [[ "$output" == *"SUCCESS:Rebuildable system caches, 1 item"* ]] +} + +@test "is_rebuildable_gpu_cache_dir only allows C GPU cache shards" { + run env PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/com.apple.metal" +is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/com.apple.metalfe" +is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/com.apple.gpuarchiver" +! is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/T/com.example.App/com.apple.metal" +! is_rebuildable_gpu_cache_dir "/private/var/folders/test/a/C/com.example.App/not-a-gpu-cache" +! is_rebuildable_gpu_cache_dir "/Library/Extensions/com.example.driver/com.apple.metal" +EOF + + [ "$status" -eq 0 ] +} + +@test "gpu_cache_dir_is_stale uses contained file mtimes" { + 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/system.sh" + +stale_dir="$HOME/gpu-stale" +active_dir="$HOME/gpu-active" +mkdir -p "$stale_dir" "$active_dir" +touch "$stale_dir/functions.data" "$active_dir/functions.data" +touch -t 202001010000 "$stale_dir/functions.data" + +gpu_cache_dir_is_stale "$stale_dir" 1 +! gpu_cache_dir_is_stale "$active_dir" 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "clean_deep_system cleans only narrow private var GPU cache shards" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +CALL_LOG="$HOME/gpu_cache_calls.log" +> "$CALL_LOG" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/system.sh" + +sudo() { + if [[ "$1" == "test" ]]; then + return 1 + fi + if [[ "$1" == "find" ]]; then + return 0 + fi + return 0 +} +safe_sudo_find_delete() { return 0; } +safe_sudo_remove() { + echo "safe_sudo_remove:$1" >> "$CALL_LOG" + return 0 +} +log_success() { echo "SUCCESS:$1" >> "$CALL_LOG"; } +start_section_spinner() { :; } +stop_section_spinner() { :; } +find() { return 0; } +gpu_cache_dir_is_stale() { return 0; } +run_with_timeout() { + local _timeout="$1" + shift + if [[ "${1:-}" == "command" && "${2:-}" == "find" && "${3:-}" == "/private/var/folders" ]]; then + printf 'find_args:%s\n' "$*" >> "$CALL_LOG" + printf '%s\0' \ + "/private/var/folders/test/a/C/com.example.App/com.apple.metal" \ + "/private/var/folders/test/a/C/com.example.App/com.apple.metalfe" \ + "/private/var/folders/test/a/C/com.example.App/com.apple.gpuarchiver" \ + "/private/var/folders/test/a/T/com.example.App/com.apple.metal" \ + "/private/var/folders/test/a/C/com.example.App/not-a-gpu-cache" + return 0 + fi + "$@" +} + +clean_deep_system +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.metal"* ]] + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.metalfe"* ]] + [[ "$output" == *"safe_sudo_remove:/private/var/folders/test/a/C/com.example.App/com.apple.gpuarchiver"* ]] + [[ "$output" != *"/private/var/folders/test/a/T/com.example.App/com.apple.metal"* ]] + [[ "$output" != *"not-a-gpu-cache"* ]] + [[ "$output" != *"-mtime +1"* ]] + [[ "$output" == *"SUCCESS:Accessible rebuildable GPU caches, 3 items"* ]] +} + @test "opt_memory_pressure_relief skips when pressure is normal" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail @@ -1160,6 +1111,9 @@ sudo() { } export -f sudo +# Sudo is mocked above; explicitly opt out of the test-mode short-circuit +# in optimize_sudo_available so this success-path test reaches the mock. +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH opt_memory_pressure_relief EOF @@ -1169,7 +1123,7 @@ EOF } @test "opt_network_stack_optimize skips when network is healthy" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" @@ -1192,8 +1146,35 @@ EOF [[ "$output" == *"Network stack already optimal"* ]] } +@test "opt_network_stack_optimize skips when VPN is active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=1 bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +route() { + echo "unexpected-route" + return 0 +} +export -f route + +sudo() { + echo "unexpected-sudo" + return 0 +} +export -f sudo + +opt_network_stack_optimize +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Network stack refresh skipped, active VPN detected"* ]] + [[ "$output" != *"unexpected-route"* ]] + [[ "$output" != *"unexpected-sudo"* ]] +} + @test "opt_network_stack_optimize flushes when network has issues" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" @@ -1231,6 +1212,9 @@ dscacheutil() { } export -f dscacheutil +# Sudo is mocked above; explicitly opt out of the test-mode short-circuit +# in optimize_sudo_available so this success-path test reaches the mock. +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH opt_network_stack_optimize EOF @@ -1302,6 +1286,9 @@ start_inline_spinner() { :; } stop_inline_spinner() { :; } export -f start_inline_spinner stop_inline_spinner +# Sudo is mocked above; explicitly opt out of the test-mode short-circuit +# in optimize_sudo_available so this success-path test reaches the mock. +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH opt_disk_permissions_repair EOF @@ -1309,156 +1296,6 @@ EOF [[ "$output" == *"User directory permissions repaired"* ]] } -@test "opt_bluetooth_reset skips when HID device is connected" { - 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" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - Apple Magic Keyboard: - Connected: Yes - Type: Keyboard -PROFILER_OUT - return 0 -} -export -f system_profiler - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset skips when media apps 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" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods Pro: - Connected: Yes - Type: Headphones -PROFILER_OUT - return 0 -} -export -f system_profiler - -pgrep() { - if [[ "$2" == "Spotify" ]]; then - echo "12345" - return 0 - fi - return 1 -} -export -f pgrep - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset skips when Bluetooth audio output is 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/optimize/tasks.sh" - -system_profiler() { - if [[ "$1" == "SPAudioDataType" ]]; then - cat << 'AUDIO_OUT' -Audio: - Devices: - AirPods Pro: - Default Output Device: Yes - Manufacturer: Apple Inc. - Output Channels: 2 - Transport: Bluetooth - Output Source: AirPods Pro -AUDIO_OUT - return 0 - elif [[ "$1" == "SPBluetoothDataType" ]]; then - echo "Bluetooth:" - return 0 - fi - return 1 -} -export -f system_profiler - -awk() { - if [[ "${*}" == *"Default Output Device"* ]]; then - cat << 'AWK_OUT' - Default Output Device: Yes - Manufacturer: Apple Inc. - Output Channels: 2 - Transport: Bluetooth - Output Source: AirPods Pro -AWK_OUT - return 0 - fi - command awk "$@" -} -export -f awk - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth already optimal"* ]] -} - -@test "opt_bluetooth_reset restarts when safe" { - 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" - -system_profiler() { - cat << 'PROFILER_OUT' -Bluetooth: - AirPods: - Connected: Yes - Type: Audio -PROFILER_OUT - return 0 -} -export -f system_profiler - -pgrep() { - if [[ "$2" == "bluetoothd" ]]; then - return 1 # bluetoothd not running after TERM - fi - return 1 -} -export -f pgrep - -sudo() { - if [[ "$1" == "pkill" ]]; then - echo "pkill:bluetoothd:$2" - return 0 - fi - return 1 -} -export -f sudo - -sleep() { :; } -export -f sleep - -opt_bluetooth_reset -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Bluetooth module restarted"* ]] -} - @test "opt_spotlight_index_optimize skips when search is fast" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail diff --git a/Resources/mole/tests/clean_user_core.bats b/Resources/mole/tests/clean_user_core.bats index acf3b54..846cc13 100644 --- a/Resources/mole/tests/clean_user_core.bats +++ b/Resources/mole/tests/clean_user_core.bats @@ -10,11 +10,17 @@ 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" } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -38,7 +44,7 @@ EOF [[ "$output" != *"Trash"* ]] } -@test "clean_user_essentials falls back when Finder trash operations time out" { +@test "clean_user_essentials empties trash directly without Finder prompt" { mkdir -p "$HOME/.Trash" touch "$HOME/.Trash/one.tmp" "$HOME/.Trash/two.tmp" @@ -49,18 +55,17 @@ source "$PROJECT_ROOT/lib/clean/user.sh" DRY_RUN=false start_section_spinner() { :; } stop_section_spinner() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } safe_clean() { :; } note_activity() { :; } is_path_whitelisted() { return 1; } debug_log() { :; } -run_with_timeout() { - local _duration="$1" - shift - if [[ "$1" == "osascript" ]]; then - return 124 - fi - "$@" +osascript() { + echo "FAIL: osascript called, should be direct delete" >&2 + return 1 } +export -f osascript safe_remove() { local target="$1" /bin/rm -rf "$target" @@ -74,6 +79,42 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Trash · emptied, 2 items"* ]] + [[ "$output" != *"osascript called"* ]] +} + +@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" { @@ -96,6 +137,51 @@ EOF [[ "$output" == *"Saved application states"* ]] || [[ "$output" == *"App caches"* ]] } +@test "clean_app_caches does not clean Autosave Information" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +start_section_spinner() { :; } +safe_clean() { echo "$2|$1"; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Autosave information"* ]] + [[ "$output" != *"Library/Autosave Information"* ]] +} + +@test "clean_app_caches includes additional Apple cache families" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +stop_section_spinner() { :; } +start_section_spinner() { :; } +safe_clean() { echo "$2"; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Apple Media Services cache"* ]] + [[ "$output" == *"Duet Expert cache"* ]] + [[ "$output" == *"Parsecd cache"* ]] + [[ "$output" == *"Apple Python cache"* ]] + [[ "$output" == *"Apple Intelligence runtime cache"* ]] +} + @test "clean_app_caches shows spinner during initial app cache scan" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -114,7 +200,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 +223,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 +245,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 +274,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 @@ -204,8 +323,8 @@ files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Application Support/TestApp/logs/nested" -dd if=/dev/zero of="$HOME/Library/Application Support/TestApp/logs/nested/data.bin" bs=1024 count=2 2> /dev/null +mkdir -p "$HOME/Library/Application Support/TestApp/Code Cache/nested" +dd if=/dev/zero of="$HOME/Library/Application Support/TestApp/Code Cache/nested/data.bin" bs=1024 count=2 2> /dev/null clean_application_support_logs echo "TOTAL_KB=$total_size_cleaned" @@ -239,9 +358,9 @@ files_cleaned=0 total_size_cleaned=0 total_items=0 -mkdir -p "$HOME/Library/Application Support/adspower_global/logs" +mkdir -p "$HOME/Library/Application Support/adspower_global/Crashpad/completed" for i in $(seq 1 101); do - touch "$HOME/Library/Application Support/adspower_global/logs/file-$i.log" + touch "$HOME/Library/Application Support/adspower_global/Crashpad/completed/file-$i.dmp" done clean_application_support_logs @@ -255,6 +374,137 @@ EOF [[ "$output" != *"REMOVE:"* ]] } +@test "clean_application_support_logs does not clean generic Application Support logs" { + local support_home="$HOME/support-appsupport-generic-logs" + 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; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/TestApp/logs" +touch "$HOME/Library/Application Support/TestApp/logs/runtime.log" + +clean_application_support_logs +test -f "$HOME/Library/Application Support/TestApp/logs/runtime.log" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"REMOVE:"* ]] +} + +@test "clean_application_support_logs cleans Electron-style Cache only when cache markers exist" { + local support_home="$HOME/support-appsupport-electron-cache" + run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false 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() { :; } +update_progress_if_needed() { return 1; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +WHITELIST_PATTERNS=() +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Application Support/ElectronLike/Code Cache" +mkdir -p "$HOME/Library/Application Support/ElectronLike/Cache" +mkdir -p "$HOME/Library/Application Support/ElectronLike/CachedData" +touch "$HOME/Library/Application Support/ElectronLike/Code Cache/runtime.bin" +touch "$HOME/Library/Application Support/ElectronLike/Cache/http-cache" +touch "$HOME/Library/Application Support/ElectronLike/CachedData/v8-data" + +mkdir -p "$HOME/Library/Application Support/PlainApp/Cache" +touch "$HOME/Library/Application Support/PlainApp/Cache/keep.db" + +clean_application_support_logs + +test ! -e "$HOME/Library/Application Support/ElectronLike/Code Cache/runtime.bin" +test ! -e "$HOME/Library/Application Support/ElectronLike/Cache/http-cache" +test ! -e "$HOME/Library/Application Support/ElectronLike/CachedData/v8-data" +test -e "$HOME/Library/Application Support/PlainApp/Cache/keep.db" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] +} + +@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/Code Cache" +touch "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/Code Cache/runtime.bin" + +clean_application_support_logs +test -f "$HOME/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/Code Cache/runtime.bin" +rm -rf "$HOME/Library/Application Support" +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"REMOVE:"* ]] +} + +@test "_clean_darwin_user_runtime_dir removes only old non-state files" { + local runtime_home="$HOME/darwin-runtime" + run env HOME="$runtime_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +mkdir -p "$HOME/runtime/T" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +_darwin_user_runtime_dir_is_safe() { return 0; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +echo "old" > "$HOME/runtime/T/old.tmp" +echo "new" > "$HOME/runtime/T/new.tmp" +echo "state" > "$HOME/runtime/T/state.sqlite" +touch -t 202301010000 "$HOME/runtime/T/old.tmp" "$HOME/runtime/T/state.sqlite" + +_clean_darwin_user_runtime_dir "$HOME/runtime/T" "temp" "Darwin user temp files" + +[[ ! -e "$HOME/runtime/T/old.tmp" ]] +[[ -e "$HOME/runtime/T/new.tmp" ]] +[[ -e "$HOME/runtime/T/state.sqlite" ]] +echo "PASS" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] +} + @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' @@ -314,6 +564,37 @@ EOF [[ "$output" == *"PASS"* ]] } +@test "clean_group_container_caches skips Apple Notes group container" { + 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/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +notes_cache="$HOME/Library/Group Containers/group.com.apple.notes/Library/Caches" +mkdir -p "$notes_cache" +echo "notes" > "$notes_cache/NoteStore.sqlite" + +clean_group_container_caches + +if [[ -e "$notes_cache/NoteStore.sqlite" ]]; then + echo "PASS" +else + echo "FAIL" + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS"* ]] +} + @test "clean_group_container_caches respects whitelist entries" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=false /bin/bash --noprofile --norc <<'EOF' set -euo pipefail @@ -415,6 +696,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 +758,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 +772,167 @@ EOF [[ "$output" == *"Puppeteer browser cache"* ]] } +@test "clean_browsers preserves Brave Service Worker ScriptCache" { + 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_browsers covers Arc User Data layout" { + mkdir -p "$HOME/Library/Application Support/Arc/User Data/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|$1"; } +clean_service_worker_cache() { echo "Arc SW $2"; } +note_activity() { :; } +pgrep() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Arc code cache|$HOME/Library/Application Support/Arc/User Data/"* ]] + [[ "$output" == *"Arc component CRX cache|$HOME/Library/Application Support/Arc/User Data/component_crx_cache/"* ]] + [[ "$output" == *"Arc extensions CRX cache|$HOME/Library/Application Support/Arc/User Data/extensions_crx_cache/"* ]] + [[ "$output" == *"Arc SW $HOME/Library/Application Support/Arc/User Data/Default/Service Worker/CacheStorage"* ]] + [[ "$output" != *"Arc Service Worker ScriptCache|$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache/"* ]] + + rm -rf "$HOME/Library" +} + +@test "clean_browsers always preserves Chromium Service Worker ScriptCache (#785 #964 #968)" { + mkdir -p "$HOME/Library/Application Support/Google/Chrome/Default/Service Worker/ScriptCache" + mkdir -p "$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache" + mkdir -p "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default/Service Worker/ScriptCache" + mkdir -p "$HOME/Library/Application Support/Vivaldi/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 "SW-CALL $1"; } +note_activity() { :; } +pgrep() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + # CacheStorage cleanup still runs (it has its own protection logic). + [[ "$output" == *"SW-CALL Chrome"* ]] + # ScriptCache cleanup must NOT run at all: wiping V8 bytecode can break + # Chromium MV3 extension service workers even after the browser exits. + [[ "$output" != *"Chrome Service Worker ScriptCache"* ]] + [[ "$output" != *"Arc Service Worker ScriptCache"* ]] + [[ "$output" != *"Brave Service Worker ScriptCache"* ]] + [[ "$output" != *"Vivaldi Service Worker ScriptCache"* ]] + + rm -rf "$HOME/Library" +} + +@test "clean_browsers preserves Arc User Data ScriptCache regardless of running state" { + mkdir -p "$HOME/Library/Application Support/Arc/User Data/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|$1"; } +clean_service_worker_cache() { echo "Arc SW $2"; } +note_activity() { :; } +pgrep() { + [[ "${2:-}" == "Arc" ]] +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Arc SW $HOME/Library/Application Support/Arc/User Data/Default/Service Worker/CacheStorage"* ]] + [[ "$output" != *"Arc Service Worker ScriptCache|$HOME/Library/Application Support/Arc/User Data/Default/Service Worker/ScriptCache/"* ]] + + rm -rf "$HOME/Library" +} + +@test "clean_browsers covers QQ Browser 3 caches when not running" { + mkdir -p "$HOME/Library/Application Support/QQBrowser3/Default/Code 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" +safe_clean() { echo "$2|$1"; } +clean_service_worker_cache() { :; } +note_activity() { :; } +pgrep() { return 1; } +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QQ Browser cache|$HOME/Library/Caches/com.tencent.QQBrowser3/"* ]] + [[ "$output" == *"QQ Browser code cache|$HOME/Library/Application Support/QQBrowser3/"* ]] + [[ "$output" == *"QQ Browser component cache|$HOME/Library/Application Support/QQBrowser3/component_crx_cache/"* ]] + + rm -rf "$HOME/Library" +} + +@test "clean_browsers skips QQ Browser 3 profile caches while running" { + mkdir -p "$HOME/Library/Application Support/QQBrowser3/Default/Code 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" +safe_clean() { echo "$2|$1"; } +clean_service_worker_cache() { :; } +note_activity() { :; } +pgrep() { + [[ "${2:-}" == "QQBrowser3" ]] +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 +clean_browsers +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QQ Browser cache|$HOME/Library/Caches/com.tencent.QQBrowser3/"* ]] + [[ "$output" != *"QQ Browser code cache"* ]] + [[ "$output" != *"QQ Browser GPU cache"* ]] + + 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 +984,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..03a7136 --- /dev/null +++ b/Resources/mole/tests/clean_xcode_derived_data.bats @@ -0,0 +1,153 @@ +#!/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() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + 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..990ba6b 100644 --- a/Resources/mole/tests/cli.bats +++ b/Resources/mole/tests/cli.bats @@ -7,18 +7,52 @@ 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" + + CLI_OWNS_GO_HELPERS=0 + export CLI_OWNS_GO_HELPERS + + if [[ -x "${MOLE_TEST_ANALYZE_BIN:-}" && -x "${MOLE_TEST_STATUS_BIN:-}" ]]; then + ANALYZE_BIN="$MOLE_TEST_ANALYZE_BIN" + STATUS_BIN="$MOLE_TEST_STATUS_BIN" + export ANALYZE_BIN STATUS_BIN + elif command -v go > /dev/null 2>&1; then + # Build Go binaries from current source for JSON tests. + # Point GOPATH/GOMODCACHE/GOCACHE at the real home so local focused runs + # can reuse caches when the full runner did not prebuild helpers. + 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 + CLI_OWNS_GO_HELPERS=1 + export ANALYZE_BIN STATUS_BIN + fi } teardown_file() { - rm -rf "$HOME/.config/mole" - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME/.config/mole" + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi + if [[ "${CLI_OWNS_GO_HELPERS:-0}" == "1" ]]; then + rm -f "${ANALYZE_BIN:-}" "${STATUS_BIN:-}" + fi } create_fake_utils() { @@ -43,9 +77,32 @@ fi exit 0 SCRIPT chmod +x "$dir/bioutil" + + cat >"$dir/chown" <<'SCRIPT' +#!/usr/bin/env bash +exit 0 +SCRIPT + chmod +x "$dir/chown" + + cat >"$dir/install" <<'SCRIPT' +#!/usr/bin/env bash +args=() +skip_next="" +for arg in "$@"; do + if [[ -n "$skip_next" ]]; then skip_next=""; continue; fi + case "$arg" in -o|-g) skip_next=1 ;; *) args+=("$arg") ;; esac +done +exec /usr/bin/install "${args[@]}" +SCRIPT + chmod +x "$dir/install" } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config/mole" mkdir -p "$HOME/.config/mole" } @@ -54,7 +111,9 @@ setup() { run env HOME="$HOME" "$PROJECT_ROOT/mole" --help [ "$status" -eq 0 ] [[ "$output" == *"mo clean"* ]] + [[ "$output" == *"mo optimize"* ]] [[ "$output" == *"mo analyze"* ]] + [[ "$output" != *"mo optimise"* ]] } @test "mole --version reports script version" { @@ -64,6 +123,22 @@ setup() { [[ "$output" == *"$expected_version"* ]] } +@test "mole --version does not hang on slow Homebrew detection" { + local fake_bin + fake_bin="$(mktemp -d "${BATS_TEST_TMPDIR}/fake-bin.XXXXXX")" + ln -s "$PROJECT_ROOT/mole" "$fake_bin/mole" + cat > "$fake_bin/brew" <<'SCRIPT' +#!/usr/bin/env bash +sleep 3 +exit 1 +SCRIPT + chmod +x "$fake_bin/brew" + + run env HOME="$HOME" PATH="$fake_bin:$PATH" MOLE_HOMEBREW_DETECT_TIMEOUT=1 "$PROJECT_ROOT/mole" --version + [ "$status" -eq 0 ] + [[ "$output" == *"Install: Manual"* ]] +} + @test "mole --version shows nightly channel metadata" { expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" mkdir -p "$HOME/.config/mole" @@ -83,6 +158,36 @@ EOF [[ "$output" == *"Unknown command: unknown-command"* ]] } +@test "mole --help does not list check command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" != *"mo check"* ]] +} + +@test "mole --help documents history command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" --help + [ "$status" -eq 0 ] + [[ "$output" == *"mo history"* ]] +} + +@test "mole check is not a public command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" check --help + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown command: check"* ]] +} + +@test "mole doctor is not a public command" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" doctor --help + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown command: doctor"* ]] +} + +@test "mole optimize --check is not a public option" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" optimize --check + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown optimize option: --check"* ]] +} + @test "mole uninstall --whitelist returns unsupported option error" { run env HOME="$HOME" "$PROJECT_ROOT/mole" uninstall --whitelist [ "$status" -ne 0 ] @@ -107,6 +212,26 @@ EOF [[ "$output" != *"U Update"* ]] } +@test "show_main_menu keeps history out of the primary menu" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +HOME="$(mktemp -d)" +export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 +source "$PROJECT_ROOT/mole" +show_brand_banner() { printf 'banner\n'; } +show_menu_option() { printf '%s\n' "$2"; } +MAIN_MENU_BANNER="" +MAIN_MENU_UPDATE_MESSAGE="" +MAIN_MENU_SHOW_UPDATE=false +show_main_menu 1 true +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Clean Free up disk space"* ]] + [[ "$output" != *"History"* ]] + [[ "$output" != *"history"* ]] +} + @test "interactive_main_menu ignores U shortcut when update notice is hidden" { run bash --noprofile --norc <<'EOF' set -euo pipefail @@ -157,6 +282,43 @@ EOF [[ "$output" == *"UPDATE_CALLED"* ]] } +@test "interactive_main_menu drains numeric shortcut Enter before launching uninstall" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +HOME="$(mktemp -d)" +export HOME MOLE_TEST_MODE=1 MOLE_SKIP_MAIN=1 +source "$PROJECT_ROOT/mole" + +fake_root="$HOME/fake-mole" +mkdir -p "$fake_root/bin" +cat > "$fake_root/bin/uninstall.sh" <<'SCRIPT' +#!/usr/bin/env bash +if IFS= read -r -s -n1 -t 0.1 key; then + if [[ -z "$key" ]]; then + echo "LEAK:ENTER" + else + printf 'LEAK:%s\n' "$key" + fi +else + echo "NO_LEAK" +fi +SCRIPT +chmod +x "$fake_root/bin/uninstall.sh" + +SCRIPT_DIR="$fake_root" +show_brand_banner() { :; } +show_main_menu() { :; } +hide_cursor() { :; } +show_cursor() { :; } + +interactive_main_menu < <(printf '2\n') +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"NO_LEAK"* ]] + [[ "$output" != *"LEAK:"* ]] +} + @test "touchid status reports current configuration" { run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status [ "$status" -eq 0 ] @@ -164,7 +326,7 @@ EOF } @test "mo optimize command is recognized" { - run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" + run bash -c "grep -Eq '\"optimi[sz]e\"[[:space:]]*\\|[[:space:]]*\"optimi[sz]e\"' '$PROJECT_ROOT/mole'" [ "$status" -eq 0 ] } @@ -184,7 +346,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 +368,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 +377,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 +475,212 @@ EOF run grep "pam_tid.so" "$pam_file" [ "$status" -ne 0 ] } + +@test "enable_touchid sets correct file permissions on pam file" { + pam_file="$HOME/pam_perms_enable" + cat >"$pam_file" <<'EOF' +auth sufficient pam_opendirectory.so +EOF + + fake_bin="$HOME/fake-bin-perms-enable" + create_fake_utils "$fake_bin" + + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable + [ "$status" -eq 0 ] + grep -q "pam_tid.so" "$pam_file" + + local perms + perms=$(stat -f "%Lp" "$pam_file" 2>/dev/null || stat -c "%a" "$pam_file" 2>/dev/null) + [ "$perms" = "444" ] +} + +@test "disable_touchid sets correct file permissions on pam file" { + pam_file="$HOME/pam_perms_disable" + cat >"$pam_file" <<'EOF' +auth sufficient pam_tid.so +auth sufficient pam_opendirectory.so +EOF + + fake_bin="$HOME/fake-bin-perms-disable" + create_fake_utils "$fake_bin" + + run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable + [ "$status" -eq 0 ] + + local perms + perms=$(stat -f "%Lp" "$pam_file" 2>/dev/null || stat -c "%a" "$pam_file" 2>/dev/null) + [ "$perms" = "444" ] +} + +@test "enable_touchid sets correct permissions on sudo_local file" { + pam_file="$HOME/pam_perms_sudolocal" + pam_local="$(dirname "$pam_file")/sudo_local_perms" + cat >"$pam_file" <<'EOF' +# sudo: auth account password session +auth include sudo_local +auth sufficient pam_opendirectory.so +EOF + + fake_bin="$HOME/fake-bin-perms-sudolocal" + create_fake_utils "$fake_bin" + + run env PATH="$fake_bin:$PATH" \ + MOLE_PAM_SUDO_FILE="$pam_file" \ + MOLE_PAM_SUDO_LOCAL_FILE="$pam_local" \ + "$PROJECT_ROOT/bin/touchid.sh" enable + [ "$status" -eq 0 ] + grep -q "pam_tid.so" "$pam_local" + + local perms + perms=$(stat -f "%Lp" "$pam_local" 2>/dev/null || stat -c "%a" "$pam_local" 2>/dev/null) + [ "$perms" = "444" ] +} + +# --- 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..714bdce 100755 --- a/Resources/mole/tests/completion.bats +++ b/Resources/mole/tests/completion.bats @@ -20,7 +20,9 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi @@ -30,6 +32,11 @@ teardown_file() { } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" mkdir -p "$HOME" @@ -67,6 +74,7 @@ setup() { [[ "$output" == *"uninstall"* ]] [[ "$output" == *"analyze"* ]] [[ "$output" == *"status"* ]] + [[ "$output" == *"history"* ]] [[ "$output" == *"purge"* ]] [[ "$output" == *"touchid"* ]] [[ "$output" == *"completion"* ]] @@ -78,6 +86,18 @@ setup() { [[ "$output" == *"complete -F _mole_completions mole mo"* ]] } +@test "completion bash includes current clean, analyze, history, and purge options only" { + run "$PROJECT_ROOT/bin/completion.sh" bash + [ "$status" -eq 0 ] + [[ "$output" == *"--dry-run -n --external --whitelist --debug --help -h"* ]] + [[ "$output" == *"--json --help -h"* ]] + [[ "$output" == *"--json --limit --help -h"* ]] + [[ "$output" == *"--paths --dry-run -n --include-empty --debug --help -h"* ]] + [[ "$output" != *"--select"* ]] + [[ "$output" != *"--categories"* ]] + [[ "$output" != *"--exclude-paths"* ]] +} + @test "completion bash can be loaded in bash" { run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" [ "$status" -eq 0 ] @@ -94,26 +114,55 @@ setup() { @test "completion zsh includes command descriptions" { run "$PROJECT_ROOT/bin/completion.sh" zsh [ "$status" -eq 0 ] - [[ "$output" == *"optimize:Check and maintain system"* ]] + [[ "$output" == *"optimize:Refresh caches and services"* ]] [[ "$output" == *"clean:Free up disk space"* ]] + [[ "$output" == *"history:Review cleanup activity"* ]] +} + +@test "completion zsh includes current clean, analyze, history, and purge options only" { + run "$PROJECT_ROOT/bin/completion.sh" zsh + [ "$status" -eq 0 ] + [[ "$output" == *"--dry-run"* ]] + [[ "$output" == *"--external"* ]] + [[ "$output" == *"--whitelist"* ]] + [[ "$output" == *"--json"* ]] + [[ "$output" == *"--limit"* ]] + [[ "$output" == *"--include-empty"* ]] + [[ "$output" != *"--select"* ]] + [[ "$output" != *"--categories"* ]] + [[ "$output" != *"--exclude-paths"* ]] } @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 ] } +@test "completion fish includes current clean, analyze, history, and purge options only" { + run "$PROJECT_ROOT/bin/completion.sh" fish + [ "$status" -eq 0 ] + [[ "$output" == *"-l dry-run"* ]] + [[ "$output" == *"-l external"* ]] + [[ "$output" == *"-l whitelist"* ]] + [[ "$output" == *"-l json"* ]] + [[ "$output" == *"-l limit"* ]] + [[ "$output" == *"-l include-empty"* ]] + [[ "$output" != *"-l select"* ]] + [[ "$output" != *"-l categories"* ]] + [[ "$output" != *"-l exclude-paths"* ]] +} + @test "completion auto-install detects zsh" { # shellcheck disable=SC2030,SC2031 export SHELL=/bin/zsh diff --git a/Resources/mole/tests/core_common.bats b/Resources/mole/tests/core_common.bats index 69d0a6f..055cbe7 100644 --- a/Resources/mole/tests/core_common.bats +++ b/Resources/mole/tests/core_common.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" mkdir -p "$HOME" } @@ -44,13 +51,144 @@ setup() { [[ -n "$result" ]] } +@test "get_free_space uses decimal formatting from df kilobytes" { + local mock_bin="$HOME/bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/df" <<'MOCK' +#!/bin/bash +printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' +printf '/dev/disk1 200000000 126599680 73400320 64%% /\n' +MOCK + chmod +x "$mock_bin/df" + + output="$( + HOME="$HOME" PATH="$mock_bin:$PATH" bash --noprofile --norc <<'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +get_free_space_kb +get_free_space +format_free_space_kb 73400320 +format_free_space_kb invalid +format_free_space_delta_kb 1024 +format_free_space_delta_kb -1024 +EOF + )" + + lines=() + while IFS= read -r line; do + lines+=("$line") + done <<< "$output" + + [ "${lines[0]}" = "73400320" ] + [ "${lines[1]}" = "75.16GB" ] + [ "${lines[2]}" = "75.16GB" ] + [ "${lines[3]}" = "Unknown" ] + [ "${lines[4]}" = "+1.0MB" ] + [ "${lines[5]}" = "-1.0MB" ] +} + +@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 "mole_is_reverse_dns_bundle_id rejects defaults domains and glob-like ids" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +for valid in "com.example.App" "andriiliakh.Artpaper" "dev.zed.Zed-Nightly" "org.keepassxc.KeePassXC"; do + mole_is_reverse_dns_bundle_id "$valid" || { + echo "valid rejected: $valid" + exit 1 + } +done + +for invalid in "-g" "NSGlobalDomain" "com-example" "com.foo.*" "com.foo.[abc]" "unknown" ""; do + if mole_is_reverse_dns_bundle_id "$invalid"; then + echo "invalid accepted: $invalid" + exit 1 + fi +done +EOF + + [ "$status" -eq 0 ] +} + +@test "mole_name_has_bundle_id_boundary rejects sibling bundle prefixes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +bundle_id="com.example.TestApp" + +for valid in \ + "com.example.TestApp.plist" \ + "com.example.TestApp.helper.plist" \ + "/tmp/com.example.TestApp.pkg.bom"; do + mole_name_starts_with_bundle_id_boundary "$valid" "$bundle_id" || { + echo "valid start boundary rejected: $valid" + exit 1 + } +done + +for invalid in \ + "group.com.example.TestApp" \ + "TEAM.com.example.TestApp.FileProvider" \ + "com.example.TestApplication.plist"; do + if mole_name_starts_with_bundle_id_boundary "$invalid" "$bundle_id"; then + echo "sibling start boundary accepted: $invalid" + exit 1 + fi +done + +for valid in \ + "com.example.TestApp.plist" \ + "com.example.TestApp.helper.plist" \ + "group.com.example.TestApp" \ + "TEAM.com.example.TestApp.FileProvider" \ + "/tmp/com.example.TestApp.pkg.bom"; do + mole_name_has_bundle_id_boundary "$valid" "$bundle_id" || { + echo "valid boundary rejected: $valid" + exit 1 + } +done + +for invalid in \ + "com.example.TestApplication.plist" \ + "group.com.example.TestApplication" \ + "xcom.example.TestApp" \ + "com.example.TestAppHelper.plist" \ + "com-example-TestApp.plist"; do + if mole_name_has_bundle_id_boundary "$invalid" "$bundle_id"; then + echo "sibling boundary accepted: $invalid" + exit 1 + fi +done +EOF + + [ "$status" -eq 0 ] +} + @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,15 +202,41 @@ 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 + if command -v mkfile > /dev/null 2>&1; then + mkfile -n 1100k "$log_file" + else + truncate -s 1100k "$log_file" + fi HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'" [[ -f "${log_file}.old" ]] @@ -119,6 +283,32 @@ EOF [ "${bytes_lines[3]}" = "3.00GB" ] } +@test "colorize_human_size colors dry-run size units by suffix" { + output="$( + env -u NO_COLOR HOME="$HOME" bash --noprofile --norc << 'EOF' +source "$PROJECT_ROOT/lib/core/common.sh" +colorize_human_size "1.00GB" +printf '\n' +colorize_human_size "5.0MB" +printf '\n' +colorize_human_size "180KB" +printf '\n' +colorize_human_size "0B" +printf '\n' +EOF + )" + + color_lines=() + while IFS= read -r line; do + color_lines+=("$line") + done <<< "$output" + + [ "${color_lines[0]}" = $'\033[0;31m1.00GB\033[0m' ] + [ "${color_lines[1]}" = $'\033[0;33m5.0MB\033[0m' ] + [ "${color_lines[2]}" = $'\033[0;32m180KB\033[0m' ] + [ "${color_lines[3]}" = $'\033[0;90m0B\033[0m' ] +} + @test "create_temp_file and create_temp_dir are tracked and cleaned" { HOME="$HOME" bash --noprofile --norc << 'EOF' source "$PROJECT_ROOT/lib/core/common.sh" @@ -142,10 +332,76 @@ 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 'org.amnezia.awg' && 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.wireguard.macos' && 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 "should_protect_data protects Codex runtime identifiers" { + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'Codex' && 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.openai.codex' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'codex-runtimes' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + local codex_runtimes_path="$HOME/.cache/codex-runtimes" + result=$(HOME="$HOME" TARGET_PATH="$codex_runtimes_path" bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo "protected" || echo "not-protected"') + [ "$result" = "protected" ] + + for codex_state_path in \ + "$HOME/Library/Application Support/Codex/Cache/index" \ + "$HOME/Library/Logs/com.openai.codex/codex.log" \ + "$HOME/.codex/sessions/2026/06/session.jsonl" \ + "$HOME/.codex/cache/session_index.jsonl" \ + "$HOME/.codex/cache/codex_app_directory/index.json" \ + "$HOME/.codex/state_5.sqlite" \ + "$HOME/.codex/logs_2.sqlite"; do + result=$(HOME="$HOME" TARGET_PATH="$codex_state_path" bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && echo "protected" || echo "not-protected"') + [ "$result" = "protected" ] + done +} + +@test "should_protect_data covers Raycast wildcard variants" { + for id in com.raycast.macos com.raycast.shared com.raycast.macos.BrowserExtension; do + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data '$id' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + done + + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_data 'com.raycastfoo.bar' && echo 'protected' || echo 'not-protected'") + [ "$result" = "not-protected" ] +} + +@test "should_protect_path protects NetworkExtension VPN preferences" { + result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_path '/Volumes/Data/Library/Preferences/com.apple.networkextension.plist' && echo 'protected' || echo 'not-protected'") + [ "$result" = "protected" ] + + local user_network_ext_pref="$HOME/Library/Preferences/com.apple.networkextension.necp.plist" + result=$(HOME="$HOME" TARGET_PATH="$user_network_ext_pref" bash --noprofile --norc -c 'source "$PROJECT_ROOT/lib/core/common.sh"; should_protect_path "$TARGET_PATH" && 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 +457,34 @@ sleep 0.1 stop_inline_spinner echo "done" EOF -) + ) [[ "$result" == *"done"* ]] } +@test "start_inline_spinner ignores PATH-provided sleep in TTY mode" { + if ! /usr/bin/script -q /dev/null /bin/true > /dev/null 2>&1; then + skip "script cannot allocate a TTY in this environment" + fi + + 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 +511,93 @@ 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 "read_key keeps Ctrl-C as quit when forcing printable characters" { + run bash -c "export MOLE_BASE_LOADED=1; export MOLE_READ_KEY_FORCE_CHAR=1; source '$PROJECT_ROOT/lib/core/ui.sh'; printf '\\003' | read_key" + [ "$output" = "QUIT" ] +} + +@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 "sudo helpers do not invoke sudo in no-auth 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" +sudo() { + echo "SUDO_CALLED:$*" >&2 + exit 99 +} +export -f sudo + +has_sudo_session && has_rc=0 || has_rc=$? +request_sudo_access "Test prompt" && request_rc=0 || request_rc=$? +ensure_sudo_session "Test prompt" && ensure_rc=0 || ensure_rc=$? + +echo "HAS=$has_rc" +echo "REQUEST=$request_rc" +echo "ENSURE=$ensure_rc" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"HAS=1"* ]] + [[ "$output" == *"REQUEST=1"* ]] + [[ "$output" == *"ENSURE=1"* ]] + [[ "$output" != *"SUDO_CALLED"* ]] +} + +@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"* ]] +} + +@test "adopt_sudo_session starts keepalive for cached sudo" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +source "$PROJECT_ROOT/lib/core/sudo.sh" + +sudo() { + printf 'SUDO:%s\n' "$*" + [[ "${1:-}" == "-n" && "${2:-}" == "-v" ]] +} +_start_sudo_keepalive() { + echo "keepalive-pid" +} +_stop_sudo_keepalive() { :; } + +adopt_sudo_session +echo "EXIT=$?" +echo "FLAG=$MOLE_SUDO_ESTABLISHED" +echo "PID=$MOLE_SUDO_KEEPALIVE_PID" +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"SUDO:-n -v"* ]] + [[ "$output" == *"EXIT=0"* ]] + [[ "$output" == *"FLAG=true"* ]] + [[ "$output" == *"PID=keepalive-pid"* ]] +} diff --git a/Resources/mole/tests/core_performance.bats b/Resources/mole/tests/core_performance.bats index 4bf4034..c703e8f 100644 --- a/Resources/mole/tests/core_performance.bats +++ b/Resources/mole/tests/core_performance.bats @@ -106,32 +106,9 @@ setup() { [ "$result" = "$current_user" ] } -@test "get_invoking_user executes quickly" { - local start end elapsed - local limit_ms="${MOLE_PERF_GET_INVOKING_USER_LIMIT_MS:-500}" - - start=$(date +%s%N) - for i in {1..100}; do - get_invoking_user > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt "$limit_ms" ] -} - -@test "get_darwin_major caches correctly" { - local first second - first=$(get_darwin_major) - second=$(get_darwin_major) - - [ "$first" = "$second" ] - [[ "$first" =~ ^[0-9]+$ ]] -} - @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 +120,7 @@ setup() { elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 1000 ] + [ "$elapsed" -lt "$limit_ms" ] [ "${#MOLE_TEMP_FILES[@]}" -eq 50 ] @@ -152,7 +129,7 @@ setup() { end=$(date +%s%N) elapsed=$(( (end - start) / 1000000 )) - [ "$elapsed" -lt 2000 ] + [ "$elapsed" -lt "$limit_ms" ] [ "${#MOLE_TEMP_FILES[@]}" -eq 0 ] } @@ -168,31 +145,6 @@ setup() { rm -f "$temp_file" } -@test "get_brand_name handles common apps efficiently" { - local start end elapsed - - get_brand_name "wechat" > /dev/null - - start=$(date +%s%N) - for i in {1..50}; do - get_brand_name "wechat" > /dev/null - get_brand_name "QQ" > /dev/null - get_brand_name "dingtalk" > /dev/null - done - end=$(date +%s%N) - - elapsed=$(( (end - start) / 1000000 )) - - [ "$elapsed" -lt 5000 ] -} - -@test "get_brand_name returns correct localized names" { - local result - result=$(get_brand_name "wechat") - - [[ "$result" == "WeChat" || "$result" == "微信" ]] -} - @test "get_optimal_parallel_jobs returns sensible values" { local result @@ -233,5 +185,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..fcfeb55 100644 --- a/Resources/mole/tests/core_safe_functions.bats +++ b/Resources/mole/tests/core_safe_functions.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi source "$PROJECT_ROOT/lib/core/common.sh" TEST_DIR="$HOME/test_safe_functions" mkdir -p "$TEST_DIR" @@ -66,6 +73,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 ] @@ -74,11 +84,76 @@ teardown() { run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/etc'" [ "$status" -eq 1 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Library/Apple'" + [ "$status" -eq 1 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Applications/Finder.app'" + [ "$status" -eq 1 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Users'" + [ "$status" -eq 1 ] +} + +@test "validate_path_for_deletion rejects aliased critical paths" { + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '//etc/passwd'" + [ "$status" -eq 1 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '///System'" + [ "$status" -eq 1 ] } @test "validate_path_for_deletion accepts valid path" { run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$TEST_DIR/valid'" [ "$status" -eq 0 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '$HOME/Library/Caches/com.example.app/cache.db'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion accepts CoreSimulator system cache children" { + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Library/Developer/CoreSimulator/Caches/dyld'" + [ "$status" -eq 0 ] +} + +@test "validate_path_for_deletion allows Darwin C cache shards but rejects protected extension paths" { + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/private/var/folders/test/a/C/com.example.App/com.apple.metal'" + [ "$status" -eq 0 ] + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; validate_path_for_deletion '/Library/Extensions/com.example.driver/com.apple.metal' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"critical system path"* ]] +} + +@test "should_protect_path applies high-risk cleanup denylist" { + run bash -c " + source '$PROJECT_ROOT/lib/core/common.sh' + should_protect_path '$HOME/Library/Caches/ms-playwright/chromium-123' + should_protect_path '$HOME/Library/Caches/com.apple.homed/state' + should_protect_path '$HOME/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite' + should_protect_path '$HOME/Library/Preferences/com.paceap.eden.iLokLicenseManager.plist' + should_protect_path '/private/var/folders/aa/bb/C/com.native-instruments.NativeAccess/license' + should_protect_path '/Library/Audio/Plug-Ins/VST3/Example.vst3' + should_protect_data 'com.native-instruments.NativeAccess' + ! should_protect_path '$HOME/Library/Application Support/Example/Cache/item' + " + [ "$status" -eq 0 ] +} + +@test "should_protect_path protects OrbStack live container data" { + local orb_group_data="$HOME/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data/data.img.raw" + local orb_state="$HOME/.orbstack/state.db" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" ORB_GROUP_DATA="$orb_group_data" ORB_STATE="$orb_state" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +should_protect_data "dev.orbstack.OrbStack" +should_protect_data "dev.kdrag0n.MacVirt" +should_protect_path "$ORB_GROUP_DATA" +should_protect_path "$ORB_STATE" +EOF + + [ "$status" -eq 0 ] } @test "safe_remove validates path before deletion" { @@ -86,6 +161,25 @@ 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 silent mode hides protected symlink validation warning" { + local link_path="$TEST_DIR/silent-system-link" + ln -s "/System" "$link_path" + + run bash -c "source '$PROJECT_ROOT/lib/core/common.sh'; safe_remove '$link_path' true 2>&1" + [ "$status" -eq 1 ] + [[ -L "$link_path" ]] + [[ "$output" != *"Symlink points to protected system path"* ]] +} + @test "safe_remove successfully removes file" { local test_file="$TEST_DIR/test_file.txt" echo "test" > "$test_file" @@ -134,6 +228,154 @@ 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_sudo_remove never opens an interactive sudo prompt" { + local target_dir="$TEST_DIR/sudo-target" + mkdir -p "$target_dir" + touch "$target_dir/file" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +sudo() { + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + du) + shift + command du "$@" + ;; + rm) + shift + command rm "$@" + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +safe_sudo_remove "$TARGET_DIR" +[[ ! -e "$TARGET_DIR" ]] +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" != *"INTERACTIVE_SUDO"* ]] +} + +@test "safe_sudo_remove returns auth failure when noninteractive sudo expires" { + local target_dir="$TEST_DIR/sudo-expired" + mkdir -p "$target_dir" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 bash --noprofile --norc <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +sudo() { + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + echo "sudo: a password is required" >&2 + return 1 +} +export -f sudo + +safe_sudo_remove "$TARGET_DIR" && rc=0 || rc=$? +echo "RC=$rc" +[[ -e "$TARGET_DIR" ]] +SCRIPT + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=11"* ]] + [[ "$output" != *"INTERACTIVE_SUDO"* ]] +} + +@test "safe_sudo_find_delete never opens an interactive sudo prompt" { + local target_dir="$TEST_DIR/sudo-find-target" + local script="$TEST_DIR/sudo-find-delete-test.sh" + mkdir -p "$target_dir" + touch "$target_dir/old.log" + + cat > "$script" <<'SCRIPT' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +TRACE="$TARGET_DIR/sudo.trace" +> "$TRACE" + +sudo() { + printf 'SUDO:%s\n' "$*" >> "$TRACE" + if [[ "${1:-}" != "-n" ]]; then + echo "INTERACTIVE_SUDO:$*" >&2 + return 99 + fi + shift + case "${1:-}" in + test) + shift + command test "$@" + ;; + find) + printf '%s\0' "$TARGET_DIR/old.log" + ;; + du) + shift + command du "$@" + ;; + rm) + return 0 + ;; + *) + "$@" + ;; + esac +} +export -f sudo + +set +e +safe_sudo_find_delete "$TARGET_DIR" "*.log" "0" "f" +rc=$? +set -e +printf 'RC=%s\n' "$rc" +cat "$TRACE" || true +exit 0 +SCRIPT + chmod +x "$script" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TARGET_DIR="$target_dir" MOLE_TEST_MODE=0 MOLE_TEST_NO_AUTH=0 bash --noprofile --norc "$script" + + [ "$status" -eq 0 ] + [[ "$output" == *"RC=0"* ]] + [[ "$output" == *"SUDO:-n test -d "* ]] + [[ "$output" == *"SUDO:-n test -L "* ]] + [[ "$output" == *"SUDO:-n find "* ]] + [[ "$output" != *"INTERACTIVE_SUDO"* ]] +} + @test "safe_find_delete rejects symlinked directory" { local real_dir="$TEST_DIR/real" local link_dir="$TEST_DIR/link" @@ -166,6 +408,21 @@ teardown() { [ "$status" -eq 0 ] } +@test "safe_find_delete works when app protection is not loaded" { + local old_file="$TEST_DIR/file-ops-only.tmp" + touch "$old_file" + touch -t "$(date -v-8d '+%Y%m%d%H%M.%S' 2>/dev/null || date -d '8 days ago' '+%Y%m%d%H%M.%S')" "$old_file" 2>/dev/null || true + + run bash --noprofile --norc </dev/null 2>&1 set -e end_time=$(date +%s) @@ -186,3 +186,86 @@ setup() { ") [[ "$result" == "1" ]] } + +@test "run_with_timeout: perl fallback preserves command exit code (#1003)" { + if ! command -v perl > /dev/null 2>&1; then + skip "perl not available" + fi + set +e + bash -c " + set +e + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN=\"\$(command -v perl)\" + run_with_timeout 5 sh -c 'exit 7' + exit \$? + " + exit_code=$? + set -e + [[ $exit_code -eq 7 ]] +} + +@test "run_with_timeout: perl fallback kills long-running command (#1003)" { + if ! command -v perl > /dev/null 2>&1; then + skip "perl not available" + fi + start_time=$(date +%s) + set +e + bash -c " + set +e + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN=\"\$(command -v perl)\" + run_with_timeout 2 sleep 8 + " > /dev/null 2>&1 + set -e + end_time=$(date +%s) + duration=$((end_time - start_time)) + [[ $duration -lt 7 ]] +} + +# setsid() in the perl fallback strips the controlling terminal, which breaks +# nested sudo inside brew cask uninstall scripts (issue #1003). The fallback must +# use setpgid to keep the tty while still enabling process-group kill. This guards +# against a regression that is otherwise only observable on a real terminal. +@test "timeout.sh: perl fallback must not detach the controlling tty (#1003)" { + # Match call statements at line start, not the comments that explain why + # setsid is avoided (those mention "setsid()" mid-line and would false-positive). + run grep -nE '^[[:space:]]*setsid[[:space:]]*\(' "$PROJECT_ROOT/lib/core/timeout.sh" + [ "$status" -ne 0 ] + run grep -nE '^[[:space:]]*setpgid[[:space:]]*\(' "$PROJECT_ROOT/lib/core/timeout.sh" + [ "$status" -eq 0 ] +} + +@test "run_with_timeout: shell fallback preserves caller INT trap" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN='' + trap 'echo caller-trap' INT + run_with_timeout 2 true + trap -p INT + ") + [[ "$result" == *"caller-trap"* ]] +} + +@test "run_with_timeout: shell fallback cleans up watchdog sleep" { + bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + MO_TIMEOUT_BIN='' + MO_TIMEOUT_PERL_BIN='' + run_with_timeout 287 true + sleep 0.1 + leaked='' + for pid in \$(pgrep -x sleep 2>/dev/null || true); do + command_line=\$(ps -p \"\$pid\" -o command= 2>/dev/null || true) + if [[ \"\$command_line\" == 'sleep 287' ]]; then + leaked=\"\$pid\" + kill \"\$pid\" 2>/dev/null || true + fi + done + [[ -z \"\$leaked\" ]] + " +} diff --git a/Resources/mole/tests/dev_extended.bats b/Resources/mole/tests/dev_extended.bats index ec7515e..1ac53b1 100644 --- a/Resources/mole/tests/dev_extended.bats +++ b/Resources/mole/tests/dev_extended.bats @@ -1,28 +1,30 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-dev-extended.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-dev-extended.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } @test "clean_dev_elixir cleans hex cache" { - mkdir -p "$HOME/.mix" "$HOME/.hex" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + mkdir -p "$HOME/.mix" "$HOME/.hex" + 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" @@ -30,28 +32,28 @@ safe_clean() { echo "$2"; } clean_dev_elixir EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Hex cache"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Hex cache"* ]] } @test "clean_dev_elixir does not clean mix archives" { - mkdir -p "$HOME/.mix/archives" - touch "$HOME/.mix/archives/test_tool.ez" - - # Source and run the function - source "$PROJECT_ROOT/lib/core/common.sh" - source "$PROJECT_ROOT/lib/clean/dev.sh" - # shellcheck disable=SC2329 - safe_clean() { :; } - clean_dev_elixir > /dev/null 2>&1 || true - - # Verify the file still exists - [ -f "$HOME/.mix/archives/test_tool.ez" ] + mkdir -p "$HOME/.mix/archives" + touch "$HOME/.mix/archives/test_tool.ez" + + # Source and run the function + source "$PROJECT_ROOT/lib/core/common.sh" + source "$PROJECT_ROOT/lib/clean/dev.sh" + # shellcheck disable=SC2329 + safe_clean() { :; } + clean_dev_elixir >/dev/null 2>&1 || true + + # Verify the file still exists + [ -f "$HOME/.mix/archives/test_tool.ez" ] } @test "clean_dev_haskell cleans cabal install cache" { - mkdir -p "$HOME/.cabal" "$HOME/.stack" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + mkdir -p "$HOME/.cabal" "$HOME/.stack" + 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" @@ -59,28 +61,28 @@ safe_clean() { echo "$2"; } clean_dev_haskell EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Cabal install cache"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Cabal install cache"* ]] } @test "clean_dev_haskell does not clean stack programs" { - mkdir -p "$HOME/.stack/programs/x86_64-osx" - touch "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" - - # Source and run the function - source "$PROJECT_ROOT/lib/core/common.sh" - source "$PROJECT_ROOT/lib/clean/dev.sh" - # shellcheck disable=SC2329 - safe_clean() { :; } - clean_dev_haskell > /dev/null 2>&1 || true - - # Verify the file still exists - [ -f "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" ] + mkdir -p "$HOME/.stack/programs/x86_64-osx" + touch "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" + + # Source and run the function + source "$PROJECT_ROOT/lib/core/common.sh" + source "$PROJECT_ROOT/lib/clean/dev.sh" + # shellcheck disable=SC2329 + safe_clean() { :; } + clean_dev_haskell >/dev/null 2>&1 || true + + # Verify the file still exists + [ -f "$HOME/.stack/programs/x86_64-osx/ghc-9.2.8.tar.xz" ] } @test "clean_dev_ocaml cleans opam cache" { - mkdir -p "$HOME/.opam" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + mkdir -p "$HOME/.opam" + 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" @@ -88,59 +90,29 @@ safe_clean() { echo "$2"; } clean_dev_ocaml EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Opam cache"* ]] -} - -@test "clean_dev_editors cleans VS Code and Zed caches" { - mkdir -p "$HOME/Library/Caches/com.microsoft.VSCode" "$HOME/Library/Application Support/Code" "$HOME/Library/Caches/Zed" - 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() { echo "$2"; } -clean_dev_editors -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"VS Code cached data"* ]] - [[ "$output" == *"Zed cache"* ]] -} - -@test "clean_dev_editors does not clean VS Code workspace storage" { - mkdir -p "$HOME/Library/Application Support/Code/User/workspaceStorage/abc123" - touch "$HOME/Library/Application Support/Code/User/workspaceStorage/abc123/workspace.json" - - # Source and run the function - source "$PROJECT_ROOT/lib/core/common.sh" - source "$PROJECT_ROOT/lib/clean/dev.sh" - # shellcheck disable=SC2329 - safe_clean() { :; } - clean_dev_editors > /dev/null 2>&1 || true - - # Verify the file still exists - [ -f "$HOME/Library/Application Support/Code/User/workspaceStorage/abc123/workspace.json" ] + [ "$status" -eq 0 ] + [[ "$output" == *"Opam cache"* ]] } @test "check_android_ndk reports multiple NDK versions" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"Android NDK versions: 3 found"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Android NDK versions: 3 found"* ]] } @test "check_android_ndk silent when only one NDK" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" != *"NDK versions"* ]] + [ "$status" -eq 0 ] + [[ "$output" != *"NDK versions"* ]] } @test "clean_xcode_device_support handles empty directories under nounset" { - local ds_dir="$HOME/EmptyDeviceSupport" - mkdir -p "$ds_dir" + local ds_dir="$HOME/EmptyDeviceSupport" + mkdir -p "$ds_dir" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -150,19 +122,19 @@ clean_xcode_device_support "$HOME/EmptyDeviceSupport" "iOS DeviceSupport" echo "survived" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"survived"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"survived"* ]] } @test "clean_xcode_documentation_cache keeps newest DeveloperDocumentation index" { - local doc_root="$HOME/DocumentationCache" - mkdir -p "$doc_root" - touch "$doc_root/DeveloperDocumentation.index" - touch "$doc_root/DeveloperDocumentation-16.0.index" - touch -t 202402010000 "$doc_root/DeveloperDocumentation.index" - touch -t 202401010000 "$doc_root/DeveloperDocumentation-16.0.index" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" bash --noprofile --norc <<'EOF' + local doc_root="$HOME/DocumentationCache" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" + touch "$doc_root/DeveloperDocumentation-16.0.index" + touch -t 202402010000 "$doc_root/DeveloperDocumentation.index" + touch -t 202401010000 "$doc_root/DeveloperDocumentation-16.0.index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -177,18 +149,18 @@ safe_sudo_remove() { clean_xcode_documentation_cache EOF - [ "$status" -eq 0 ] - [[ "$output" == *"CLEAN:$doc_root/DeveloperDocumentation-16.0.index:Xcode documentation cache (old indexes)"* ]] - [[ "$output" != *"CLEAN:$doc_root/DeveloperDocumentation.index:Xcode documentation cache (old indexes)"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"CLEAN:$doc_root/DeveloperDocumentation-16.0.index:Xcode documentation cache (old indexes)"* ]] + [[ "$output" != *"CLEAN:$doc_root/DeveloperDocumentation.index:Xcode documentation cache (old indexes)"* ]] } @test "clean_xcode_documentation_cache skips when Xcode is running" { - local doc_root="$HOME/DocumentationCache" - mkdir -p "$doc_root" - touch "$doc_root/DeveloperDocumentation.index" - touch "$doc_root/DeveloperDocumentation-16.0.index" + local doc_root="$HOME/DocumentationCache" + mkdir -p "$doc_root" + touch "$doc_root/DeveloperDocumentation.index" + touch "$doc_root/DeveloperDocumentation-16.0.index" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_DOCUMENTATION_CACHE_DIR="$doc_root" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -198,34 +170,158 @@ safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } clean_xcode_documentation_cache EOF - [ "$status" -eq 0 ] - [[ "$output" == *"skipping documentation cache cleanup"* ]] - [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"skipping documentation cache cleanup"* ]] + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@test "clean_xcode_system_coresimulator_caches removes only direct cache children" { + local cache_root="$HOME/SystemCoreSimulatorCaches" + mkdir -p "$cache_root/dyld/runtime" "$cache_root/metadata" + touch "$cache_root/dyld/runtime/cache" "$cache_root/metadata/index" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 1; } +has_sudo_session() { return 0; } +is_path_whitelisted() { return 1; } +should_protect_path() { return 1; } +safe_sudo_remove() { echo "REMOVE:$1"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"REMOVE:$cache_root/dyld"* ]] + [[ "$output" == *"REMOVE:$cache_root/metadata"* ]] + [[ "$output"$'\n' != *"REMOVE:$cache_root"$'\n'* ]] +} + +@test "clean_xcode_system_coresimulator_caches skips while CoreSimulator is active" { + local cache_root="$HOME/SystemCoreSimulatorCaches" + mkdir -p "$cache_root/dyld" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SYSTEM_CORESIMULATOR_CACHE_DIR="$cache_root" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" +note_activity() { :; } +pgrep() { return 0; } +safe_sudo_remove() { echo "UNEXPECTED_SAFE_SUDO_REMOVE"; } +clean_xcode_system_coresimulator_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CoreSimulator is running"* ]] + [[ "$output" != *"UNEXPECTED_SAFE_SUDO_REMOVE"* ]] +} + +@test "clean_xcode_xctest_devices targets only exact XCTestDevices directory" { + local developer_root="$HOME/Library/Developer" + mkdir -p "$developer_root/XCTestDevices" "$developer_root/XCTestDevices-old" + + 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() { :; } +pgrep() { return 1; } +safe_clean() { printf 'SAFE:%s|%s\n' "$1" "$2"; } +clean_xcode_xctest_devices +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SAFE:$developer_root/XCTestDevices|Xcode XCTestDevices test data"* ]] + [[ "$output" != *"XCTestDevices-old"* ]] +} + +@test "clean_xcode_xctest_devices skips while XCTest process is active" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + + 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() { :; } +pgrep() { + [[ "$*" == *"xcodebuild"* ]] +} +safe_clean() { echo "UNEXPECTED_SAFE_CLEAN"; } +clean_xcode_xctest_devices +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode or XCTest is running"* ]] + [[ "$output" != *"UNEXPECTED_SAFE_CLEAN"* ]] +} + +@test "clean_xcode_xctest_devices dry-run keeps XCTestDevices directory" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + touch "$xctest_root/test-device" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +DRY_RUN=true +MOLE_DRY_RUN=1 +pgrep() { return 1; } +clean_xcode_xctest_devices +[[ -d "$HOME/Library/Developer/XCTestDevices" ]] && echo "STILL_EXISTS" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode XCTestDevices test data"* ]] + [[ "$output" == *"dry"* ]] + [[ "$output" == *"STILL_EXISTS"* ]] +} + +@test "clean_xcode_xctest_devices respects whitelist" { + local xctest_root="$HOME/Library/Developer/XCTestDevices" + mkdir -p "$xctest_root" + touch "$xctest_root/test-device" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/clean.sh" +WHITELIST_PATTERNS=("$HOME/Library/Developer/XCTestDevices") +pgrep() { return 1; } +clean_xcode_xctest_devices +[[ -d "$HOME/Library/Developer/XCTestDevices" ]] && echo "STILL_EXISTS" +printf 'WHITELIST_SKIPPED:%s\n' "$whitelist_skipped_count" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"STILL_EXISTS"* ]] + [[ "$output" == *"WHITELIST_SKIPPED:1"* ]] } @test "check_rust_toolchains reports multiple toolchains" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"Rust toolchains: 3 found"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Rust toolchains: 3 found"* ]] } @test "check_rust_toolchains silent when only one toolchain" { - run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" + run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="✓" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh" - [ "$status" -eq 0 ] - [[ "$output" != *"Rust toolchains"* ]] + [ "$status" -eq 0 ] + [[ "$output" != *"Rust toolchains"* ]] } @test "clean_dev_jetbrains_toolbox cleans old versions and bypasses toolbox whitelist" { - local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" - mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/241.3" - ln -s "241.3" "$toolbox_channel/current" - touch -t 202401010000 "$toolbox_channel/241.1" - touch -t 202402010000 "$toolbox_channel/241.2" - touch -t 202403010000 "$toolbox_channel/241.3" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/241.3" + ln -s "241.3" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" + touch -t 202403010000 "$toolbox_channel/241.3" + + 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" @@ -246,18 +342,18 @@ MOLE_JETBRAINS_TOOLBOX_KEEP=1 clean_dev_jetbrains_toolbox EOF - [ "$status" -eq 0 ] - [[ "$output" == *"/241.1"* ]] - [[ "$output" != *"/241.2"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] + [[ "$output" != *"/241.2"* ]] } @test "clean_dev_jetbrains_toolbox keeps current directory and removes older versions" { - local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" - mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/current" - touch -t 202401010000 "$toolbox_channel/241.1" - touch -t 202402010000 "$toolbox_channel/241.2" + local toolbox_channel="$HOME/Library/Application Support/JetBrains/Toolbox/apps/IDEA/ch-0" + mkdir -p "$toolbox_channel/241.1" "$toolbox_channel/241.2" "$toolbox_channel/current" + touch -t 202401010000 "$toolbox_channel/241.1" + touch -t 202402010000 "$toolbox_channel/241.2" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -267,18 +363,230 @@ MOLE_JETBRAINS_TOOLBOX_KEEP=1 clean_dev_jetbrains_toolbox EOF - [ "$status" -eq 0 ] - [[ "$output" == *"/241.1"* ]] - [[ "$output" != *"/241.2"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"/241.1"* ]] + [[ "$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" + local copilot_root="$HOME/.copilot/pkg/universal" + mkdir -p "$claude_root" "$cursor_root" "$copilot_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" + mkdir -p "$copilot_root/1.0.5" "$copilot_root/1.0.32" "$copilot_root/1.0.34" + touch -t 202604010000 "$copilot_root/1.0.5" + touch -t 202604200000 "$copilot_root/1.0.32" + touch -t 202604250000 "$copilot_root/1.0.34" + + 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|"* ]] + [[ "$output" == *"/1.0.5|GitHub Copilot CLI old version"* ]] + [[ "$output" == *"/1.0.32|GitHub Copilot CLI old version"* ]] + [[ "$output" != *"/1.0.34|"* ]] +} + +@test "clean_dev_ai_agents protects the active version pointed at by ~/.local/bin/" { + local claude_root="$HOME/.local/share/claude/versions" + local cursor_root="$HOME/.local/share/cursor-agent/versions" + local bin_dir="$HOME/.local/bin" + rm -rf "$claude_root" "$cursor_root" "$bin_dir" + mkdir -p "$claude_root" "$cursor_root" "$bin_dir" + + mkdir -p "$claude_root/2.1.112" "$claude_root/2.1.113" "$claude_root/2.1.114" + touch -t 202604170000 "$claude_root/2.1.112" + touch -t 202604180000 "$claude_root/2.1.113" + touch -t 202604200000 "$claude_root/2.1.114" + ln -s "$claude_root/2.1.113" "$bin_dir/claude" + + mkdir -p "$cursor_root/2026.04.01-old" "$cursor_root/2026.04.10-active" "$cursor_root/2026.04.20-newest" + touch -t 202604010000 "$cursor_root/2026.04.01-old" + touch -t 202604100000 "$cursor_root/2026.04.10-active" + touch -t 202604200000 "$cursor_root/2026.04.20-newest" + : >"$cursor_root/2026.04.10-active/cursor-agent" + ln -s "$cursor_root/2026.04.10-active/cursor-agent" "$bin_dir/cursor-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/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|"* ]] + [[ "$output" != *"/2.1.114|"* ]] + [[ "$output" == *"/2026.04.01-old|Cursor Agent old version"* ]] + [[ "$output" != *"/2026.04.10-active|"* ]] + [[ "$output" != *"/2026.04.20-newest|"* ]] +} + +@test "clean_dev_ai_agents skips cleanup entirely when the active symlink is broken" { + local claude_root="$HOME/.local/share/claude/versions" + local bin_dir="$HOME/.local/bin" + rm -rf "$claude_root" "$bin_dir" + mkdir -p "$claude_root" "$bin_dir" + + mkdir -p "$claude_root/2.1.112" "$claude_root/2.1.113" "$claude_root/2.1.114" + touch -t 202604170000 "$claude_root/2.1.112" + touch -t 202604180000 "$claude_root/2.1.113" + touch -t 202604200000 "$claude_root/2.1.114" + ln -s "$claude_root/2.1.999-missing" "$bin_dir/claude" + + 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" != *"|Claude Code old version"* ]] + [[ "$output" == *"Claude Code old version active symlink is broken"* ]] + + rm -f "$bin_dir/claude" +} + +@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_dev_misc does not touch Claude Code state" { + mkdir -p "$HOME/.claude/projects/project-a/memory" + mkdir -p "$HOME/.claude/plugins/cache/plugin-a" + mkdir -p "$HOME/.claude/plugins/marketplaces" + mkdir -p "$HOME/.claude/paste-cache" + mkdir -p "$HOME/.claude/tmp" + mkdir -p "$HOME/.claude/session-env" + mkdir -p "$HOME/.claude/shell-snapshots" + + 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 'SAFE:%s|%s\n' "$1" "$2"; } +safe_find_delete() { printf 'FIND:%s|%s|%s|%s\n' "$1" "$2" "$3" "$4"; } +clean_service_worker_cache() { :; } +clean_dev_misc +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"$HOME/.claude/projects"* ]] + [[ "$output" != *"$HOME/.claude/plugins/cache"* ]] + [[ "$output" != *"$HOME/.claude/plugins/marketplaces"* ]] + [[ "$output" != *"$HOME/.claude/paste-cache"* ]] + [[ "$output" != *"$HOME/.claude/tmp"* ]] + [[ "$output" != *"$HOME/.claude/session-env"* ]] + [[ "$output" != *"$HOME/.claude/shell-snapshots"* ]] } @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" - mkdir -p "$volumes_root/in-use-runtime" "$volumes_root/unused-runtime" - mkdir -p "$cryptex_root" + local volumes_root="$HOME/sim-volumes" + local cryptex_root="$HOME/sim-cryptex" + mkdir -p "$volumes_root/in-use-runtime" "$volumes_root/unused-runtime" + mkdir -p "$cryptex_root" - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$cryptex_root" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_XCODE_SIM_RUNTIME_VOLUMES_ROOT="$volumes_root" MOLE_XCODE_SIM_RUNTIME_CRYPTEX_ROOT="$cryptex_root" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -310,16 +618,16 @@ echo "SIZE_LOG_START" cat "$size_log" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Xcode runtime volumes · scanning 2 entries"* ]] - [[ "$output" == *"Xcode runtime volumes · cleaning 1 unused"* ]] - [[ "$output" == *"REMOVE:$volumes_root/unused-runtime"* ]] - [[ "$output" == *"$volumes_root/unused-runtime"* ]] - [[ "$output" != *"$volumes_root/in-use-runtime"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Xcode runtime volumes · scanning 2 entries"* ]] + [[ "$output" == *"Xcode runtime volumes · cleaning 1 unused"* ]] + [[ "$output" == *"REMOVE:$volumes_root/unused-runtime"* ]] + [[ "$output" == *"$volumes_root/unused-runtime"* ]] + [[ "$output" != *"$volumes_root/in-use-runtime"* ]] } @test "clean_dev_mobile continues cleanup when simctl is unavailable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/dev.sh" @@ -336,8 +644,63 @@ xcrun() { return 1; } clean_dev_mobile EOF - [ "$status" -eq 0 ] - [[ "$output" == *"simctl not available"* ]] - [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] - [[ "$output" == *"SAFE_CLEAN:Android SDK cache"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"simctl not available"* ]] + [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] + [[ "$output" == *"SAFE_CLEAN:Android SDK cache"* ]] +} + +@test "clean_dev_mobile retries simctl probe on cold-boot timeout (#890)" { + # Exercises the timeout-retry branch (the only path the #890 fix touches). + # Strategy: + # - put a real `xcrun` shim on PATH so `command -v xcrun` succeeds AND + # `declare -F xcrun` returns false → function falls into the else branch. + # - stub `run_with_timeout` so the first probe returns 124 (timeout) and + # the second returns 0, mirroring a cold-boot CoreSimulatorService + # warmup. + # - the shim itself returns empty for the post-probe + # `xcrun simctl list devices unavailable` call so we take the + # "already clean" branch and don't try to delete anything. + local tmp_bin + tmp_bin="$(mktemp -d)" + cat > "$tmp_bin/xcrun" <<'XEOF' +#!/bin/bash +exit 0 +XEOF + chmod +x "$tmp_bin/xcrun" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TMP_XCRUN_BIN="$tmp_bin" DRY_RUN=false bash --noprofile --norc <<'EOF' +set -euo pipefail +PATH="$TMP_XCRUN_BIN:$PATH" +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_device_support() { :; } +safe_clean() { :; } +note_activity() { :; } +debug_log() { echo "debug: $*"; } +sleep() { :; } # skip the 1s pause between probes for fast tests + +# First call (5s timeout) simulates cold-boot warmup → return 124. +# Second call (8s timeout) succeeds. +__rwt_count=0 +run_with_timeout() { + __rwt_count=$((__rwt_count + 1)) + if [[ $__rwt_count -eq 1 ]]; then + return 124 + fi + return 0 +} + +clean_dev_mobile +EOF + + rm -rf "$tmp_bin" + + [ "$status" -eq 0 ] + [[ "$output" == *"simctl probe succeeded on retry"* ]] || return 1 + [[ "$output" != *"simctl not available"* ]] || return 1 } 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..d88c982 --- /dev/null +++ b/Resources/mole/tests/file_ops_mole_delete.bats @@ -0,0 +1,442 @@ +#!/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 moves sudo-required paths to invoking user Trash" { + local victim="$SANDBOX/victim_sudo_trash" + local fake_bin="$SANDBOX/bin" + local fake_home="$SANDBOX/home" + local trace="$SANDBOX/trace.log" + + mkdir -p "$fake_bin" "$fake_home" + mkdir -p "$victim" + printf 'payload' > "$victim/data.txt" + + cat > "$fake_bin/trash" <<'SH' +#!/bin/bash +printf 'trash %s\n' "$*" >> "$MOLE_TEST_TRACE" +exit 99 +SH + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + cat > "$fake_bin/osascript" <<'SH' +#!/bin/bash +printf 'osascript %s\n' "$*" >> "$MOLE_TEST_TRACE" +exit 98 +SH + chmod +x "$fake_bin/trash" "$fake_bin/sudo" "$fake_bin/osascript" + + run bash --noprofile --norc < /dev/null || true)" -eq 1 ]] + [[ "$(grep -c '^sudo -n trash ' "$trace" 2> /dev/null || true)" -eq 0 ]] + [[ "$(grep -c '^trash ' "$trace" 2> /dev/null || true)" -eq 0 ]] + [[ "$(grep -c '^osascript ' "$trace" 2> /dev/null || true)" -eq 0 ]] + + local status_col + status_col=$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG") + [ "$status_col" = "ok" ] +} + +@test "mole_delete refuses symlinked invoking user Trash for sudo-required paths" { + local victim="$SANDBOX/victim_sudo_symlink_trash" + local fake_bin="$SANDBOX/bin" + local fake_home="$SANDBOX/home" + local redirected="$SANDBOX/redirected" + local trace="$SANDBOX/trace.log" + + mkdir -p "$fake_bin" "$fake_home" "$redirected" "$victim" + printf 'payload' > "$victim/data.txt" + ln -s "$redirected" "$fake_home/.Trash" + + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + chmod +x "$fake_bin/sudo" + + run bash --noprofile --norc < /dev/null || true)" ]] + [[ "$(grep -c '^sudo -n mv ' "$trace" 2> /dev/null || true)" -eq 0 ]] + + local status_col + status_col=$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG") + [ "$status_col" = "trash-failed" ] +} + +@test "mole_delete uses unique Trash name for sudo-required path conflicts" { + local victim="$SANDBOX/conflict_app" + local fake_bin="$SANDBOX/bin" + local fake_home="$SANDBOX/home" + local trace="$SANDBOX/trace.log" + + mkdir -p "$fake_bin" "$fake_home/.Trash" "$victim" + printf 'payload' > "$victim/data.txt" + mkdir -p "$fake_home/.Trash/$(basename "$victim")" + + cat > "$fake_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$MOLE_TEST_TRACE" +if [[ "${1:-}" == "-n" ]]; then + shift +fi +"$@" +SH + chmod +x "$fake_bin/sudo" + + run bash --noprofile --norc < /dev/null || true)" -eq 1 ]] + + local status_col + status_col=$(awk -F'\t' 'END { print $4 }' "$MOLE_DELETE_LOG") + [ "$status_col" = "ok" ] +} + +@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" + + run bash --noprofile --norc < "$first" + : > "$second" + + 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 < "$first" + : > "$second" + + 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/file_ops_size.bats b/Resources/mole/tests/file_ops_size.bats new file mode 100644 index 0000000..a8e59c2 --- /dev/null +++ b/Resources/mole/tests/file_ops_size.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats + +# Tests for get_path_size_kb in lib/core/file_ops.sh. +# Exercises the stat fast-path for regular files / symlinks and the du +# fallback for directories, plus error and edge cases. Numbers chosen to +# reveal rounding bugs (KB ceiling) and to confirm symlinks are NOT +# followed. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-fileops-size.XXXXXX")" + export SANDBOX + export MOLE_TEST_NO_AUTH=1 +} + +teardown() { + rm -rf "$SANDBOX" +} + +prelude() { + cat << EOF +set -euo pipefail +export MOLE_TEST_NO_AUTH=1 +source "$PROJECT_ROOT/lib/core/common.sh" +EOF +} + +@test "get_path_size_kb returns 0 for empty path" { + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "" +EOF + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "get_path_size_kb returns 0 for non-existent path" { + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/does-not-exist" +EOF + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "get_path_size_kb returns 0 for empty file" { + : > "$SANDBOX/empty" + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/empty" +EOF + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "get_path_size_kb rounds up sub-KB files to 1 KB" { + # 500 bytes is < 1 KB; ceiling rounding should report 1. + dd if=/dev/zero of="$SANDBOX/small" bs=500 count=1 2> /dev/null + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/small" +EOF + [ "$status" -eq 0 ] + [ "$output" = "1" ] +} + +@test "get_path_size_kb reports exact 1 KB for 1024-byte file" { + dd if=/dev/zero of="$SANDBOX/onek" bs=1024 count=1 2> /dev/null + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/onek" +EOF + [ "$status" -eq 0 ] + [ "$output" = "1" ] +} + +@test "get_path_size_kb rounds up odd byte counts" { + # 50000 bytes / 1024 = 48.83..., ceiling is 49. + dd if=/dev/zero of="$SANDBOX/odd" bs=50000 count=1 2> /dev/null + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/odd" +EOF + [ "$status" -eq 0 ] + [ "$output" = "49" ] +} + +@test "get_path_size_kb does not follow symlinks" { + # 100 KB target, symlink should report its own (tiny) size, not 100 KB. + dd if=/dev/zero of="$SANDBOX/target" bs=1024 count=100 2> /dev/null + ln -s "$SANDBOX/target" "$SANDBOX/link" + + target_kb=$(bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/target" +EOF +) + link_kb=$(bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/link" +EOF +) + + [ "$target_kb" = "100" ] + # Symlink path strings are short, so link size rounds to 1 KB or 0. + # Either is acceptable; what must NOT happen is the link reporting the + # 100 KB target size. + [ "$link_kb" -lt 10 ] +} + +@test "get_path_size_kb still returns 0 for broken symlinks" { + ln -s "$SANDBOX/missing" "$SANDBOX/broken" + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/broken" +EOF + [ "$status" -eq 0 ] + # -e on a broken symlink returns false, so the early return triggers. + [ "$output" = "0" ] +} + +@test "get_path_size_kb sums directory contents recursively" { + mkdir -p "$SANDBOX/dir/sub" + dd if=/dev/zero of="$SANDBOX/dir/a" bs=1024 count=10 2> /dev/null + dd if=/dev/zero of="$SANDBOX/dir/sub/b" bs=1024 count=20 2> /dev/null + + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$SANDBOX/dir" +EOF + [ "$status" -eq 0 ] + # Should be at least the sum of the two files (30 KB). Filesystem + # overhead may push it slightly higher, so use >= rather than ==. + [ "$output" -ge 30 ] +} + +@test "get_path_size_kb handles whitespace in paths" { + local quirky="$SANDBOX/dir with spaces" + mkdir -p "$quirky" + dd if=/dev/zero of="$quirky/payload" bs=1024 count=5 2> /dev/null + + run bash --noprofile --norc << EOF +$(prelude) +get_path_size_kb "$quirky/payload" +EOF + [ "$status" -eq 0 ] + [ "$output" = "5" ] +} diff --git a/Resources/mole/tests/fuzz_corpus/dangerous_paths.txt b/Resources/mole/tests/fuzz_corpus/dangerous_paths.txt new file mode 100644 index 0000000..c9f5371 --- /dev/null +++ b/Resources/mole/tests/fuzz_corpus/dangerous_paths.txt @@ -0,0 +1,106 @@ +# Adversarial path corpus for validate_path_for_deletion. +# Every path in this file MUST be rejected (validate returns non-zero). +# Lines starting with # or empty are ignored. No quoting. +# When adding entries: prefer real-world attacks over synthetic ones. +# +# Categories (kept grouped for review, not parsed): + +# ---- empty / not absolute ---- +relative/path +./foo +foo/bar +~/Documents +just-a-name + +# ---- root and top-level system dirs ---- +/ +/bin +/sbin +/usr +/usr/bin +/usr/sbin +/usr/lib +/System +/Library/Extensions +/private +/etc +/private/etc +/var +/var/db +/private/var +/private/var/db + +# ---- shallow system files ---- +/bin/bash +/bin/sh +/sbin/init +/usr/bin/ls +/usr/lib/dyld +/System/Library/CoreServices/Finder.app +/etc/passwd +/etc/sudoers +/private/etc/passwd +/var/db/dslocal +/private/var/db/dslocal/nodes/Default/users/root.plist + +# ---- path traversal variants ---- +/../etc/passwd +/Users/../etc/passwd +/Users/me/../../etc +/private/var/db/../../../etc/passwd +/var/../etc/passwd +/Users/me/Documents/../../../etc +/../../../../../etc + +# ---- control character injection ---- +/Users/me/foo bar + +# ---- protected system caches ---- +/System/Library/Caches +/System/Library/Caches/com.apple.kext.caches +/Library/Apple +/Library/Apple/System +/System/Library +/System/Volumes/Data/System +/System/Library/PrivateFrameworks + +# ---- protected app/data namespaces ---- +/Applications/Safari.app +/Applications/Finder.app +/System/Applications/Mail.app +/System/Applications/Settings.app +/System/Applications/Utilities/Keychain Access.app +/Library/Keychains +/Library/Keychains/login.keychain-db + +# ---- /var/db sensitive subtrees ---- +/var/db/sudo +/var/db/SystemPolicy +/var/db/locationd +/var/db/uuidtext +/private/var/db/sudo +/private/var/db/SystemPolicy + +# ---- attempts to escape with weird separators ---- +//etc/passwd +///System +/etc/./passwd +/etc/. +/etc/.. +/System/. + +# ---- attempts to look like /private aliases ---- +/private/etc/sudoers +/private/etc/master.passwd +/private/var/audit +/private/var/folders/.. + +# ---- realistic user-facing oopses ---- +/Users +/Users/Shared +/Users/Guest + +# ---- realistic mistakes that should still fail (no absolute prefix) ---- +Library/Caches/com.apple.spotlight +.Trash +.cache/foo diff --git a/Resources/mole/tests/history.bats b/Resources/mole/tests/history.bats new file mode 100644 index 0000000..9b95802 --- /dev/null +++ b/Resources/mole/tests/history.bats @@ -0,0 +1,201 @@ +#!/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-history-home.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + rm -rf "$HOME/Library" + mkdir -p "$HOME/Library/Logs/mole" +} + +write_history_logs() { + cat > "$HOME/Library/Logs/mole/operations.log" <<'EOF' +# ========== clean session started at 2026-05-24 10:00:00 ========== +[2026-05-24 10:00:01] [clean] REMOVED /tmp/cache one (2KB) +[2026-05-24 10:00:02] [clean] TRASHED /tmp/Old App.app (4KB) +[2026-05-24 10:00:03] [clean] SKIPPED /tmp/protected (whitelist) +[2026-05-24 10:00:04] [clean] FAILED /tmp/fail (permission denied) +# ========== clean session ended at 2026-05-24 10:00:05, 2 items, 6KB ========== +# ========== purge session started at 2026-05-24 11:00:00 ========== +[2026-05-24 11:00:01] [purge] REMOVED /tmp/build (10KB) +# ========== purge session ended at 2026-05-24 11:00:02, 1 items, 10KB ========== +EOF + + printf '2026-05-24T10:00:02+0000\ttrash\t4\tok\t/tmp/Old App.app\n' > "$HOME/Library/Logs/mole/deletions.log" + printf '2026-05-24T11:00:01+0000\tpermanent\t10\tdry-run\t/tmp/build\n' >> "$HOME/Library/Logs/mole/deletions.log" +} + +@test "mo history summarizes operation sessions and deletion audit" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"Mole History"* ]] + [[ "$output" == *"purge"* ]] + [[ "$output" == *"1 items, 10KB"* ]] + [[ "$output" == *"clean"* ]] + [[ "$output" == *"removed 1, trashed 1, skipped 1, failed 1"* ]] + [[ "$output" == *"/tmp/Old App.app"* ]] +} + +@test "mo history --json returns stable parseable fields" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --json + [ "$status" -eq 0 ] + + printf '%s\n' "$output" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +assert data["limit"] == 20 +assert data["sessions"][0]["command"] == "purge" +assert data["sessions"][1]["command"] == "clean" +assert data["sessions"][1]["actions"]["trashed"] == 1 +assert data["sessions"][1]["actions"]["failed"] == 1 +assert data["deletions"][0]["mode"] == "permanent" +assert data["deletions"][0]["size_kb"] == 10 +assert data["deletions"][1]["path"] == "/tmp/Old App.app" +' +} + +@test "mo history --json escapes unusual path characters" { + : > "$HOME/Library/Logs/mole/operations.log" + weird_path=$'/tmp/unicode-\xe9\x9b\xaa-quote"slash\\tab\tbackspace\bformfeed\fend' + printf '2026-05-24T10:00:02+0000\ttrash\t4\tok\t%s\n' "$weird_path" > "$HOME/Library/Logs/mole/deletions.log" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --json + [ "$status" -eq 0 ] + + printf '%s\n' "$output" | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +assert data["deletions"][0]["path"] == "/tmp/unicode-\u96ea-quote\"slash\\tab\tbackspace\bformfeed\fend" +' +} + +@test "mo history --limit caps sessions and deletion entries" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 1 + [ "$status" -eq 0 ] + [[ "$output" == *"purge"* ]] + [[ "$output" != *"clean 2026-05-24 10:00:00"* ]] + [[ "$output" == *"/tmp/build"* ]] + [[ "$output" != *"/tmp/Old App.app"* ]] +} + +@test "mo history --limit accepts decimal values with leading zeros" { + write_history_logs + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 0001 + [ "$status" -eq 0 ] + [[ "$output" == *"purge"* ]] + [[ "$output" != *"clean 2026-05-24 10:00:00"* ]] + [[ "$output" != *"value too great for base"* ]] +} + +@test "mo history handles empty logs" { + : > "$HOME/Library/Logs/mole/operations.log" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"No operation history yet"* ]] + [[ "$output" == *"No deletion audit entries yet"* ]] +} + +@test "mo history tolerates malformed session summaries" { + cat > "$HOME/Library/Logs/mole/operations.log" <<'EOF' +# ========== clean session started at 2026-05-24 10:00:00 ========== +[2026-05-24 10:00:01] [clean] REMOVED /tmp/cache (2KB) +# ========== clean session ended at malformed summary ========== +EOF + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"clean 2026-05-24 10:00:00, 0 items, 0B"* ]] + [[ "$output" == *"removed 1, ended malformed summary"* ]] + [[ "$output" != *"malformed summary items"* ]] +} + +@test "mo history does not create logs when none exist" { + rm -rf "$HOME/Library" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history + [ "$status" -eq 0 ] + [[ "$output" == *"No operation history yet"* ]] + [ ! -e "$HOME/Library/Logs/mole/operations.log" ] + [ ! -e "$HOME/Library/Logs/mole/mole.log" ] +} + +@test "mo history early dispatch respects source guard" { + # shellcheck disable=SC2016 + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc -c ' +set -euo pipefail +set -- history +MOLE_TEST_MODE=1 +MOLE_SKIP_MAIN=1 +source "$PROJECT_ROOT/mole" +echo sourced +' + [ "$status" -eq 0 ] + [[ "$output" == *"sourced"* ]] + [[ "$output" != *"Mole History"* ]] +} + +@test "mo history early dispatch keeps global debug flag behavior" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" --debug history --limit 0001 + [ "$status" -eq 0 ] + [[ "$output" == *"Mole History"* ]] + [[ "$output" != *"Unknown option"* ]] + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --debug --limit 0001 + [ "$status" -eq 0 ] + [[ "$output" == *"Mole History"* ]] + [[ "$output" != *"Unknown option"* ]] +} + +@test "mo history rejects unknown options" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --bad-option + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown option for mo history"* ]] +} + +@test "mo history rejects invalid limit values" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit nope + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid value for --limit"* ]] + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 500 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid value for --limit"* ]] + + run env HOME="$HOME" "$PROJECT_ROOT/mole" history --limit 999999999999999999999999 + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid value for --limit"* ]] + [[ "$output" != *"value too great for base"* ]] +} diff --git a/Resources/mole/tests/install_checksum.bats b/Resources/mole/tests/install_checksum.bats new file mode 100644 index 0000000..c17e501 --- /dev/null +++ b/Resources/mole/tests/install_checksum.bats @@ -0,0 +1,416 @@ +#!/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-install-checksum-home.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + rm -rf "${HOME:?}"/* + mkdir -p "$HOME/source" "$HOME/config/bin" "$HOME/install" + cat > "$HOME/source/mole" <<'MOLE' +VERSION="1.2.3" +MOLE +} + +load_installer_binary_helpers() { + eval "$(sed -n '/^get_source_version()/,/^install_files()/p' "$PROJECT_ROOT/install.sh" | sed '$d')" +} +export -f load_installer_binary_helpers + +@test "download_binary installs release asset only after checksum verification" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +# Exercise the checksum-only path deterministically: a real authenticated gh on +# the host would otherwise run `attestation verify` against the fake fixture and +# fail. Attestation policy itself is covered by its own test below. +verify_release_attestation() { return 2; } + +content="verified-binary" +asset="analyze-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf '%s' "$content" | shasum -a 256 | awk '{print $1}') + +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf '%s' "$content" > "$out" ;; + *"SHA256SUMS") printf '%s %s\n' "$hash" "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +download_binary "analyze" +grep -q "verified-binary" "$CONFIG_DIR/bin/analyze-go" +test -x "$CONFIG_DIR/bin/analyze-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS:Downloaded analyze binary"* ]] +} + +@test "download_binary rejects checksum mismatch and falls back to local build" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +build_binary_from_source() { + printf 'built-from-source' > "$2" + chmod +x "$2" + return 0 +} + +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf 'tampered-binary' > "$out" ;; + *"SHA256SUMS") printf '%064d %s\n' 0 "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +download_binary "status" +grep -q "built-from-source" "$CONFIG_DIR/bin/status-go" +! grep -q "tampered-binary" "$CONFIG_DIR/bin/status-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"WARNING:Checksum verification failed for status, trying local build"* ]] +} + +@test "download_binary rejects SHA256SUMS without matching asset entry" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +build_binary_from_source() { + printf 'rebuilt-after-missing-checksum' > "$2" + chmod +x "$2" + return 0 +} + +asset="analyze-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf 'release-binary' | shasum -a 256 | awk '{print $1}') +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf 'release-binary' > "$out" ;; + *"SHA256SUMS") printf '%s other-asset\n' "$hash" > "$out" ;; + *) return 1 ;; + esac +} + +download_binary "analyze" +grep -q "rebuilt-after-missing-checksum" "$CONFIG_DIR/bin/analyze-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"WARNING:Checksum verification failed for analyze, trying local build"* ]] +} + +@test "download_binary rejects release asset when SHA256SUMS cannot be downloaded" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +build_binary_from_source() { + printf 'rebuilt-after-checksum-404' > "$2" + chmod +x "$2" + return 0 +} + +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"${asset}") printf 'release-binary' > "$out" ;; + *"SHA256SUMS") return 22 ;; + *) return 1 ;; + esac +} + +download_binary "status" +grep -q "rebuilt-after-checksum-404" "$CONFIG_DIR/bin/status-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"WARNING:Checksum verification failed for status, trying local build"* ]] +} + +@test "download_binary verifies fallback release asset against fallback checksums" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +INSTALL_DIR="$HOME/install" +CONFIG_DIR="$HOME/config" +SOURCE_DIR="$HOME/source" +VERBOSE=1 +GREEN='' BLUE='' YELLOW='' RED='' NC='' +ICON_SUCCESS='ok' +ICON_ERROR='err' + +load_installer_binary_helpers + +start_line_spinner() { :; } +stop_line_spinner() { :; } +log_success() { echo "SUCCESS:$*"; } +log_warning() { echo "WARNING:$*"; } +log_error() { echo "ERROR:$*"; } +get_latest_release_tag() { echo "V1.2.2"; } +# See note above: keep the fallback-checksum path independent of host gh state. +verify_release_attestation() { return 2; } + +content="fallback-binary" +asset="status-darwin-$(uname -m | sed 's/x86_64/amd64/')" +hash=$(printf '%s' "$content" | shasum -a 256 | awk '{print $1}') +curl() { + local out="" url="" + while [[ $# -gt 0 ]]; do + case "$1" in + -o) out="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac + done + case "$url" in + *"V1.2.3/${asset}") return 22 ;; + *"V1.2.2/${asset}") printf '%s' "$content" > "$out" ;; + *"V1.2.2/SHA256SUMS") printf '%s %s\n' "$hash" "$asset" > "$out" ;; + *) return 1 ;; + esac +} + +download_binary "status" +grep -q "fallback-binary" "$CONFIG_DIR/bin/status-go" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS:Downloaded status from V1.2.2"* ]] +} + + +@test "write_install_channel_metadata succeeds for stable channel with empty commit hash" { + # Regression: the previous `[[ -n "$h" ]] && printf` form returned 1 + # whenever the commit hash was empty (always the case on stable), making + # the block redirect look like an I/O failure and tripping the warning. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +CONFIG_DIR="$HOME/config" +mkdir -p "$CONFIG_DIR" + +eval "$(sed -n '/^write_install_channel_metadata()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +if ! write_install_channel_metadata "stable" ""; then + echo "WRONG: stable write reported failure"; exit 1 +fi +[[ -f "$CONFIG_DIR/install_channel" ]] || { echo "WRONG: file not created"; exit 1; } +grep -q '^CHANNEL=stable$' "$CONFIG_DIR/install_channel" || { echo "WRONG: channel value missing"; cat "$CONFIG_DIR/install_channel"; exit 1; } +grep -q '^COMMIT_HASH=' "$CONFIG_DIR/install_channel" && { echo "WRONG: commit hash leaked"; exit 1; } + +# Nightly path with a commit hash should still work. +if ! write_install_channel_metadata "nightly" "deadbeef"; then + echo "WRONG: nightly write failed"; exit 1 +fi +grep -q '^CHANNEL=nightly$' "$CONFIG_DIR/install_channel" || { echo "WRONG: nightly channel"; exit 1; } +grep -q '^COMMIT_HASH=deadbeef$' "$CONFIG_DIR/install_channel" || { echo "WRONG: nightly commit"; exit 1; } + +# No leftover temp files. +if ls "$CONFIG_DIR"/install_channel.?????? 2>/dev/null | grep -q .; then + echo "WRONG: tmp file leaked"; ls "$CONFIG_DIR"; exit 1 +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "verify_release_attestation maps gh availability and result to 2/0/1" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +eval "$(sed -n '/^verify_release_attestation()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +stubdir="$(mktemp -d "${TMPDIR:-/tmp}/mole-gh-stub.XXXXXX")" +cat > "$stubdir/gh" <<'STUB' +#!/bin/bash +case "$1 $2" in + "auth status") exit "${STUB_AUTH_RC:-0}" ;; + "attestation verify") exit "${STUB_VERIFY_RC:-0}" ;; +esac +exit 0 +STUB +chmod +x "$stubdir/gh" +target="$(mktemp "${TMPDIR:-/tmp}/mole-att-file.XXXXXX")" + +# gh missing -> cannot verify (2) +( PATH="/var/empty"; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 2 ] || { echo "WRONG: gh-missing rc=$rc want 2"; exit 1; } + +# gh present but unauthenticated -> cannot verify (2) +( PATH="$stubdir:$PATH"; export STUB_AUTH_RC=1; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 2 ] || { echo "WRONG: unauth rc=$rc want 2"; exit 1; } + +# gh authenticated + attestation verifies -> 0 +( PATH="$stubdir:$PATH"; export STUB_AUTH_RC=0 STUB_VERIFY_RC=0; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 0 ] || { echo "WRONG: verify-ok rc=$rc want 0"; exit 1; } + +# gh authenticated + attestation fails -> 1 +( PATH="$stubdir:$PATH"; export STUB_AUTH_RC=0 STUB_VERIFY_RC=1; verify_release_attestation "$target" ) && rc=0 || rc=$? +[ "$rc" -eq 1 ] || { echo "WRONG: verify-fail rc=$rc want 1"; exit 1; } + +rm -rf "$stubdir" "$target" +EOF + + [ "$status" -eq 0 ] +} + +@test "verify_release_asset_checksum enforces attestation policy gate" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +eval "$(sed -n '/^extract_release_checksum()/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^calculate_file_sha256()/,/^}/p' "$PROJECT_ROOT/install.sh")" +eval "$(sed -n '/^verify_release_asset_checksum()/,/^}/p' "$PROJECT_ROOT/install.sh")" + +log_success() { echo "SUCCESS:$*"; } +log_error() { echo "ERROR:$*"; } + +asset="status-darwin-amd64" +file="$(mktemp "${TMPDIR:-/tmp}/mole-asset.XXXXXX")" +printf 'release-binary' > "$file" +hash="$(printf 'release-binary' | shasum -a 256 | awk '{print $1}')" +download_release_checksums() { printf '%s %s\n' "$hash" "$asset" > "$2"; return 0; } + +# attestation verification failed (status 1) -> fatal, never installs +verify_release_attestation() { return 1; } +out="$(verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 1 ] || { echo "WRONG: status1 rc=$rc want 1"; exit 1; } +[[ "$out" == *"ERROR:Release attestation verification failed"* ]] || { echo "WRONG: status1 error missing: $out"; exit 1; } + +# cannot verify (status 2) + MOLE_REQUIRE_ATTESTATION=1 -> fatal +verify_release_attestation() { return 2; } +out="$(MOLE_REQUIRE_ATTESTATION=1 verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 1 ] || { echo "WRONG: require-gate rc=$rc want 1"; exit 1; } +[[ "$out" == *"ERROR:MOLE_REQUIRE_ATTESTATION=1 set but gh"* ]] || { echo "WRONG: require-gate error missing: $out"; exit 1; } + +# cannot verify (status 2) without the gate -> falls back to checksum-only +verify_release_attestation() { return 2; } +out="$(verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 0 ] || { echo "WRONG: checksum-only rc=$rc want 0"; exit 1; } + +# attestation verified (status 0) + checksum match -> success with combined label +verify_release_attestation() { return 0; } +out="$(verify_release_asset_checksum V1.0.0 "$asset" "$file")" && rc=0 || rc=$? +[ "$rc" -eq 0 ] || { echo "WRONG: verified rc=$rc want 0"; exit 1; } +[[ "$out" == *"SUCCESS:Verified ${asset} (sha256 + attestation)"* ]] || { echo "WRONG: verified success missing: $out"; exit 1; } + +rm -f "$file" +EOF + + [ "$status" -eq 0 ] +} diff --git a/Resources/mole/tests/installer.bats b/Resources/mole/tests/installer.bats index 1e26595..5e5b486 100644 --- a/Resources/mole/tests/installer.bats +++ b/Resources/mole/tests/installer.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" export MO_DEBUG=0 @@ -244,3 +251,188 @@ setup() { [[ "$output" != *"symlink.dmg"* ]] [[ "$output" != *"dangling.lnk"* ]] } + +@test "delete_selected_installers removes selected files and records successes" { + local first="$HOME/Downloads/First.dmg" + local second="$HOME/Downloads/Second.pkg" + printf 'one' > "$first" + printf 'two' > "$second" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + + INSTALLER_PATHS=("$2" "$3") + INSTALLER_SIZES=(3 3) + MOLE_SELECTION_RESULT="0,1" + + delete_selected_installers < <(printf "\n") + printf "deleted=%s failed=%s freed=%s\n" "$total_deleted" "${total_delete_failed:-0}" "$total_size_freed_kb" + [[ ! -e "$2" ]] + [[ ! -e "$3" ]] + grep -F "[installer] REMOVED $2" "$HOME/Library/Logs/mole/operations.log" > /dev/null + ' bash "$PROJECT_ROOT/bin/installer.sh" "$first" "$second" + + [ "$status" -eq 0 ] + [[ "$output" == *"deleted=2 failed=0"* ]] +} + +@test "delete_selected_installers records protected-path failures" { + local removable="$HOME/Downloads/Good.dmg" + printf 'good' > "$removable" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + + system_size=$(get_file_size "/System") + INSTALLER_PATHS=("$2" "/System") + INSTALLER_SIZES=(4 "$system_size") + MOLE_SELECTION_RESULT="0,1" + + set +e + delete_selected_installers < <(printf "\n") + rc=$? + set -e + printf "rc=%s deleted=%s failed=%s\n" "$rc" "$total_deleted" "${total_delete_failed:-0}" + if [[ ${total_delete_failed:-0} -gt 0 ]]; then + printf "failure=%s\n" "${INSTALLER_DELETE_FAILURES[0]}" + fi + [[ ! -e "$2" ]] + ' bash "$PROJECT_ROOT/bin/installer.sh" "$removable" + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=3 deleted=1 failed=1"* ]] + [[ "$output" == *"failure=/System (delete failed)"* ]] +} + +@test "execute_installer_delete_plan refuses replaced files" { + local target="$HOME/Downloads/Replaced.dmg" + local replacement="$HOME/Downloads/Replacement.dmg" + printf 'one' > "$target" + printf 'one' > "$replacement" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + source "$1" + + INSTALLER_PATHS=("$2") + INSTALLER_SIZES=("$(get_file_size "$2")") + build_installer_delete_plan 0 + mv "$2" "$2.old" + mv "$3" "$2" + + set +e + execute_installer_delete_plan + rc=$? + set -e + + printf "rc=%s deleted=%s failed=%s failure=%s\n" "$rc" "$total_deleted" "$total_delete_failed" "${INSTALLER_DELETE_FAILURES[0]}" + [[ -e "$2" ]] + [[ -e "$2.old" ]] + ' bash "$PROJECT_ROOT/bin/installer.sh" "$target" "$replacement" + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=3 deleted=0 failed=1"* ]] + [[ "$output" == *"Replaced.dmg (changed since scan)"* ]] +} + +@test "execute_installer_delete_plan refuses size drift" { + local target="$HOME/Downloads/Grew.dmg" + printf 'one' > "$target" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + source "$1" + + INSTALLER_PATHS=("$2") + INSTALLER_SIZES=("$(get_file_size "$2")") + build_installer_delete_plan 0 + printf "two" >> "$2" + + set +e + execute_installer_delete_plan + rc=$? + set -e + + printf "rc=%s deleted=%s failed=%s failure=%s\n" "$rc" "$total_deleted" "$total_delete_failed" "${INSTALLER_DELETE_FAILURES[0]}" + [[ -e "$2" ]] + ' bash "$PROJECT_ROOT/bin/installer.sh" "$target" + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=3 deleted=0 failed=1"* ]] + [[ "$output" == *"Grew.dmg (changed since scan)"* ]] +} + +@test "show_summary reports installer delete failures" { + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + source "$1" + + total_deleted=1 + total_size_freed_kb=1 + total_delete_failed=2 + INSTALLER_DELETE_FAILURES=("$HOME/Downloads/Blocked.dmg (protected path)" "$HOME/Downloads/Stale.pkg (still exists)") + + show_summary + ' bash "$PROJECT_ROOT/bin/installer.sh" + + [ "$status" -eq 0 ] + [[ "$output" == *"Installer cleanup incomplete"* ]] + [[ "$output" == *"Failed to remove"* ]] + [[ "$output" == *"Blocked.dmg"* ]] + [[ "$output" == *"protected path"* ]] + [[ "$output" == *"Stale.pkg"* ]] + [[ "$output" == *"still exists"* ]] + [[ "$output" != *"Your Mac is cleaner now!"* ]] +} + +@test "main exits nonzero after real incomplete installer cleanup" { + local removable="$HOME/Downloads/MainGood.dmg" + printf 'good' > "$removable" + + # shellcheck disable=SC2016 + run env HOME="$HOME" TERM="$TERM" bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_LOG="$HOME/deletions.log" + source "$1" + test_removable="$2" + + collect_installers() { + local system_size + system_size=$(get_file_size "/System") + INSTALLER_PATHS=("$test_removable" "/System") + INSTALLER_SIZES=(4 "$system_size") + DISPLAY_NAMES=("MainGood.dmg" "System") + return 0 + } + + show_installer_menu() { + MOLE_SELECTION_RESULT="0,1" + return 0 + } + + set +e + main < <(printf "\n") + rc=$? + set -e + printf "rc=%s removed=%s\n" "$rc" "$([[ ! -e "$test_removable" ]] && echo yes || echo no)" + ' bash "$PROJECT_ROOT/bin/installer.sh" "$removable" + + [ "$status" -eq 0 ] + [[ "$output" == *"Installer cleanup incomplete"* ]] + [[ "$output" == *"rc=1"* ]] + [[ "$output" == *"removed=yes"* ]] +} diff --git a/Resources/mole/tests/installer_fd.bats b/Resources/mole/tests/installer_fd.bats index 10d98ff..3639eb8 100644 --- a/Resources/mole/tests/installer_fd.bats +++ b/Resources/mole/tests/installer_fd.bats @@ -20,13 +20,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" export MO_DEBUG=0 diff --git a/Resources/mole/tests/installer_zip.bats b/Resources/mole/tests/installer_zip.bats index 743df15..3d50d99 100644 --- a/Resources/mole/tests/installer_zip.bats +++ b/Resources/mole/tests/installer_zip.bats @@ -30,13 +30,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="xterm-256color" export MO_DEBUG=0 diff --git a/Resources/mole/tests/manage_autofix.bats b/Resources/mole/tests/manage_autofix.bats deleted file mode 100644 index 219d6d2..0000000 --- a/Resources/mole/tests/manage_autofix.bats +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bats - -setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT -} - -@test "show_suggestions lists auto and manual items and exports flag" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" - -export FIREWALL_DISABLED=true -export FILEVAULT_DISABLED=true -export TOUCHID_NOT_CONFIGURED=true -export CACHE_SIZE_GB=9 -export BREW_HAS_WARNINGS=true -export DISK_FREE_GB=25 - -show_suggestions -echo "AUTO_FLAG=${HAS_AUTO_FIX_SUGGESTIONS}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Enable Firewall for better security"* ]] - [[ "$output" == *"Enable FileVault"* ]] - [[ "$output" == *"Enable Touch ID for sudo"* ]] - [[ "$output" == *"Low disk space (25GB free)"* ]] - [[ "$output" == *"AUTO_FLAG=true"* ]] -} - -@test "ask_for_auto_fix accepts Enter" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" -HAS_AUTO_FIX_SUGGESTIONS=true -read_key() { echo "ENTER"; return 0; } -ask_for_auto_fix -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"yes"* ]] -} - -@test "ask_for_auto_fix rejects other keys" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" -HAS_AUTO_FIX_SUGGESTIONS=true -read_key() { echo "ESC"; return 0; } -ask_for_auto_fix -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"no"* ]] -} - -@test "perform_auto_fix applies available actions and records summary" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/base.sh" -source "$PROJECT_ROOT/lib/manage/autofix.sh" - -has_sudo_session() { return 0; } -ensure_sudo_session() { return 0; } -sudo() { - case "$1" in - defaults) return 0 ;; - bash) return 0 ;; - /usr/libexec/ApplicationFirewall/socketfilterfw) return 0 ;; - *) return 0 ;; - esac -} - -export FIREWALL_DISABLED=true -export TOUCHID_NOT_CONFIGURED=true - -perform_auto_fix -echo "SUMMARY=${AUTO_FIX_SUMMARY}" -echo "DETAILS=${AUTO_FIX_DETAILS}" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Firewall enabled"* ]] - [[ "$output" == *"Touch ID configured"* ]] - [[ "$output" == *"SUMMARY=Auto fixes applied: 2 issues"* ]] - [[ "$output" == *"DETAILS"* ]] -} 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..5a778dd 100644 --- a/Resources/mole/tests/manage_whitelist.bats +++ b/Resources/mole/tests/manage_whitelist.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" mkdir -p "$HOME" WHITELIST_PATH="$HOME/.config/mole/whitelist" @@ -116,6 +123,41 @@ setup() { [ "$status" -eq 1 ] } +@test "mo clean --whitelist cancel preserves existing file (#807)" { + whitelist_file="$HOME/.config/mole/whitelist" + mkdir -p "$(dirname "$whitelist_file")" + + run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf \$'\\n' | HOME='$HOME' ./mo clean --whitelist" + [ "$status" -eq 0 ] + [[ -f "$whitelist_file" ]] + before_hash=$(shasum "$whitelist_file" | awk '{print $1}') + + run bash --noprofile --norc -c "cd '$PROJECT_ROOT'; printf 'q' | HOME='$HOME' ./mo clean --whitelist" + [ "$status" -eq 0 ] + [[ "$output" == *"Cancelled"* ]] + after_hash=$(shasum "$whitelist_file" | awk '{print $1}') + [ "$before_hash" = "$after_hash" ] +} + +@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 +172,112 @@ 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 + 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/no_color.bats b/Resources/mole/tests/no_color.bats new file mode 100644 index 0000000..b570437 --- /dev/null +++ b/Resources/mole/tests/no_color.bats @@ -0,0 +1,37 @@ +#!/usr/bin/env bats +# Verify NO_COLOR support per https://no-color.org. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +@test "NO_COLOR strips ANSI escapes from base color vars" { + run env NO_COLOR=1 PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +printf '%s' "<${GREEN}><${RED}><${YELLOW}><${BLUE}><${CYAN}><${PURPLE}><${PURPLE_BOLD}><${GRAY}><${NC}>" +EOF + [ "$status" -eq 0 ] + [ "$output" = "<><><><><><><><><>" ] +} + +@test "default keeps ANSI escapes in base color vars" { + run env -u NO_COLOR PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +printf '%s' "${GREEN}x${NC}" +EOF + [ "$status" -eq 0 ] + [ "$output" = $'\033[0;32mx\033[0m' ] +} + +@test "empty NO_COLOR keeps ANSI escapes per spec" { + run env NO_COLOR="" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/base.sh" +printf '%s' "${RED}y${NC}" +EOF + [ "$status" -eq 0 ] + [ "$output" = $'\033[0;31my\033[0m' ] +} diff --git a/Resources/mole/tests/optimize.bats b/Resources/mole/tests/optimize.bats index 00a7b11..32748c3 100644 --- a/Resources/mole/tests/optimize.bats +++ b/Resources/mole/tests/optimize.bats @@ -1,27 +1,29 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize.XXXXXX")" - export HOME + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-optimize.XXXXXX")" + export HOME - mkdir -p "$HOME" + mkdir -p "$HOME" } teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi } @test "needs_permissions_repair returns true when home not writable" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" USER="tester" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" USER="tester" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/optimize/tasks.sh" stat() { echo "root"; } @@ -31,34 +33,12 @@ if needs_permissions_repair; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"needs"* ]] -} - -@test "has_bluetooth_hid_connected detects HID" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/optimize/tasks.sh" -system_profiler() { - cat << 'OUT' -Bluetooth: - Apple Magic Mouse: - Connected: Yes - Type: Mouse -OUT -} -export -f system_profiler -if has_bluetooth_hid_connected; then - echo "hid" -fi -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"hid"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"needs"* ]] } @test "is_ac_power detects AC power" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/optimize/tasks.sh" pmset() { echo "AC Power"; } @@ -68,12 +48,12 @@ if is_ac_power; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"ac"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"ac"* ]] } @test "is_memory_pressure_high detects warning" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/optimize/tasks.sh" memory_pressure() { echo "warning"; } @@ -83,12 +63,12 @@ if is_memory_pressure_high; then fi EOF - [ "$status" -eq 0 ] - [[ "$output" == *"high"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"high"* ]] } @test "opt_system_maintenance reports DNS and Spotlight" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' + 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" @@ -97,13 +77,13 @@ mdutil() { echo "Indexing enabled."; } opt_system_maintenance EOF - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache flushed"* ]] - [[ "$output" == *"Spotlight index verified"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"DNS cache flushed"* ]] + [[ "$output" == *"Spotlight index verified"* ]] } @test "opt_network_optimization refreshes DNS" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' + 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" @@ -111,13 +91,191 @@ flush_dns_cache() { return 0; } opt_network_optimization EOF - [ "$status" -eq 0 ] - [[ "$output" == *"DNS cache refreshed"* ]] - [[ "$output" == *"mDNSResponder restarted"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"DNS cache refreshed"* ]] + [[ "$output" == *"mDNSResponder restarted"* ]] +} + +@test "fix_broken_preferences repairs only non-Apple preference plists" { + local test_home="$HOME/fixprefs-basic" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +CALL_LOG="$HOME/fix-broken-preferences.log" +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs/ByHost" +touch \ + "$prefs/com.example.broken.plist" \ + "$prefs/com.apple.broken.plist" \ + "$prefs/loginwindow.plist" \ + "$prefs/ByHost/com.example.byhost.plist" \ + "$prefs/ByHost/loginwindow.plist" + +plutil() { + echo "lint:$2" >> "$CALL_LOG" + return 1 +} +safe_remove() { + echo "remove:$1" >> "$CALL_LOG" +} + +count=$(fix_broken_preferences) +echo "count=$count" +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=3"* ]] + [[ "$output" == *"remove:$test_home/Library/Preferences/com.example.broken.plist"* ]] + [[ "$output" == *"remove:$test_home/Library/Preferences/ByHost/com.example.byhost.plist"* ]] + [[ "$output" == *"remove:$test_home/Library/Preferences/ByHost/loginwindow.plist"* ]] + [[ "$output" != *"lint:$test_home/Library/Preferences/com.apple.broken.plist"* ]] + [[ "$output" != *"lint:$test_home/Library/Preferences/loginwindow.plist"* ]] +} + +@test "fix_broken_preferences does not count safe_remove failures" { + local test_home="$HOME/fixprefs-remove-failure" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +prefs="$HOME/Library/Preferences" +mkdir -p "$prefs" +touch "$prefs/com.example.broken.plist" + +plutil() { return 1; } +safe_remove() { return 1; } + +count=$(fix_broken_preferences) +echo "count=$count" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] +} + +@test "fix_broken_preferences does not count protected Adobe plists" { + local test_home="$HOME/fixprefs-protected" + run env HOME="$test_home" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/maintenance.sh" + +prefs="$HOME/Library/Preferences" +plist="$prefs/com.adobe.Photoshop.uxp_com.adobe.ccx.start.plist" +mkdir -p "$prefs" +touch "$plist" + +plutil() { return 1; } + +count=$(fix_broken_preferences) +echo "count=$count" +[[ -f "$plist" ]] && echo "still-present" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"count=0"* ]] + [[ "$output" == *"still-present"* ]] +} + +@test "opt_cache_refresh reuses measured cache sizes for deletion" { + 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" + +CALL_LOG="$HOME/cache-refresh.log" +cache_dir="$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" +mkdir -p "$cache_dir" +touch "$cache_dir/test.db" + +get_path_size_kb() { + echo "size:$1" >> "$CALL_LOG" + echo "42" +} +should_protect_path() { + return 1 +} +safe_remove() { + echo "remove:$1:${3:-missing}" >> "$CALL_LOG" +} + +opt_cache_refresh +echo "cleaned=${OPTIMIZE_CACHE_CLEANED_KB:-missing}" +cat "$CALL_LOG" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"QuickLook thumbnails refreshed"* ]] + [[ "$output" == *"cleaned=42"* ]] + [[ "$output" == *"remove:$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache:42"* ]] + [ "$(grep -c "size:$HOME/Library/Caches/com.apple.QuickLook.thumbnailcache" <<< "$output")" -eq 1 ] +} + +@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' + 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" @@ -125,40 +283,86 @@ export PATH="/nonexistent" opt_sqlite_vacuum EOF - [ "$status" -eq 0 ] - [[ "$output" == *"sqlite3 unavailable"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"sqlite3 unavailable"* ]] +} + +@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 succeeds in dry-run" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +@test "opt_dock_refresh reports refresh" { + 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_font_cache_rebuild +opt_dock_refresh EOF - [ "$status" -eq 0 ] - [[ "$output" == *"Font cache cleared"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"Dock refreshed"* ]] } -@test "opt_dock_refresh clears cache files" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 bash --noprofile --norc <<'EOF' +@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" -mkdir -p "$HOME/Library/Application Support/Dock" -touch "$HOME/Library/Application Support/Dock/test.db" -safe_remove() { return 0; } -opt_dock_refresh +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" == *"Dock cache cleared"* ]] - [[ "$output" == *"Dock refreshed"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"optional"* ]] + [[ "$output" == *"described"* ]] } @test "execute_optimization dispatches actions" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" @@ -166,24 +370,142 @@ opt_dock_refresh() { echo "dock"; } execute_optimization dock_refresh EOF - [ "$status" -eq 0 ] - [[ "$output" == *"dock"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"dock"* ]] } @test "execute_optimization rejects unknown action" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" execute_optimization unknown_action EOF - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown action"* ]] + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown action"* ]] +} + +@test "opt_prune_spotlight_orphan_rules removes orphan but keeps system, apple and installed rules" { + 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" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string System.iphoneApps" \ + -c "Add :EnabledPreferenceRules:1 string com.apple.Safari" \ + -c "Add :EnabledPreferenceRules:2 string com.installed.App" \ + -c "Add :EnabledPreferenceRules:3 string com.lm.william.TwinklingCard" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "DEFAULTS: $*" ;; + esac +} +bundle_has_installed_app() { [[ "$1" == "com.installed.App" ]]; } +opt_prune_spotlight_orphan_rules +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Removed 1 orphan"* ]] + [[ "$output" == *"DEFAULTS: write"* ]] + [[ "$output" == *"System.iphoneApps"* ]] + [[ "$output" == *"com.apple.Safari"* ]] + [[ "$output" == *"com.installed.App"* ]] + [[ "$output" != *"com.lm.william.TwinklingCard"* ]] +} + +@test "opt_prune_spotlight_orphan_rules dry-run reports but does not write" { + 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" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string System.iphoneApps" \ + -c "Add :EnabledPreferenceRules:1 string com.lm.william.TwinklingCard" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "DEFAULTS: $*" ;; + esac +} +bundle_has_installed_app() { return 1; } +opt_prune_spotlight_orphan_rules +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Would remove 1 orphan"* ]] + [[ "$output" != *"DEFAULTS: write"* ]] + [[ "$output" != *"DEFAULTS: delete"* ]] +} + +@test "opt_prune_spotlight_orphan_rules reports clean when every rule still has its app" { + 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" +PLIST="$HOME/Library/Preferences/com.apple.spotlight.plist" +mkdir -p "$(dirname "$PLIST")" +rm -f "$PLIST" +/usr/libexec/PlistBuddy \ + -c "Add :EnabledPreferenceRules array" \ + -c "Add :EnabledPreferenceRules:0 string System.iphoneApps" \ + -c "Add :EnabledPreferenceRules:1 string com.apple.Safari" \ + -c "Add :EnabledPreferenceRules:2 string com.installed.App" \ + "$PLIST" >/dev/null 2>&1 +defaults() { + case "$1" in + read) return 0 ;; + write | delete) echo "DEFAULTS: $*" ;; + esac +} +bundle_has_installed_app() { return 0; } +opt_prune_spotlight_orphan_rules +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] + [[ "$output" != *"DEFAULTS: write"* ]] +} + +@test "opt_prune_spotlight_orphan_rules reports clean when rules key is absent" { + 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() { return 1; } +opt_prune_spotlight_orphan_rules +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already clean"* ]] +} + +@test "execute_optimization dispatches spotlight_orphan_rules_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_prune_spotlight_orphan_rules() { echo "pruned"; } +execute_optimization spotlight_orphan_rules_cleanup +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"pruned"* ]] } @test "opt_launch_services_rebuild handles missing lsregister without exiting" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/optimize/tasks.sh" @@ -195,7 +517,806 @@ opt_launch_services_rebuild echo "survived" EOF - [ "$status" -eq 0 ] - [[ "$output" == *"lsregister not found"* ]] - [[ "$output" == *"survived"* ]] + [ "$status" -eq 0 ] + [[ "$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 "opt_launch_agents_cleanup spares agents on unmounted volumes" { + 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 +# A program on an unplugged /Volumes/ is missing but not broken; +# the volume is simply unmounted, so the agent must be left alone. +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$HOME/Library/LaunchAgents/com.test.external.plist" <<'PLIST' + + + + + Label + com.test.external + ProgramArguments + + /Volumes/MoleNonexistentDisk/tool + + + +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" +periodic() { true; } +export -f periodic +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 ignores non-BSD stat earlier in PATH" { + 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" +periodic() { true; } +export -f periodic +tmpdir="$(mktemp -d /tmp/mole-test-stat-path.XXXXXX)" +mkdir -p "$tmpdir/bin" +cat > "$tmpdir/bin/stat" <<'STAT' +#!/usr/bin/env bash +echo " File: /var/log/daily.out" +STAT +chmod +x "$tmpdir/bin/stat" +tmplog="$tmpdir/daily.out" +touch "$tmplog" +PATH="$tmpdir/bin:$PATH" MOLE_PERIODIC_LOG="$tmplog" opt_periodic_maintenance +rm -rf "$tmpdir" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"already current"* ]] + [[ "$output" != *"unbound variable"* ]] +} + +@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" +periodic() { true; } +export -f periodic +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" +periodic() { true; } +export -f periodic +MOLE_PERIODIC_LOG="/tmp/mole-test-nonexistent-daily.out" opt_periodic_maintenance +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance triggered"* ]] +} + +@test "run_optimize_diagnostics flags sustained CloudShell as primary bottleneck" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'120 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-capture\n35 /usr/libexec/syspolicyd\n20 /System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'140 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-processor\n30 /usr/libexec/syspolicyd\n18 /System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: CloudShell / AliEntSafe"* ]] + [[ "$output" == *"Mole will not terminate enterprise security processes"* ]] +} + +@test "run_optimize_diagnostics treats CoreSimulator images as informational for syspolicyd" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'55 /usr/libexec/syspolicyd\n12 /usr/libexec/diskimagesiod' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'60 /usr/libexec/syspolicyd\n10 /Library/Developer/PrivateFrameworks/CoreSimulator.framework/Resources/bin/simdiskimaged' \ + MOLE_OPTIMIZE_SPCTL_STATUS="assessments enabled" \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime/example.asset/AssetData/Restore/000.dmg\n/dev/disk8s1\t/Library/Developer/CoreSimulator/Volumes/iOS_23E244\n' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Likely bottleneck: syspolicyd"* ]] + [[ "$output" == *"Gatekeeper status: assessments enabled"* ]] + [[ "$output" == *"Only system-managed CoreSimulator images are mounted"* ]] + [[ "$output" != *"Mounted image detach candidates"* ]] +} + +@test "run_optimize_diagnostics suppresses one-off CPU spikes" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'180 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-capture' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'5 /Applications/AliEntSafe.app/Contents/Services/CloudShell.app/Contents/MacOS/CloudShell --type=event-capture' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No obvious sustained high-CPU bottleneck detected"* ]] +} + +@test "run_optimize_diagnostics lists user-mounted image detach candidates in dry-run" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'1 /usr/sbin/distnoted' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'1 /usr/sbin/distnoted' \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Users/test/Downloads/TestInstaller.dmg\n/dev/disk14s1\t/Volumes/Test Installer\n' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Mounted image detach candidates:"* ]] + [[ "$output" == *"/Volumes/Test Installer"* ]] + [[ "$output" == *"Would offer detach for 1 mounted image"* ]] +} + +@test "run_optimize_diagnostics skips protected mounted images" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Users/test/Downloads/KeepMe.dmg\n/dev/disk15s1\t/Volumes/KeepMe\n' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { + [[ "$1" == "/Volumes/KeepMe" ]] +} +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" != *"Mounted image detach candidates:"* ]] +} + +@test "run_optimize_diagnostics honors optimize whitelist paths for mounted images (#977)" { + mkdir -p "$HOME/.config/mole" + cat > "$HOME/.config/mole/whitelist_optimize" <<'EOF' +system_maintenance +/Volumes/EXT3/Mail/TB.dmg +/Volumes/mail +EOF + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_DRY_RUN=1 \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'1 /usr/sbin/distnoted' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'1 /usr/sbin/distnoted' \ + MOLE_OPTIMIZE_HDIUTIL_INFO=$'================================================\nimage-path : /Volumes/EXT3/Mail/TB.dmg\n/dev/disk6s2 Apple_HFS /Volumes/mail\n' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +load_whitelist optimize +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No obvious sustained high-CPU bottleneck detected"* ]] + [[ "$output" != *"Mounted image detach candidates:"* ]] + [[ "$output" != *"Would offer detach"* ]] +} + +@test "run_optimize_diagnostics stays quiet when nothing matches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_PS_SAMPLE_1=$'4 /usr/sbin/distnoted\n3 /usr/libexec/coreaudiod' \ + MOLE_OPTIMIZE_PS_SAMPLE_2=$'5 /usr/sbin/distnoted\n2 /usr/libexec/coreaudiod' \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/diagnostics.sh" +is_path_whitelisted() { return 1; } +run_optimize_diagnostics +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"No obvious sustained high-CPU bottleneck detected"* ]] +} + +@test "opt_periodic_maintenance skips when periodic command 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/optimize/tasks.sh" +command() { + if [[ "$1" == "-v" && "$2" == "periodic" ]]; then + return 1 + fi + builtin command "$@" +} +export -f command +opt_periodic_maintenance +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Periodic maintenance skipped (not available on this macOS version)"* ]] +} + +@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 "execute_optimization skips whitelisted task ids" { + 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" +is_whitelisted() { [[ "$1" == "dock_refresh" ]]; } +opt_dock_refresh() { echo "UNEXPECTED_DOCK"; } +execute_optimization dock_refresh +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Skipped (whitelisted): dock_refresh"* ]] + [[ "$output" != *"UNEXPECTED_DOCK"* ]] +} + +@test "optimize whitelist is loaded before system health checks" { + run env PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +load_line=$(awk '/load_whitelist "optimize"/ { print NR; exit }' "$PROJECT_ROOT/bin/optimize.sh") +health_line=$(awk '/^[[:space:]]*show_system_health / { print NR; exit }' "$PROJECT_ROOT/bin/optimize.sh") +if [[ "$load_line" -lt "$health_line" ]]; then + echo "ordered" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ordered"* ]] +} + +@test "optimize whitelist items include task ids" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/manage/whitelist.sh" +get_optimize_whitelist_items +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Permission Repair|disk_permissions_repair|optimize_task"* ]] + [[ "$output" == *"Login Items Audit|login_items_audit|optimize_task"* ]] +} + +@test "_login_item_app_exists finds nested helper app bundles" { + local helper="$HOME/Applications/Roon.app/Contents/RoonServer.app" + mkdir -p "$helper" + + 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" +mdfind() { return 1; } +sfltool() { return 1; } +export -f mdfind sfltool +if _login_item_app_exists "RoonServer"; then + echo "found" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"found"* ]] +} + +@test "_login_item_app_exists finds nested helper apps by bundle display name" { + local helper="$HOME/Applications/Adobe Acrobat DC.app/Contents/Helpers/AdobeResourceSynchronizer.app" + mkdir -p "$helper/Contents" + cat > "$helper/Contents/Info.plist" <<'PLIST' + + + + + CFBundleDisplayName + Acrobat Collaboration Synchronizer + CFBundleName + AdobeResourceSynchronizer + + +PLIST + + 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" +mdfind() { return 1; } +sfltool() { return 1; } +export -f mdfind sfltool +if _login_item_app_exists "Acrobat Collaboration Synchronizer"; then + echo "found" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"found"* ]] +} + +@test "_login_item_app_exists trusts an existing System Events login item path" { + local helper="$HOME/Applications/Adobe Acrobat DC.app/Contents/Helpers/AdobeResourceSynchronizer.app" + mkdir -p "$helper" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MO_DEBUG=1 HELPER_PATH="$helper" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +mdfind() { return 1; } +sfltool() { return 1; } +export -f mdfind sfltool +if _login_item_app_exists "Acrobat Collaboration Synchronizer" "$HELPER_PATH" 2>&1; then + echo "found" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"found"* ]] + [[ "$output" == *"resolved by login item path"* ]] +} + +@test "optimize_sudo_available returns false when sudo session was denied" { + run env PROJECT_ROOT="$PROJECT_ROOT" MOLE_OPTIMIZE_SUDO_AVAILABLE="false" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +if optimize_sudo_available; then + echo "WRONG: returned true under denied sudo" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "optimize_sudo_available returns false in test mode regardless of optimize entrypoint" { + # Ad-hoc task invocation under MOLE_TEST_NO_AUTH must hard-deny sudo + # even when MOLE_OPTIMIZE_SUDO_AVAILABLE was never set by bin/optimize.sh. + run env PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +unset MOLE_OPTIMIZE_SUDO_AVAILABLE +if optimize_sudo_available; then + echo "WRONG: leaked sudo to test-mode caller" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "flush_dns_cache does not invoke sudo under MOLE_TEST_NO_AUTH" { + # Reproduces the reported regression: ad-hoc flush_dns_cache under test + # mode used to fall through optimize_sudo_available and reach `sudo dscacheutil`. + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_NO_AUTH=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +unset MOLE_OPTIMIZE_SUDO_AVAILABLE +trace="$HOME/sudo_calls.log" +: > "$trace" +sudo() { + printf 'SUDO_CALLED:%s\n' "$*" >> "$trace" + return 0 +} +export -f sudo + +flush_dns_cache 2>&1 || true + +if [[ -s "$trace" ]]; then + echo "WRONG: sudo invoked under test mode:" + cat "$trace" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "sudo-required optimize tasks short-circuit without invoking sudo when access denied" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_OPTIMIZE_SUDO_AVAILABLE="false" \ + MOLE_DRY_RUN="0" \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/optimize/tasks.sh" + +trace="$HOME/sudo_calls.log" +: > "$trace" +sudo() { + printf 'sudo %s\n' "$*" >> "$trace" + return 0 +} +export -f sudo + +# Force the "needs work" branch so each task reaches its sudo block. +is_memory_pressure_high() { return 0; } +needs_permissions_repair() { return 0; } +has_active_vpn_interface() { return 1; } +route() { return 1; } +dscacheutil() { return 1; } +mdutil() { echo "Indexing enabled."; } +mdfind() { sleep 4; } +get_epoch_seconds() { date +%s; } +is_ac_power() { return 0; } +pgrep() { return 1; } +system_profiler() { return 1; } +plutil() { return 1; } +defaults() { return 1; } +get_path_size_kb() { echo "0"; } +debug_log() { :; } +opt_msg() { :; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } + +opt_memory_pressure_relief 2>&1 || true +opt_network_stack_optimize 2>&1 || true +opt_disk_permissions_repair 2>&1 || true +opt_periodic_maintenance 2>&1 || true +flush_dns_cache 2>&1 || true + +if [[ -s "$trace" ]]; then + echo "WRONG: sudo invoked while denied:" + cat "$trace" + exit 1 +fi +echo "ok" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "opt_diag_parse_image_mount_pairs ignores image-alias/icon-path lines (#960)" { + 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/diagnostics.sh" + +# Sample hdiutil info block reproducing the issue from #960. The image-alias +# line carries an absolute path identical to image-path, which the previous +# extract_mount regex incorrectly accepted as a mount point. Only the +# /dev/disk* line is a real mount. +sample=$(cat <<'HDIUTIL' +================================================ +image-path : /Volumes/EXT3/Mail/TB.dmg +image-alias : /Volumes/EXT3/Mail/TB.dmg +shadow-path : +icon-path : /System/Library/PrivateFrameworks/DiskImages.framework/Resources/CDiskImage.icns +image-type : read-only +/dev/disk6 Apple_partition_scheme +/dev/disk6s1 Apple_partition_map +/dev/disk6s2 Apple_HFS /Volumes/mail +HDIUTIL +) + +opt_diag_parse_image_mount_pairs "$sample" +EOF + + [ "$status" -eq 0 ] + # Expect exactly one pair: image=/Volumes/EXT3/Mail/TB.dmg mount=/Volumes/mail + line_count=$(printf '%s\n' "$output" | awk 'NF' | wc -l | tr -d ' ') + [ "$line_count" = "1" ] + [[ "$output" == *"/Volumes/EXT3/Mail/TB.dmg"$'\t'"/Volumes/mail"* ]] + # Critical regression guard: image-alias line must not surface as a mount. + [[ "$output" != *"/Volumes/EXT3/Mail/TB.dmg"$'\t'"/Volumes/EXT3/Mail/TB.dmg"* ]] +} + +@test "has_active_vpn_interface respects MOLE_ASSUME_VPN_ACTIVE override" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Force scutil/route to fail loudly so the env override is the only path. +scutil() { echo "should not be called" >&2; return 1; } +route() { echo "should not be called" >&2; return 1; } +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"vpn"* ]] + [[ "$output" != *"no_vpn"* ]] + [[ "$output" != *"should not be called"* ]] +} + +@test "has_active_vpn_interface returns false when MOLE_ASSUME_VPN_ACTIVE=0" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_ASSUME_VPN_ACTIVE=0 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# scutil/route should not run when env says no. +scutil() { echo "should not be called" >&2; return 1; } +route() { echo "should not be called" >&2; return 1; } +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] + [[ "$output" != *"should not be called"* ]] +} + +@test "has_active_vpn_interface detects scutil Connected entry" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +scutil() { + cat <<'NC' +* (Disconnected) AA1B2C3D-1111-2222-3333-444455556666 PPP (L2TP) "Office VPN" [L2TP] +* (Connected) 87654321-aaaa-bbbb-cccc-dddddddddddd IPSec (IKEv2) "Remote Office"[IKEv2] +NC +} +export -f scutil +# Default route should NOT be consulted once scutil already proved a VPN active. +route() { echo "should not be called" >&2; return 1; } +export -f route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"vpn"* ]] + [[ "$output" != *"should not be called"* ]] +} + +@test "has_active_vpn_interface ignores scutil entries that are all Disconnected" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +scutil() { + cat <<'NC' +* (Disconnected) AA1B2C3D-1111-2222-3333-444455556666 PPP (L2TP) "Office VPN" [L2TP] +* (Disconnected) 87654321-aaaa-bbbb-cccc-dddddddddddd IPSec (IKEv2) "Remote Office"[IKEv2] +NC +} +# Default route via en0 (no VPN). This is the user's case in #959. +route() { + cat <<'ROUTE' + route to: default +destination: default + mask: default + gateway: 192.168.1.1 + interface: en0 +ROUTE +} +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] +} + +@test "has_active_vpn_interface detects full-tunnel via utun default route" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# No system-managed VPN configured in scutil. +scutil() { echo ""; } +# Default route owned by utun3 -> full-tunnel VPN (WireGuard / OpenVPN style). +route() { + cat <<'ROUTE' + route to: default +destination: default + mask: default + gateway: 10.8.0.1 + interface: utun3 +ROUTE +} +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"vpn"* ]] +} + +@test "has_active_vpn_interface returns false for iCloud Private Relay style utun (#959)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/optimize/tasks.sh" +# Private Relay / Continuity create utun* but the default route stays on en0. +# The old netstat/ifconfig probe would have false-positived this; the new +# probe must not. +scutil() { echo ""; } +route() { + cat <<'ROUTE' + route to: default +destination: default + mask: default + gateway: 192.168.1.1 + interface: en0 +ROUTE +} +export -f scutil route +if has_active_vpn_interface; then echo "vpn"; else echo "no_vpn"; fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"no_vpn"* ]] +} + +@test "opt_dock_refresh preserves desktoppicture.db and other db files (#995)" { + local dock_support="$HOME/Library/Application Support/Dock" + mkdir -p "$dock_support" + : > "$dock_support/desktoppicture.db" + : > "$dock_support/another.db" + + 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" +killall() { return 0; } +export -f killall +opt_dock_refresh +EOF + + [ "$status" -eq 0 ] + [ -f "$HOME/Library/Application Support/Dock/desktoppicture.db" ] + [ -f "$HOME/Library/Application Support/Dock/another.db" ] +} + +@test "opt_diag_parse_image_mount_pairs handles multiple blocks" { + 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/diagnostics.sh" + +sample=$(cat <<'HDIUTIL' +================================================ +image-path : /Users/test/Sample.dmg +image-alias : /Users/test/Sample.dmg +/dev/disk5s2 Apple_HFS /Volumes/Sample +================================================ +image-path : /Library/Developer/CoreSimulator/Volumes/iOS_17.dmg +image-alias : /Library/Developer/CoreSimulator/Volumes/iOS_17.dmg +/dev/disk7s1 Apple_APFS /Library/Developer/CoreSimulator/Volumes/iOS_17.0 +HDIUTIL +) + +opt_diag_parse_image_mount_pairs "$sample" | awk 'NF' | sort +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"/Users/test/Sample.dmg"$'\t'"/Volumes/Sample"* ]] + [[ "$output" == *"/Library/Developer/CoreSimulator/Volumes/iOS_17.dmg"$'\t'"/Library/Developer/CoreSimulator/Volumes/iOS_17.0"* ]] + line_count=$(printf '%s\n' "$output" | awk 'NF' | wc -l | tr -d ' ') + [ "$line_count" = "2" ] } diff --git a/Resources/mole/tests/optimize_db.bats b/Resources/mole/tests/optimize_db.bats new file mode 100644 index 0000000..40b5545 --- /dev/null +++ b/Resources/mole/tests/optimize_db.bats @@ -0,0 +1,119 @@ +#!/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-optimize-db.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +create_logical_file() { + local path="$1" + local size="$2" + + if command -v mkfile > /dev/null 2>&1; then + mkfile -n "$size" "$path" + else + truncate -s "$size" "$path" + fi +} + +@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_logical_file "$nc_db_dir/db" 1k + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc < "$fake_bin/du" <<'EOF' +#!/bin/bash +echo "112640 total" +EOF + chmod +x "$fake_bin/du" + + run env HOME="$tmp_dir" PROJECT_ROOT="$PROJECT_ROOT" PATH="$fake_bin:$PATH" bash --noprofile --norc <&2 + return 1 + fi + # shellcheck source=lib/core/common.sh + source "$PROJECT_ROOT/lib/core/common.sh" +} + +@test "corpus file exists and is non-empty" { + [ -f "$CORPUS" ] + [ -s "$CORPUS" ] +} + +@test "every dangerous path is rejected by validate_path_for_deletion" { + [ -f "$CORPUS" ] + + local rejected=0 + local accepted=0 + local -a accepted_paths=() + local line + + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and blank lines + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "$line" ]] && continue + + run bash --noprofile --norc -s -- "$line" <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +validate_path_for_deletion "$1" +EOF + if [[ "$status" -eq 0 ]]; then + accepted=$((accepted + 1)) + accepted_paths+=("$line") + else + rejected=$((rejected + 1)) + fi + done < "$CORPUS" + + if [[ $accepted -gt 0 ]]; then + printf 'FAIL: %d dangerous paths were accepted:\n' "$accepted" >&2 + printf ' %s\n' "${accepted_paths[@]}" >&2 + fi + [ "$accepted" -eq 0 ] + [ "$rejected" -ge 50 ] +} + +@test "generated control-character paths are rejected" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +validate_path_for_deletion $'/Users/me/with\nnewline' +EOF + [ "$status" -eq 1 ] + + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +validate_path_for_deletion $'/Users/me/with\tab' +EOF + [ "$status" -eq 1 ] +} + +@test "corpus has minimum coverage" { + local active + active=$(grep -cvE '^\s*(#|$)' "$CORPUS") + # Lower bound prevents accidental corpus deletion from passing CI. + [ "$active" -ge 50 ] +} diff --git a/Resources/mole/tests/purge.bats b/Resources/mole/tests/purge.bats index 9895373..29a2049 100644 --- a/Resources/mole/tests/purge.bats +++ b/Resources/mole/tests/purge.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi mkdir -p "$HOME/www" mkdir -p "$HOME/dev" mkdir -p "$HOME/.cache/mole" @@ -109,6 +116,39 @@ setup() { [[ "$result" == "ALLOWED" ]] } +@test "compact_purge_scan_path keeps the tail of long purge paths visible" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_SKIP_MAIN=1 bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/purge.sh" +compact_purge_scan_path "$HOME/projects/team/service/very/deep/component/node_modules" 32 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == ".../deep/component/node_modules" ]] +} + +@test "compact_purge_menu_path keeps the project tail visible" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +compact_purge_menu_path "$HOME/projects/team/service/very/deep/component/node_modules" 32 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == ".../deep/component/node_modules" ]] +} + +@test "format_purge_target_path rewrites home with tilde" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +format_purge_target_path "$HOME/www/app/node_modules" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == \~/www/app/node_modules ]] +} + @test "filter_nested_artifacts: removes nested node_modules" { mkdir -p "$HOME/www/project/node_modules/package/node_modules" @@ -347,14 +387,28 @@ EOF } @test "confirm_purge_cleanup accepts Enter" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/clean/project.sh" drain_pending_input() { :; } confirm_purge_cleanup 2 1024 0 <<< '' EOF - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] +} + +@test "confirm_purge_cleanup shows selected paths" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/clean/project.sh" +drain_pending_input() { :; } +confirm_purge_cleanup 2 1024 0 "~/www/app/node_modules" "~/www/app/dist" <<< '' +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Selected paths:"* ]] + [[ "$output" == *"~/www/app/node_modules"* ]] + [[ "$output" == *"~/www/app/dist"* ]] } @test "confirm_purge_cleanup cancels on ESC" { @@ -571,6 +625,100 @@ EOF [[ "$result" == "FOUND" ]] } +@test "scan_purge_targets: includes valid CACHEDIR.TAG directories in find mode" { + mkdir -p "$HOME/www/python-app/.custom-cache" + touch "$HOME/www/python-app/pyproject.toml" + printf 'Signature: 8a477f597d28d172789f06886806bc55\n' > "$HOME/www/python-app/.custom-cache/CACHEDIR.TAG" + + scan_output=$(mktemp) + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/www' '$scan_output' + if grep -q '$HOME/www/python-app/.custom-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'NOT_FOUND' + fi + ") + rm -f "$scan_output" + + [[ "$result" == "FOUND" ]] +} + +@test "scan_purge_targets: ignores invalid CACHEDIR.TAG signatures" { + mkdir -p "$HOME/www/python-app/.custom-cache" + touch "$HOME/www/python-app/pyproject.toml" + printf 'Signature: invalid\n' > "$HOME/www/python-app/.custom-cache/CACHEDIR.TAG" + + scan_output=$(mktemp) + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/www' '$scan_output' + if grep -q '$HOME/www/python-app/.custom-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'NOT_FOUND' + fi + ") + rm -f "$scan_output" + + [[ "$result" == "NOT_FOUND" ]] +} + +@test "scan_purge_targets: keeps CACHEDIR.TAG under Library out of purge scans" { + mkdir -p "$HOME/www/python-app/Library/fontconfig-cache" + touch "$HOME/www/python-app/pyproject.toml" + printf 'Signature: 8a477f597d28d172789f06886806bc55\n' > "$HOME/www/python-app/Library/fontconfig-cache/CACHEDIR.TAG" + + scan_output=$(mktemp) + result=$(bash -c " + source '$PROJECT_ROOT/lib/clean/project.sh' + MO_USE_FIND=1 scan_purge_targets '$HOME/www' '$scan_output' + if grep -q '$HOME/www/python-app/Library/fontconfig-cache' '$scan_output'; then + echo 'FOUND' + else + echo 'NOT_FOUND' + fi + ") + rm -f "$scan_output" + + [[ "$result" == "NOT_FOUND" ]] +} + +@test "scan_purge_targets: trusts empty fd result without falling back to find" { + mkdir -p "$HOME/.config/mole" "$HOME/www/empty-project" + printf '%s\n' "$HOME/www" > "$HOME/.config/mole/purge_paths" + + local mock_bin="$HOME/mock-bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/fd" <<'EOF' +#!/bin/bash +exit 0 +EOF + chmod +x "$mock_bin/fd" + cat > "$mock_bin/find" <<'EOF' +#!/bin/bash +echo find-called >> "$HOME/find-called" +exit 0 +EOF + chmod +x "$mock_bin/find" + + local scan_output + scan_output="$(mktemp)" + + run env HOME="$HOME" PATH="$mock_bin:$PATH" bash --noprofile --norc < "$HOME/.cache/mole/purge_stats" + +mkdir -p "$HOME/www/test-project/node_modules" +touch "$HOME/www/test-project/package.json" +touch -t 202001010101 "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" + +PURGE_SEARCH_PATHS=("$HOME/www") +get_dir_size_kb() { echo 0; } + +export MOLE_PURGE_INCLUDE_EMPTY=1 +export MOLE_DRY_RUN=1 +clean_project_artifacts /dev/null || echo missing)" +echo "SIZE=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo missing)" +[[ -d "$HOME/www/test-project/node_modules" ]] +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=1"* ]] + [[ "$output" == *"SIZE=0"* ]] +} + +@test "clean_project_artifacts: skips size calculation errors instead of showing 0B (#869)" { + 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/project.sh" + +mkdir -p "$HOME/www/test-project/node_modules" +touch "$HOME/www/test-project/package.json" +touch -t 202001010101 "$HOME/www/test-project/node_modules" "$HOME/www/test-project/package.json" "$HOME/www/test-project" + +PURGE_SEARCH_PATHS=("$HOME/www") +get_dir_size_kb() { echo ERROR; } + +clean_project_artifacts /dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then skip "gtimeout/timeout not available" @@ -778,7 +998,7 @@ EOF run bash -c " export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true " true } @@ -793,12 +1013,29 @@ EOF run bash -c " export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true " [[ "$output" == *"DRY RUN MODE"* ]] || [[ "$output" == *"Dry run complete"* ]] } +@test "mo purge: accepts --include-empty flag" { + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi + + timeout_cmd="timeout" + command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout" + + run bash -c " + export HOME='$HOME' + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge --include-empty --dry-run < /dev/null 2>&1 + " + + [ "$status" -eq 0 ] || [ "$status" -eq 2 ] + [[ "$output" != *"Unknown option"* ]] +} + @test "mo purge: creates cache directory for stats" { if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then skip "gtimeout/timeout not available" @@ -809,7 +1046,7 @@ EOF bash -c " export HOME='$HOME' - $timeout_cmd 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true + $timeout_cmd 10 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true " [ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ] @@ -907,3 +1144,143 @@ EOF rm -f "$scan_output" [[ "$result" == "SKIPPED" ]] } + +# --------------------------------------------------------------------------- +# Regression tests: sort-order consistency in clean_project_artifacts +# +# Bug: after sorting artifacts by size (descending), item_display_paths was +# not included in the reorder, so PURGE_CATEGORY_FULL_PATHS_ARRAY ended up +# in the original discovery order (alphabetical) while every other parallel +# array (menu_options, item_paths, item_sizes, …) was in size order. +# Effect: the "Full path" footer showed the wrong project for the highlighted +# item, and the confirmation dialog listed paths that did not match the +# selection. See https://github.com/tw93/Mole/issues/647 +# +# These tests run clean_project_artifacts under a pseudo-terminal (so the +# interactive code path is taken and select_purge_categories is called). +# The function is overridden to capture PURGE_CATEGORY_FULL_PATHS_ARRAY and +# PURGE_CATEGORY_SIZES without performing any actual deletion. +# --------------------------------------------------------------------------- + +# Run a bash script file under a pseudo-terminal so that [[ -t 0 ]] is true +# inside the script. Required to exercise the interactive branch of +# clean_project_artifacts, which only calls select_purge_categories when +# stdin is a tty. +_run_in_pty() { + local script_file="$1" + script -q /dev/null bash --noprofile --norc "$script_file" 2>/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..6190736 100644 --- a/Resources/mole/tests/purge_config_paths.bats +++ b/Resources/mole/tests/purge_config_paths.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi rm -rf "$HOME/.config" mkdir -p "$HOME/.config/mole" } @@ -108,8 +115,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..540ffd3 100644 --- a/Resources/mole/tests/regression.bats +++ b/Resources/mole/tests/regression.bats @@ -106,11 +106,11 @@ setup() { cat > "$fake_cmd" <<'EOF' #!/bin/bash trap "" TERM -sleep 30 +sleep 5 EOF chmod +x "$fake_cmd" - run /usr/bin/perl -e 'alarm 8; exec @ARGV' env FAKE_CMD="$fake_cmd" bash --noprofile --norc <<'EOF' + run /usr/bin/perl -e 'alarm 5; exec @ARGV' env FAKE_CMD="$fake_cmd" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/timeout.sh" MO_TIMEOUT_BIN="" @@ -187,3 +187,133 @@ EOF ") [[ "$result" == "loaded" ]] } + +@test "normalize_paths_for_cleanup handles large nested batches without hanging" { + local limit_ms="${MOLE_PERF_NORMALIZE_PATHS_LIMIT_MS:-10000}" + + run env PROJECT_ROOT="$PROJECT_ROOT" LIMIT_MS="$limit_ms" bash --noprofile --norc <<'EOF' +set -euo pipefail + +PYTHON_BIN="${PYTHON_BIN:-}" +if [[ -z "$PYTHON_BIN" ]]; then + PYTHON_BIN=$(command -v python3 || command -v python || true) +fi +[[ -n "$PYTHON_BIN" ]] || { echo "python unavailable"; exit 127; } + +"$PYTHON_BIN" - <<'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_BIN" - <<'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_BIN" - <<'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 + + if [ "$status" -ne 0 ]; then + printf 'normalize_paths_for_cleanup status=%s\n' "$status" >&3 + printf '%s\n' "$output" >&3 + fi + [ "$status" -eq 0 ] + [[ "$output" == *"COUNT=2"* ]] +} + +@test "normalize_paths_for_cleanup removes whole Gradle DSL hash dirs" { + run env PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail + +PYTHON_BIN="${PYTHON_BIN:-}" +if [[ -z "$PYTHON_BIN" ]]; then + PYTHON_BIN=$(command -v python3 || command -v python || true) +fi +[[ -n "$PYTHON_BIN" ]] || { echo "python unavailable"; exit 127; } + +"$PYTHON_BIN" - <<'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_gradle.sh").write_text(text[start:end] + "\n") +PY + +source /tmp/normalize_paths_for_cleanup_gradle.sh + +hash_dir="$HOME/.gradle/caches/8.13/groovy-dsl/abc123" +paths=( + "$hash_dir/metadata.bin" + "$hash_dir/classes/cp.bin" + "$HOME/.gradle/caches/8.13/kotlin-dsl/def456/metadata.bin" +) + +normalized=() +while IFS= read -r -d '' line; do + normalized+=("$line") +done < <(normalize_paths_for_cleanup "${paths[@]}") + +printf '%s\n' "${normalized[@]}" + +[[ ${#normalized[@]} -eq 2 ]] +[[ "${normalized[0]}" == "$HOME/.gradle/caches/8.13/groovy-dsl/abc123" || "${normalized[1]}" == "$HOME/.gradle/caches/8.13/groovy-dsl/abc123" ]] +[[ "${normalized[0]}" == "$HOME/.gradle/caches/8.13/kotlin-dsl/def456" || "${normalized[1]}" == "$HOME/.gradle/caches/8.13/kotlin-dsl/def456" ]] +EOF + + [ "$status" -eq 0 ] +} diff --git a/Resources/mole/tests/scripts.bats b/Resources/mole/tests/scripts.bats index 8dc0edc..fe0a8cf 100644 --- a/Resources/mole/tests/scripts.bats +++ b/Resources/mole/tests/scripts.bats @@ -14,13 +14,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="dumb" rm -rf "${HOME:?}"/* mkdir -p "$HOME" @@ -60,6 +67,14 @@ setup() { [ "$status" -eq 0 ] } +@test "release builds disable cgo and check minimum macOS version" { + run bash -c "grep -q '^RELEASE_GO_ENV := CGO_ENABLED=0$' '$PROJECT_ROOT/Makefile'" + [ "$status" -eq 0 ] + run bash -c "grep -q 'scripts/check_release_minos.sh' '$PROJECT_ROOT/.github/workflows/release.yml'" + [ "$status" -eq 0 ] + [ -x "$PROJECT_ROOT/scripts/check_release_minos.sh" ] +} + @test "setup-quick-launchers.sh has detect_mo function" { run bash -c "grep -q 'detect_mo()' '$PROJECT_ROOT/scripts/setup-quick-launchers.sh'" [ "$status" -eq 0 ] @@ -131,3 +146,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_diagnostic_reports_standalone.sh b/Resources/mole/tests/test_diagnostic_reports_standalone.sh deleted file mode 100644 index 8137a30..0000000 --- a/Resources/mole/tests/test_diagnostic_reports_standalone.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash -# Standalone test for get_diagnostic_report_paths_for_app (Issue #441). Run: bash tests/test_diagnostic_reports_standalone.sh - -set +e -set +u - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -if [[ ! -f "$PROJECT_ROOT/lib/core/app_protection.sh" ]]; then - PROJECT_ROOT="$(pwd)" - SCRIPT_DIR="$PROJECT_ROOT/tests" -fi -cd "$PROJECT_ROOT" || exit 1 - -source_crlf_safe() { - local f="$1" - if [[ -f "$f" ]]; then - # shellcheck source=/dev/null - source /dev/stdin <<< "$(sed 's/\r$//' < "$f")" - fi -} - -source_crlf_safe "$PROJECT_ROOT/lib/core/base.sh" -source_crlf_safe "$PROJECT_ROOT/lib/core/app_protection.sh" -set +e -set +u - -FAILED=0 -PASSED=0 - -assert_contains() { - local haystack="$1" - local needle="$2" - local name="${3:-assert}" - if [[ "$haystack" == *"$needle"* ]]; then - echo " OK $name" - ((PASSED++)) - return 0 - fi - echo " FAIL $name (expected to find: $needle)" - ((FAILED++)) - return 1 -} - -assert_empty() { - local val="$1" - local name="${2:-assert}" - if [[ -z "$val" ]]; then - echo " OK $name (empty as expected)" - ((PASSED++)) - return 0 - fi - echo " FAIL $name (expected empty, got: $val)" - ((FAILED++)) - return 1 -} - -echo "Testing get_diagnostic_report_paths_for_app (DiagnosticReports uninstall)" -echo "" - -out=$(get_diagnostic_report_paths_for_app "/Applications/Foo.app" "Foo" "/nonexistent/dir" 2> /dev/null || true) -assert_empty "$out" "missing directory returns empty" - -TMP_EMPTY=$(mktemp -d 2> /dev/null || mktemp -d -t mole-test 2> /dev/null || echo "") -[[ -z "$TMP_EMPTY" ]] && TMP_EMPTY="/tmp/mole-test-$$" && mkdir -p "$TMP_EMPTY" -out=$(get_diagnostic_report_paths_for_app "" "Ab" "$TMP_EMPTY" 2> /dev/null || true) -assert_empty "$out" "empty app_path returns empty" -rm -rf "$TMP_EMPTY" 2> /dev/null || true - -TMP_DIAG=$(mktemp -d 2> /dev/null || mktemp -d -t mole-diag 2> /dev/null || echo "/tmp/mole-diag-$$") -TMP_APP=$(mktemp -d 2> /dev/null || mktemp -d -t mole-app 2> /dev/null || echo "/tmp/mole-app-$$") -mkdir -p "$TMP_DIAG" "$TMP_APP" -mkdir -p "$TMP_APP/Contents" -printf '%s' 'CFBundleExecutableMyApp' > "$TMP_APP/Contents/Info.plist" - -touch "$TMP_DIAG/MyApp_2025-02-10-120000_host.ips" -touch "$TMP_DIAG/MyApp.crash" -touch "$TMP_DIAG/MyApp_2025-02-10-120001_host.spin" -touch "$TMP_DIAG/OtherApp_2025-02-10.ips" -touch "$TMP_DIAG/MyAppPro_2025-02-10-120002_host.ips" -touch "$TMP_DIAG/MyAppPro.crash" -touch "$TMP_DIAG/MyApp_log.txt" - -out=$(get_diagnostic_report_paths_for_app "$TMP_APP" "My App" "$TMP_DIAG" 2> /dev/null || true) - -assert_contains "$out" "MyApp_2025-02-10-120000" "returns .ips file" -assert_contains "$out" "MyApp.crash" "returns .crash file" -assert_contains "$out" "MyApp_2025-02-10-120001" "returns .spin file" -assert_contains "$out" ".ips" "output contains .ips path" -if [[ "$out" == *"OtherApp"* ]]; then - echo " FAIL should not return OtherApp" - ((FAILED++)) -else - echo " OK does not return OtherApp" - ((PASSED++)) -fi -if [[ "$out" == *"MyAppPro"* ]]; then - echo " FAIL should not return MyAppPro (prefix collision)" - ((FAILED++)) -else - echo " OK does not return MyAppPro" - ((PASSED++)) -fi -if [[ "$out" == *"MyApp_log.txt"* ]]; then - echo " FAIL should not return non-diagnostic extension" - ((FAILED++)) -else - echo " OK does not return .txt file" - ((PASSED++)) -fi - -rm -rf "$TMP_DIAG" "$TMP_APP" 2> /dev/null || true - -TMP_DIAG2=$(mktemp -d 2> /dev/null || mktemp -d -t mole-diag2 2> /dev/null || echo "/tmp/mole-diag2-$$") -TMP_APP2=$(mktemp -d 2> /dev/null || mktemp -d -t mole-app2 2> /dev/null || echo "/tmp/mole-app2-$$") -mkdir -p "$TMP_DIAG2" "$TMP_APP2" -mkdir -p "$TMP_APP2/Contents" -touch "$TMP_DIAG2/TestApp_2025-02-10.ips" - -out=$(get_diagnostic_report_paths_for_app "$TMP_APP2" "Test App" "$TMP_DIAG2" 2> /dev/null || true) -assert_contains "$out" "TestApp_" "fallback to nospace app name matches file" - -rm -rf "$TMP_DIAG2" "$TMP_APP2" 2> /dev/null || true - -echo "" -echo "Result: $PASSED passed, $FAILED failed" -if [[ $FAILED -gt 0 ]]; then - exit 1 -fi -echo "All DiagnosticReports tests passed." -exit 0 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..e56c6ee 100644 --- a/Resources/mole/tests/uninstall.bats +++ b/Resources/mole/tests/uninstall.bats @@ -15,13 +15,20 @@ setup_file() { } teardown_file() { - rm -rf "$HOME" + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" fi } setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi export TERM="dumb" rm -rf "${HOME:?}"/* mkdir -p "$HOME" @@ -34,11 +41,14 @@ create_app_artifacts() { mkdir -p "$HOME/Library/Containers/com.example.TestApp" mkdir -p "$HOME/Library/Preferences" touch "$HOME/Library/Preferences/com.example.TestApp.plist" + touch "$HOME/Library/Preferences/TestApp.plist" mkdir -p "$HOME/Library/Preferences/ByHost" touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" + mkdir -p "$HOME/Library/Saved Application State/TestApp.savedState" mkdir -p "$HOME/Library/LaunchAgents" touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + mkdir -p "$HOME/.cache/testapp" } @test "find_app_files discovers user-level leftovers" { @@ -55,9 +65,68 @@ EOF [[ "$result" == *"Application Support/TestApp"* ]] [[ "$result" == *"Caches/TestApp"* ]] [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] + [[ "$result" == *"Preferences/TestApp.plist"* ]] [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] + [[ "$result" == *"Saved Application State/TestApp.savedState"* ]] [[ "$result" == *"Containers/com.example.TestApp"* ]] [[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]] + [[ "$result" == *".cache/testapp"* ]] +} + +@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" { @@ -81,14 +150,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 @@ -146,7 +219,7 @@ files_cleaned=0 total_items=0 total_size_cleaned=0 -batch_uninstall_applications +printf '\n' | batch_uninstall_applications [[ ! -d "$app_bundle" ]] || exit 1 [[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1 @@ -158,17 +231,36 @@ EOF [ "$status" -eq 0 ] } -@test "batch_uninstall_applications preview shows full related file list" { - mkdir -p "$HOME/Applications/TestApp.app" - mkdir -p "$HOME/Library/Application Support/TestApp" - mkdir -p "$HOME/Library/Caches/TestApp" - mkdir -p "$HOME/Library/Logs/TestApp" - touch "$HOME/Library/Logs/TestApp/log1.log" - touch "$HOME/Library/Logs/TestApp/log2.log" - touch "$HOME/Library/Logs/TestApp/log3.log" - touch "$HOME/Library/Logs/TestApp/log4.log" - touch "$HOME/Library/Logs/TestApp/log5.log" - touch "$HOME/Library/Logs/TestApp/log6.log" +@test "batch_uninstall_applications blocks official-uninstaller apps" { + mkdir -p "$HOME/Applications/Falcon.app" + + 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() { :; } +mole_delete() { echo "MOLE_DELETE:$1"; return 0; } + +selected_apps=("0|$HOME/Applications/Falcon.app|Falcon|com.crowdstrike.falcon.UserAgent|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +if batch_uninstall_applications; then + exit 1 +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"requires the official CrowdStrike uninstaller"* ]] + [[ "$output" != *"MOLE_DELETE"* ]] +} + +@test "batch_uninstall_applications keeps system remnants review-only" { + mkdir -p "$HOME/Applications/ReviewOnly.app" "$HOME/system" + touch "$HOME/system/com.example.review.helper" run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -185,243 +277,1843 @@ show_cursor() { :; } remove_apps_from_dock() { :; } pgrep() { return 1; } pkill() { return 0; } -sudo() { return 0; } -has_sensitive_data() { return 1; } -find_app_system_files() { return 0; } -find_app_files() { - cat << LIST -$HOME/Library/Application Support/TestApp -$HOME/Library/Caches/TestApp -$HOME/Library/Logs/TestApp/log1.log -$HOME/Library/Logs/TestApp/log2.log -$HOME/Library/Logs/TestApp/log3.log -$HOME/Library/Logs/TestApp/log4.log -$HOME/Library/Logs/TestApp/log5.log -$HOME/Library/Logs/TestApp/log6.log -LIST +get_file_owner() { whoami; } +get_path_size_kb() { echo "1"; } +calculate_total_size() { echo "1"; } +find_app_files() { :; } +find_app_system_files() { printf '%s\n' "$HOME/system/com.example.review.helper"; } +get_diagnostic_report_paths_for_app() { :; } +remove_file_list() { + printf 'REMOVE_LIST:%s:%s\n' "${2:-false}" "$1" >> "$HOME/remove.log" + return 0 +} +mole_delete() { + printf 'MOLE_DELETE:%s:%s\n' "$2" "$1" >> "$HOME/remove.log" + rm -rf "$1" + return 0 } -selected_apps=() -selected_apps+=("0|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|0|Never") +selected_apps=("0|$HOME/Applications/ReviewOnly.app|ReviewOnly|com.example.review|0|Never") files_cleaned=0 total_items=0 total_size_cleaned=0 -printf 'q' | batch_uninstall_applications +printf '\n' | batch_uninstall_applications > "$HOME/output.log" 2>&1 + +grep -q "Review only: $HOME/system/com.example.review.helper" "$HOME/output.log" +! grep -q "$HOME/system/com.example.review.helper" "$HOME/remove.log" +[[ -e "$HOME/system/com.example.review.helper" ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] - [[ "$output" != *"more files"* ]] } -@test "safe_remove can remove a simple directory" { - mkdir -p "$HOME/test_dir" - touch "$HOME/test_dir/file.txt" +@test "batch_uninstall_applications dry-run does not report expected leftovers as failures" { + create_app_artifacts 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" -safe_remove "$HOME/test_dir" -[[ ! -d "$HOME/test_dir" ]] || exit 1 +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; } + +export MOLE_DRY_RUN=1 +export MOLE_DELETE_MODE=trash + +app_bundle="$HOME/Applications/TestApp.app" +mkdir -p "$app_bundle" + +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +output_file="$HOME/dry_run_uninstall.log" +printf '\n' | batch_uninstall_applications > "$output_file" 2>&1 +output=$(cat "$output_file") + +[[ -d "$app_bundle" ]] || { echo "WRONG: dry-run removed app bundle"; cat "$output_file"; exit 1; } +[[ -d "$HOME/Library/Application Support/TestApp" ]] || { echo "WRONG: dry-run removed app support"; cat "$output_file"; exit 1; } +[[ -d "$HOME/Library/Caches/TestApp" ]] || { echo "WRONG: dry-run removed cache"; cat "$output_file"; exit 1; } +[[ -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || { echo "WRONG: dry-run removed prefs"; cat "$output_file"; exit 1; } + +[[ "$output" == *"Uninstall dry run complete"* ]] || { echo "WRONG: missing dry-run summary"; cat "$output_file"; exit 1; } +[[ "$output" == *"Would remove 1 app"* ]] || { echo "WRONG: missing would-remove summary"; cat "$output_file"; exit 1; } +[[ "$output" != *"Could not remove"* ]] || { echo "WRONG: dry-run reported expected leftovers"; cat "$output_file"; exit 1; } +[[ "$output" != *"Uninstall incomplete"* ]] || { echo "WRONG: dry-run marked incomplete"; cat "$output_file"; exit 1; } EOF + [ "$status" -eq 0 ] } -@test "decode_file_list validates base64 encoding" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "force_kill_app skips the kill ladder when Quit succeeds" { + # run_with_timeout invokes its argv via gtimeout/timeout, which exec the + # real binary and bypass bash functions, so we shadow osascript via a + # real script on PATH and read the trace it writes. + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/kill_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" <> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" -valid_data=$(printf '/path/one -/path/two' | base64) -result=$(decode_file_list "$valid_data" "TestApp") -[[ -n "$result" ]] || exit 1 +# Bundle with a known id so the Quit step uses the precise `id "..."` form +# rather than the by-name fallback. +app_path="$HOME/Applications/TestApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableTestApp + CFBundleIdentifiercom.example.TestApp + +PLIST + +# First pgrep finds the process (so we enter the kill flow); subsequent +# pgrep calls find nothing (so the function returns 0 once Quit "lands"). +pgrep_count=0 +pgrep() { + pgrep_count=$((pgrep_count + 1)) + if [[ $pgrep_count -eq 1 ]]; then + echo 12345 + return 0 + fi + return 1 +} +export -f pgrep + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pkill + +sleep() { :; } +export -f sleep + +# Allow the osascript branch to run (the upfront guard skips it under test mode). +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "TestApp" "$app_path" EOF [ "$status" -eq 0 ] + grep -q 'osascript .*tell application id .*com\.example\.TestApp.* to quit' "$trace" \ + || { echo "WRONG: missing AppleScript Quit"; cat "$trace"; return 1; } + if grep -q '^pkill ' "$trace"; then + echo "WRONG: pkill ran even though Quit succeeded"; cat "$trace"; return 1 + fi } -@test "decode_file_list rejects invalid base64" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "force_kill_app escalates to pkill when Quit does not land" { + # Process keeps showing up in pgrep until pkill -9 fires, exercising the + # SIGTERM and SIGKILL rungs of the escalation ladder. + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/kill_escalate_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" <> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" -if result=$(decode_file_list "not-valid-base64!!!" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true -fi +app_path="$HOME/Applications/StubbornApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableStubbornApp + CFBundleIdentifiercom.example.StubbornApp + +PLIST + +# Stays alive until SIGKILL lands, then disappears. +sigkill_seen=0 +pgrep() { + if [[ $sigkill_seen -eq 1 ]]; then + return 1 + fi + echo 12345 + return 0 +} +export -f pgrep + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + for arg in "$@"; do + if [[ "$arg" == "-9" ]]; then + sigkill_seen=1 + fi + done + return 0 +} +export -f pkill +export sigkill_seen + +sudo() { return 1; } +export -f sudo + +sleep() { :; } +export -f sleep + +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "StubbornApp" "$app_path" EOF [ "$status" -eq 0 ] + grep -q '^pkill -x StubbornApp' "$trace" \ + || { echo "WRONG: SIGTERM rung did not fire"; cat "$trace"; return 1; } + grep -q '^pkill -9 -x StubbornApp' "$trace" \ + || { echo "WRONG: SIGKILL rung did not fire"; cat "$trace"; return 1; } } -@test "decode_file_list handles empty input" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "force_kill_app rejects unsafe bundle id in AppleScript Quit target" { + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/unsafe_kill_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" <> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" -empty_data=$(printf '' | base64) -result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true -[[ -z "$result" ]] +app_path="$HOME/Applications/TestApp.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << 'PLIST' + + + + CFBundleExecutableTestApp + CFBundleIdentifiercom.example.TestApp" to display dialog "mole + +PLIST + +pgrep_count=0 +pgrep() { + pgrep_count=$((pgrep_count + 1)) + if [[ $pgrep_count -eq 1 ]]; then + echo 12345 + return 0 + fi + return 1 +} +export -f pgrep + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pkill + +sleep() { :; } +export -f sleep + +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "TestApp" "$app_path" EOF [ "$status" -eq 0 ] + if grep -q 'display dialog' "$trace"; then + echo "WRONG: unsafe bundle id reached AppleScript"; cat "$trace"; return 1 + fi + grep -q 'osascript .*tell application "TestApp" to quit' "$trace" \ + || { echo "WRONG: unsafe id did not fall back to app name"; cat "$trace"; return 1; } } -@test "decode_file_list rejects non-absolute paths" { - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +@test "force_kill_app refuses to operate on system process names" { + # Defensive guard: a third-party .app could set CFBundleExecutable to a + # system process name (Finder, Dock, loginwindow, etc.). Even though the + # uninstall selection layer filters out protected bundle IDs, force_kill_app + # is a public function and must hold its own boundary. Verify it returns 1 + # without invoking pkill or osascript for these names. + stubdir="$HOME/stubs" + mkdir -p "$stubdir" + trace="$HOME/system_proc_trace.log" + : > "$trace" + + cat > "$stubdir/osascript" <> "$trace" +exit 0 +STUB + chmod +x "$stubdir/osascript" + + for spoofed in Finder Dock loginwindow WindowServer SystemUIServer; do + : > "$trace" + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$stubdir:$PATH" \ + TRACE_PATH="$trace" SPOOFED="$spoofed" bash --noprofile --norc <<'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/uninstall/batch.sh" -bad_data=$(printf 'relative/path' | base64) -if result=$(decode_file_list "$bad_data" "TestApp" 2>/dev/null); then - [[ -z "$result" ]] -else - true -fi +app_path="$HOME/Applications/Evil-$SPOOFED.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" << PLIST + + + + CFBundleExecutable$SPOOFED + CFBundleIdentifiercom.example.evil + +PLIST + +pkill() { + printf 'pkill %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pkill + +# pgrep must NOT be called - the guard runs before any process probing. +pgrep() { + printf 'pgrep %s\n' "$*" >> "$TRACE_PATH" + return 0 +} +export -f pgrep + +sleep() { :; } +export -f sleep + +unset MOLE_TEST_MODE MOLE_TEST_NO_AUTH + +force_kill_app "Evil-$SPOOFED" "$app_path" EOF - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] \ + || { echo "WRONG: spoofed $spoofed did not return 1 (got $status)"; cat "$trace"; return 1; } + if [[ -s "$trace" ]]; then + echo "WRONG: spoofed $spoofed reached pkill/pgrep/osascript"; cat "$trace"; return 1 + fi + done } -@test "decode_file_list handles both BSD and GNU base64 formats" { +@test "batch_uninstall_applications proceeds with deletion when force_kill_app fails" { + # Reproduces the issue where uninstalling a still-running app (e.g. Mole.app + # with a watchdog or XPC helper that ignores SIGKILL) used to abort with + # "still running" and leave the bundle on disk. macOS allows deleting a + # running app's bundle; we should warn the user but proceed. + create_app_artifacts + 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" -test_paths="/path/to/file1 -/path/to/file2" +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() { :; } +# Pretend the kill ladder exhausted itself: process is still there. +force_kill_app() { return 1; } +sudo() { return 0; } -encoded_data=$(printf '%s' "$test_paths" | base64 | tr -d '\n') +app_bundle="$HOME/Applications/TestApp.app" +mkdir -p "$app_bundle" -result=$(decode_file_list "$encoded_data" "TestApp") +related="$(find_app_files "com.example.TestApp" "TestApp")" +encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') -[[ "$result" == *"/path/to/file1"* ]] || exit 1 -[[ "$result" == *"/path/to/file2"* ]] || exit 1 +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 -[[ -n "$result" ]] || exit 1 +# Send batch_uninstall_applications its own /dev/null stdin so the inline +# `read -r -s -n1 key` does not steal a byte from the heredoc script source +# (which would silently corrupt the next bash command into 127). +output_file="$HOME/batch_output.log" +printf '\n' | batch_uninstall_applications > "$output_file" 2>&1 +output=$(cat "$output_file") + +# Bundle and leftovers must be gone even though kill failed. +[[ ! -d "$app_bundle" ]] || { echo "WRONG: bundle preserved despite running flag"; cat "$output_file"; exit 1; } +[[ ! -d "$HOME/Library/Caches/TestApp" ]] || { echo "WRONG: cache preserved"; exit 1; } +[[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || { echo "WRONG: prefs preserved"; exit 1; } + +# The legacy "still running" failure summary must NOT fire. +[[ "$output" != *"is still running"* ]] || { echo "WRONG: legacy still-running failure surfaced"; exit 1; } +[[ "$output" != *Failed:*TestApp* ]] || { echo "WRONG: app counted as failed"; exit 1; } + +# A friendlier warning should appear so the user knows to quit the lingering process. +[[ "$output" == *"Still running during uninstall"* ]] || { echo "WRONG: missing running-process warning"; cat "$output_file"; exit 1; } +[[ "$output" == *TestApp* ]] || { echo "WRONG: warning omits app name"; exit 1; } EOF [ "$status" -eq 0 ] } -@test "refresh_launch_services_after_uninstall falls back after timeout" { +@test "stop_launch_services unloads launch agents without deleting plists" { + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist" + touch "$HOME/Library/LaunchAgents/com.example.TestApp.helper.plist" + touch "$HOME/Library/LaunchAgents/com.example.TestApplication.plist" + 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" -log_file="$HOME/lsregister-timeout.log" -: > "$log_file" -call_index=0 - -get_lsregister_path() { echo "/bin/echo"; } -debug_log() { echo "DEBUG:$*" >> "$log_file"; } +trace="$HOME/trace.log" +launchctl() { + printf 'launchctl %s\n' "$*" >> "$trace" +} run_with_timeout() { - local duration="$1" - shift - call_index=$((call_index + 1)) - echo "CALL${call_index}:$duration:$*" >> "$log_file" - - if [[ "$call_index" -eq 2 ]]; then - return 124 - fi - if [[ "$call_index" -eq 3 ]]; then - return 124 - fi - return 0 + shift + "$@" +} +safe_remove() { + printf 'safe_remove %s\n' "$*" >> "$trace" + return 0 +} +safe_sudo_remove() { + printf 'safe_sudo_remove %s\n' "$*" >> "$trace" + return 0 } -if refresh_launch_services_after_uninstall; then - echo "RESULT:ok" -else - echo "RESULT:fail" -fi +stop_launch_services "com.example.TestApp" "false" "" -cat "$log_file" + grep -Fq "launchctl unload $HOME/Library/LaunchAgents/com.example.TestApp.plist" "$trace" + grep -Fq "launchctl unload $HOME/Library/LaunchAgents/com.example.TestApp.helper.plist" "$trace" + ! grep -Fq "com.example.TestApplication.plist" "$trace" + ! grep -q "safe_remove" "$trace" + [[ -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] + [[ -f "$HOME/Library/LaunchAgents/com.example.TestApp.helper.plist" ]] + [[ -f "$HOME/Library/LaunchAgents/com.example.TestApplication.plist" ]] EOF [ "$status" -eq 0 ] - [[ "$output" == *"RESULT:ok"* ]] - [[ "$output" == *"CALL2:15:/bin/echo -r -f -domain local -domain user -domain system"* ]] - [[ "$output" == *"CALL3:10:/bin/echo -r -f -domain local -domain user"* ]] - [[ "$output" == *"DEBUG:LaunchServices rebuild timed out, trying lighter version"* ]] } -@test "remove_mole deletes 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" - - run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' +@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() { :; } -rm() { - local -a flags=() - local -a paths=() - local arg - for arg in "$@"; do - if [[ "$arg" == -* ]]; then - flags+=("$arg") - else - paths+=("$arg") - fi - done - local path - for path in "${paths[@]}"; do - if [[ "$path" == "$HOME" || "$path" == "$HOME/"* ]]; then +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" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Logs/TestApp" + touch "$HOME/Library/Logs/TestApp/log1.log" + touch "$HOME/Library/Logs/TestApp/log2.log" + touch "$HOME/Library/Logs/TestApp/log3.log" + touch "$HOME/Library/Logs/TestApp/log4.log" + touch "$HOME/Library/Logs/TestApp/log5.log" + touch "$HOME/Library/Logs/TestApp/log6.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/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; } +has_sensitive_data() { return 1; } +find_app_system_files() { return 0; } +find_app_files() { + cat << LIST +$HOME/Library/Application Support/TestApp +$HOME/Library/Caches/TestApp +$HOME/Library/Logs/TestApp/log1.log +$HOME/Library/Logs/TestApp/log2.log +$HOME/Library/Logs/TestApp/log3.log +$HOME/Library/Logs/TestApp/log4.log +$HOME/Library/Logs/TestApp/log5.log +$HOME/Library/Logs/TestApp/log6.log +LIST +} + +selected_apps=() +selected_apps+=("0|$HOME/Applications/TestApp.app|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\nq' | batch_uninstall_applications +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]] + [[ "$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 "cached uninstall metadata is rejected when the current bundle is protected" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +app_path="$HOME/Applications/Safari.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.apple.Safari + + +PLIST + +if uninstall_resolve_eligible_bundle_id "$app_path" "com.example.cached" > /dev/null; then + echo "protected app should not be eligible" >&2 + exit 1 +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "cached uninstall metadata is rejected when the app is background-only" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +app_path="$HOME/Applications/Helper.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Helper + LSBackgroundOnly + + + +PLIST + +if uninstall_resolve_eligible_bundle_id "$app_path" "com.example.Helper" > /dev/null; then + echo "background-only app should not be eligible" >&2 + exit 1 +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "OneDrive Mac App Store bundle is eligible even when marked background-only" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +app_path="$HOME/Applications/OneDrive.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.microsoft.OneDrive-mac + LSBackgroundOnly + + + +PLIST + +result=$(uninstall_resolve_eligible_bundle_id "$app_path" "") +[[ "$result" == "com.microsoft.OneDrive-mac" ]] || { + echo "unexpected bundle id: $result" >&2 + exit 1 +} +EOF + + [ "$status" -eq 0 ] +} + +@test "eligible uninstall metadata uses the current bundle id over stale cache" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +eval "$(sed -n '/^uninstall_resolve_bundle_id()/,/^uninstall_app_inventory_fingerprint()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +app_path="$HOME/Applications/Plain.app" +mkdir -p "$app_path/Contents" +cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Plain + + +PLIST + +result=$(uninstall_resolve_eligible_bundle_id "$app_path" "com.example.Stale") +[[ "$result" == "com.example.Plain" ]] || { + echo "unexpected bundle id: $result" >&2 + 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" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +safe_remove "$HOME/test_dir" +[[ ! -d "$HOME/test_dir" ]] || exit 1 +EOF + [ "$status" -eq 0 ] +} + +@test "decode_file_list validates base64 encoding" { + 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" + +valid_data=$(printf '/path/one +/path/two' | base64) +result=$(decode_file_list "$valid_data" "TestApp") +[[ -n "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list rejects invalid base64" { + 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" + +if result=$(decode_file_list "not-valid-base64!!!" "TestApp" 2>/dev/null); then + [[ -z "$result" ]] +else + true +fi +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 +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +empty_data=$(printf '' | base64) +result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true +[[ -z "$result" ]] +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list rejects non-absolute 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/uninstall/batch.sh" + +bad_data=$(printf 'relative/path' | base64) +if result=$(decode_file_list "$bad_data" "TestApp" 2>/dev/null); then + [[ -z "$result" ]] +else + true +fi +EOF + + [ "$status" -eq 0 ] +} + +@test "decode_file_list handles both BSD and GNU base64 formats" { + 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" + +test_paths="/path/to/file1 +/path/to/file2" + +encoded_data=$(printf '%s' "$test_paths" | base64 | tr -d '\n') + +result=$(decode_file_list "$encoded_data" "TestApp") + +[[ "$result" == *"/path/to/file1"* ]] || exit 1 +[[ "$result" == *"/path/to/file2"* ]] || exit 1 + +[[ -n "$result" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} + +@test "refresh_launch_services_after_uninstall falls back after timeout" { + 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" + +log_file="$HOME/lsregister-timeout.log" +: > "$log_file" +call_index=0 + +get_lsregister_path() { echo "/bin/echo"; } +debug_log() { echo "DEBUG:$*" >> "$log_file"; } +run_with_timeout() { + local duration="$1" + shift + call_index=$((call_index + 1)) + echo "CALL${call_index}:$duration:$*" >> "$log_file" + + if [[ "$call_index" -eq 2 ]]; then + return 124 + fi + if [[ "$call_index" -eq 3 ]]; then + return 124 + fi + return 0 +} + +if refresh_launch_services_after_uninstall; then + echo "RESULT:ok" +else + echo "RESULT:fail" +fi + +cat "$log_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT:ok"* ]] + [[ "$output" == *"CALL2:15:/bin/echo -r -f -domain local -domain user -domain system"* ]] + [[ "$output" == *"CALL3:10:/bin/echo -r -f -domain local -domain user"* ]] + [[ "$output" == *"DEBUG:LaunchServices rebuild timed out, trying lighter version"* ]] +} + +@test "remove_mole deletes 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" "$HOME/Library/Logs/mole" + + 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() { :; } +rm() { + local -a flags=() + local -a paths=() + local arg + for arg in "$@"; do + if [[ "$arg" == -* ]]; then + flags+=("$arg") + else + paths+=("$arg") + fi + done + local path + for path in "${paths[@]}"; do + if [[ "$path" == "$HOME" || "$path" == "$HOME/"* ]]; then /bin/rm "${flags[@]}" "$path" fi done return 0 } -sudo() { - if [[ "$1" == "rm" ]]; then - shift - rm "$@" +sudo() { + if [[ "$1" == "rm" ]]; then + shift + rm "$@" + return 0 + fi + return 0 +} +export -f start_inline_spinner stop_inline_spinner rm sudo +printf '\n' | "$PROJECT_ROOT/mole" remove +EOF + + [ "$status" -eq 0 ] + [ ! -f "$HOME/.local/bin/mole" ] + [ ! -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" "$HOME/Library/Logs/mole" + + 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() { :; } +export -f start_inline_spinner stop_inline_spinner +printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN MODE"* ]] + [ -f "$HOME/.local/bin/mole" ] + [ -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 ] +} + +@test "scan_applications starts feedback before discovery and cleans no-app state" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" MOLE_TEST_FORCE_SCAN_SPINNER=1 bash --noprofile --norc <<'INNER' +set -euo pipefail + +trace_file="$HOME/scan-feedback-trace.log" +scan_temp="$HOME/scan-feedback-temp" + +MOLE_UNINSTALL_META_CACHE_DIR="$HOME/.cache/mole" +MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstall_app_metadata_v1" +MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" + +create_temp_file() { printf '%s\n' "$scan_temp"; } +ensure_user_dir() { mkdir -p "$1"; } +ensure_user_file() { + mkdir -p "$(dirname "$1")" + : > "$1" +} + +_scan_discover_apps() { + if [[ -n "${spinner_pid:-}" ]]; then + printf 'spinner-before-discover\n' >> "$trace_file" + else + printf 'missing-spinner\n' >> "$trace_file" + fi + : > "$discovered_file" +} +_scan_partition_cache() { printf 'partition\n' >> "$trace_file"; } +_scan_resolve_uncached() { printf 'resolve\n' >> "$trace_file"; } +_scan_dedupe_bundle_ids() { printf 'dedupe\n' >> "$trace_file"; } +_scan_finalize_index() { printf 'finalize\n' >> "$trace_file"; } + +eval "$(sed -n '/^scan_applications()/,/^load_applications()/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +set +e +scan_applications > "$HOME/scan-feedback.out" 2> "$HOME/scan-feedback.err" +rc=$? +set -e + +[[ $rc -eq 1 ]] || exit 1 + +expected=$(printf 'spinner-before-discover\npartition\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} + +[[ ! -e "${scan_temp}.spinner_shown" ]] +[[ ! -e "${scan_temp}.scan_status" ]] +INNER + + [ "$status" -eq 0 ] +} + +@test "select_apps_for_uninstall drains pending input before opening paginated menu" { + mkdir -p "$HOME/Applications/TraceApp.app" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" bash --noprofile --norc <<'INNER' +set -euo pipefail + +trace_file="$HOME/selector-drain-trace.log" + +source "$PROJECT_ROOT/lib/ui/app_selector.sh" + +apps_data=("1700000000|$HOME/Applications/TraceApp.app|TraceApp|com.example.TraceApp|1MB|Today|1024") +selected_apps=() + +get_display_width() { printf '%s\n' "${#1}"; } +format_app_display() { + printf 'format\n' >> "$trace_file" + printf '%s' "$1" +} +drain_pending_input() { printf 'drain\n' >> "$trace_file"; } +paginated_multi_select() { + printf 'guard:%s\n' "${MOLE_MENU_IGNORE_INITIAL_ENTER:-unset}" >> "$trace_file" + printf 'paginated\n' >> "$trace_file" + MOLE_SELECTION_RESULT="0" + return 0 +} + +select_apps_for_uninstall +[[ ${#selected_apps[@]} -eq 1 ]] +[[ -z "${MOLE_MENU_IGNORE_INITIAL_ENTER:-}" ]] + +expected=$(printf 'format\ndrain\nguard:1\npaginated\n') +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + [ "$status" -eq 0 ] +} + +@test "paginated menu can ignore one initial Enter for uninstall launch guard" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" bash --noprofile --norc <<'INNER' +set -euo pipefail + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +key_state="$HOME/menu-initial-enter-state" +read_key() { + if [[ ! -f "$key_state" ]]; then + : > "$key_state" + echo "ENTER" + else + echo "QUIT" + fi +} + +MOLE_SELECTION_RESULT="" +set +e +MOLE_MENU_IGNORE_INITIAL_ENTER=1 paginated_multi_select "Test Menu" "First App" > "$HOME/menu.out" 2> "$HOME/menu.err" +rc=$? +set -e + +echo "rc=$rc" +echo "result=${MOLE_SELECTION_RESULT:-}" +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=1"* ]] + [[ "$output" == *"result="* ]] + [[ "$output" != *"result=0"* ]] +} + +@test "paginated menu skips Size sort when size metadata is unavailable (#1126)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" bash --noprofile --norc <<'INNER' +set -euo pipefail + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +key_state="$HOME/menu-no-size-state" +read_key() { + local n + n=$(cat "$key_state" 2> /dev/null || echo 0) + n=$((n + 1)) + printf '%s\n' "$n" > "$key_state" + case "$n" in + 1 | 2) echo "CHAR:S" ;; + *) echo "ENTER" ;; + esac +} + +MOLE_SELECTION_RESULT="" +unset MOLE_MENU_SORT_MODE MOLE_MENU_SORT_REVERSE MOLE_MENU_META_SIZEKB +set +e +MOLE_MENU_META_EPOCHS="100,200" paginated_multi_select "Test Menu" "Alpha" "Beta" > "$HOME/menu.out" 2> "$HOME/menu.err" +rc=$? +set -e +echo "rc=$rc" +echo "mode=${MOLE_MENU_SORT_MODE:-}" +echo "result=${MOLE_SELECTION_RESULT:-}" +[[ $rc -eq 0 ]] +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"rc=0"* ]] + [[ "$output" == *"mode=date"* ]] + [[ "$output" == *"result=0"* ]] +} + +@test "paginated menu reverses Size order when size metadata is available (#1126)" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" TERM="xterm-256color" bash --noprofile --norc <<'INNER' +set -euo pipefail + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/ui/menu_paginated.sh" + +key_state="$HOME/menu-size-state" +read_key() { + local n + n=$(cat "$key_state" 2> /dev/null || echo 0) + n=$((n + 1)) + printf '%s\n' "$n" > "$key_state" + case "$n" in + 1) echo "${NEXT_KEY:-ENTER}" ;; + *) echo "ENTER" ;; + esac +} + +MOLE_SELECTION_RESULT="" +set +e +MOLE_MENU_META_SIZEKB="1,100" MOLE_MENU_SORT_MODE=size MOLE_MENU_SORT_REVERSE=false paginated_multi_select "Test Menu" "Small" "Large" > "$HOME/menu-default.out" 2> "$HOME/menu-default.err" +default_rc=$? +set -e +echo "default=${MOLE_SELECTION_RESULT:-}" + +: > "$key_state" +MOLE_SELECTION_RESULT="" +set +e +NEXT_KEY="CHAR:O" MOLE_MENU_META_SIZEKB="1,100" MOLE_MENU_SORT_MODE=size MOLE_MENU_SORT_REVERSE=false paginated_multi_select "Test Menu" "Small" "Large" > "$HOME/menu-reverse.out" 2> "$HOME/menu-reverse.err" +reverse_rc=$? +set -e +echo "default_rc=$default_rc" +echo "reverse_rc=$reverse_rc" +echo "reverse=${MOLE_SELECTION_RESULT:-}" +[[ $default_rc -eq 0 ]] +[[ $reverse_rc -eq 0 ]] +INNER + + [ "$status" -eq 0 ] + [[ "$output" == *"default_rc=0"* ]] + [[ "$output" == *"reverse_rc=0"* ]] + [[ "$output" == *"default=1"* ]] + [[ "$output" == *"reverse=0"* ]] +} + +@test "main rescans cleanly after returning from a completed uninstall (#866)" { + local first_cache second_cache + first_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-866-first.XXXXXX")" + second_cache="$(mktemp "${BATS_TEST_TMPDIR:-$BATS_RUN_TMPDIR:-$HOME}/tmp-866-second.XXXXXX")" + + mkdir -p "$HOME/Applications/FirstApp.app" "$HOME/Applications/SecondApp.app" + cat > "$first_cache" < "$second_cache" < "$scan_state_file" +select_count=0 +fingerprint_state="before" +selected_apps=() + +log_operation_session_start() { :; } +show_uninstall_help() { :; } +hide_cursor() { :; } +show_cursor() { :; } +clear_screen() { :; } +drain_pending_input() { :; } +uninstall_app_inventory_fingerprint() { printf '%s\n' "$fingerprint_state"; } +batch_uninstall_applications() { + printf 'batch\n' >> "$trace_file" + rmdir "$HOME/Applications/FirstApp.app" + fingerprint_state="after" +} +uninstall_normalize_size_display() { printf '%s\n' "$1"; } +uninstall_normalize_last_used_display() { printf '%s\n' "$1"; } +scan_applications() { + local scan_count + scan_count=$(cat "$scan_state_file") + scan_count=$((scan_count + 1)) + printf '%s\n' "$scan_count" > "$scan_state_file" + printf 'scan:%s\n' "$scan_count" >> "$trace_file" + if [[ $scan_count -eq 1 ]]; then + printf '%s\n' "$FIRST_CACHE" + else + printf '%s\n' "$SECOND_CACHE" + fi +} +load_applications() { + local apps_file="$1" + apps_data=() + selection_state=() + while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do + [[ -e "$app_path" ]] || continue + apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") + selection_state+=(false) + done < "$apps_file" + printf 'load:%s\n' "${apps_data[0]#*|}" >> "$trace_file" +} +select_apps_for_uninstall() { + select_count=$((select_count + 1)) + printf 'select:%s\n' "$select_count" >> "$trace_file" + if [[ $select_count -eq 1 ]]; then + selected_apps=("${apps_data[0]}") return 0 fi + return 1 +} + +eval "$(sed -n '/^main()/,/^main "\$@"/p' "$PROJECT_ROOT/bin/uninstall.sh" | sed '$d')" + +printf '\n' | main + +expected=$(printf 'scan:1\nload:%s/Applications/FirstApp.app|FirstApp|com.example.FirstApp|10MB|Today|10240\nselect:1\nbatch\nscan:2\nload:%s/Applications/SecondApp.app|SecondApp|com.example.SecondApp|11MB|Today|11264\nselect:2\n' "$HOME" "$HOME") +actual=$(cat "$trace_file") +[[ "$actual" == "$expected" ]] || { + printf 'unexpected trace:\n%s\n' "$actual" >&2 + exit 1 +} +INNER + + rm -f "$first_cache" "$second_cache" + [ "$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 + printf '%s\n' "$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 } -export -f start_inline_spinner stop_inline_spinner rm sudo -printf '\n' | "$PROJECT_ROOT/mole" remove -EOF +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 ] - [ ! -f "$HOME/.local/bin/mole" ] - [ ! -f "$HOME/.local/bin/mo" ] - [ ! -d "$HOME/.config/mole" ] - [ ! -d "$HOME/.cache/mole" ] + [[ "$output" == "[]" ]] } -@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" +@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" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' + 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 -start_inline_spinner() { :; } -stop_inline_spinner() { :; } -export -f start_inline_spinner stop_inline_spinner -printf '\n' | "$PROJECT_ROOT/mole" remove --dry-run -EOF +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" == *"DRY RUN MODE"* ]] - [ -f "$HOME/.local/bin/mole" ] - [ -f "$HOME/.local/bin/mo" ] - [ -d "$HOME/.config/mole" ] - [ -d "$HOME/.cache/mole" ] + [[ "$output" == *'"uninstall_name": "visual-studio-code"'* ]] + [[ "$output" == *'"source": "Homebrew"'* ]] +} + +# Regression tests for #940: detect Background Items left behind after uninstall. +_btm_helper_runner() { + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + BTM_DUMP="$1" DETAIL="$2" SUCCESS_PATH="$3" \ + bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" +_uninstall_match_btm_leftovers "$BTM_DUMP" "$DETAIL" -- "$SUCCESS_PATH" +EOF +} + +@test "_uninstall_match_btm_leftovers reports bundle id still in BTM dump" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + local dump=$'Record #1\n name: Paste\n bundleID: com.wiheads.paste\n url: file:///Applications/Paste.app/' + + result="$(_btm_helper_runner "$dump" "$detail" "/Applications/Paste.app")" + + [ "$result" = "Paste" ] +} + +@test "_uninstall_match_btm_leftovers stays silent when bundle id is absent" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + local dump=$'Record #1\n name: SomethingElse\n bundleID: com.example.other' + + result="$(_btm_helper_runner "$dump" "$detail" "/Applications/Paste.app")" + + [ -z "$result" ] +} + +@test "_uninstall_match_btm_leftovers skips apps that were not successfully removed" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + local dump=$'Record #1\n bundleID: com.wiheads.paste' + + result="$(_btm_helper_runner "$dump" "$detail" "/Applications/OtherApp.app")" + + [ -z "$result" ] +} + +@test "_uninstall_match_btm_leftovers ignores unknown bundle id" { + local detail="Paste|/Applications/Paste.app|unknown|0|||false|false|false||||" + local dump=$'Record #1\n bundleID: unknown' + + result="$(_btm_helper_runner "$dump" "$detail" "/Applications/Paste.app")" + + [ -z "$result" ] +} + +@test "_uninstall_match_btm_leftovers returns empty for empty dump" { + local detail="Paste|/Applications/Paste.app|com.wiheads.paste|0|||false|false|false||||" + + result="$(_btm_helper_runner "" "$detail" "/Applications/Paste.app")" + + [ -z "$result" ] } diff --git a/Resources/mole/tests/uninstall_naming_variants.bats b/Resources/mole/tests/uninstall_naming_variants.bats index eee48f9..18825df 100644 --- a/Resources/mole/tests/uninstall_naming_variants.bats +++ b/Resources/mole/tests/uninstall_naming_variants.bats @@ -17,14 +17,19 @@ setup_file() { } teardown_file() { - if [[ -d "$HOME" && "$HOME" =~ tmp-naming ]]; then + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then rm -rf "$HOME" fi export HOME="$ORIGINAL_HOME" } setup() { - find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2> /dev/null || true source "$PROJECT_ROOT/lib/core/base.sh" source "$PROJECT_ROOT/lib/core/log.sh" source "$PROJECT_ROOT/lib/core/app_protection.sh" @@ -83,20 +88,28 @@ setup() { @test "find_app_files detects multiple naming variants simultaneously" { mkdir -p "$HOME/.config/maestro-studio" + mkdir -p "$HOME/.cache/maestro-studio" mkdir -p "$HOME/Library/Application Support/MaestroStudio" mkdir -p "$HOME/Library/Application Support/Maestro-Studio" + mkdir -p "$HOME/Library/Preferences" + mkdir -p "$HOME/Library/Saved Application State/MaestroStudio.savedState" mkdir -p "$HOME/.local/share/maestrostudio" echo "test" > "$HOME/.config/maestro-studio/config.json" + echo "test" > "$HOME/.cache/maestro-studio/cache.db" echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db" echo "test" > "$HOME/Library/Application Support/Maestro-Studio/prefs.json" + echo "test" > "$HOME/Library/Preferences/Maestro-Studio.plist" echo "test" > "$HOME/.local/share/maestrostudio/cache.db" result=$(find_app_files "com.maestro.studio" "Maestro Studio") [[ "$result" =~ .config/maestro-studio ]] + [[ "$result" =~ .cache/maestro-studio ]] [[ "$result" =~ "Library/Application Support/MaestroStudio" ]] [[ "$result" =~ "Library/Application Support/Maestro-Studio" ]] + [[ "$result" =~ Library/Preferences/Maestro-Studio\.plist ]] + [[ "$result" =~ Library/Saved\ Application\ State/MaestroStudio\.savedState ]] [[ "$result" =~ .local/share/maestrostudio ]] } @@ -109,10 +122,140 @@ 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 detects vendor-nested Application Support directories" { + mkdir -p "$HOME/Library/Application Support/Avid/Sibelius" + mkdir -p "$HOME/Library/Application Support/OtherVendor/Sibelius" + echo "test" > "$HOME/Library/Application Support/Avid/Sibelius/settings.db" + echo "test" > "$HOME/Library/Application Support/OtherVendor/Sibelius/settings.db" + + result=$(find_app_files "com.avid.sibelius" "Sibelius") + + [[ "$result" =~ Library/Application\ Support/Avid/Sibelius ]] + [[ ! "$result" =~ Library/Application\ Support/OtherVendor/Sibelius ]] +} + @test "find_app_files does not match empty app name" { mkdir -p "$HOME/Library/Application Support/test" + mkdir -p "$HOME/Library/Preferences" + mkdir -p "$HOME/.config" "$HOME/.cache" "$HOME/.local/share" - result=$(find_app_files "com.test" "" 2>/dev/null || true) + result=$(find_app_files "com.test" "" 2> /dev/null || true) [[ ! "$result" =~ "Library/Application Support"$ ]] + [[ ! "$result" =~ "Library/Preferences"$ ]] + [[ ! "$result" =~ "$HOME/."$ ]] + [[ ! "$result" =~ ".config"$ ]] + [[ ! "$result" =~ ".cache"$ ]] + [[ ! "$result" =~ ".local/share"$ ]] +} + +# Regression: with an invalid bundle id AND an empty app name, no pattern +# block fires, leaving user_patterns empty. macOS /bin/bash 3.2 under set -u +# treats expanding an empty array as an unbound variable, so the scan must +# use the +-guard idiom instead of crashing. +@test "find_app_files survives empty pattern list under bash 3.2 set -u" { + run /bin/bash -c "set -u +source '$PROJECT_ROOT/lib/core/base.sh' +source '$PROJECT_ROOT/lib/core/log.sh' +source '$PROJECT_ROOT/lib/core/app_protection.sh' +find_app_files 'invalid_bundle' ''" + + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"unbound variable"* ]] || return 1 +} + +@test "find_app_files detects VS Code stable Application Support folder (#850)" { + mkdir -p "$HOME/Library/Application Support/Code" + mkdir -p "$HOME/Library/Application Support/Code - Insiders" + mkdir -p "$HOME/.vscode" + + result=$(find_app_files "com.microsoft.VSCode" "Visual Studio Code") + + [[ "$result" =~ Library/Application\ Support/Code$'\n' ]] || [[ "$result" == *"Library/Application Support/Code"* ]] + [[ "$result" == *"/.vscode"* ]] + [[ "$result" != *"Code - Insiders"* ]] +} + +@test "find_app_files detects VS Code Insiders Application Support folder (#850)" { + mkdir -p "$HOME/Library/Application Support/Code" + mkdir -p "$HOME/Library/Application Support/Code - Insiders" + mkdir -p "$HOME/.vscode-insiders" + + result=$(find_app_files "com.microsoft.VSCodeInsiders" "Visual Studio Code - Insiders") + + [[ "$result" == *"Library/Application Support/Code - Insiders"* ]] + [[ "$result" == *"/.vscode-insiders"* ]] + [[ ! "$result" =~ Library/Application\ Support/Code$'\n' ]] +} + +# Independent CLI dotdir protection — issue #993. +# Uninstalling a GUI app named "Claude" / "OpenCode" / etc. must not delete +# the same-named standalone CLI tool's state directory. + +@test "find_app_files preserves ~/.claude when uninstalling Claude.app (#993)" { + mkdir -p "$HOME/.claude/projects" + mkdir -p "$HOME/Library/Application Support/Claude" + echo "memory" > "$HOME/.claude/projects/sample" + + result=$(find_app_files "com.anthropic.claudefordesktop" "Claude") + + [[ "$result" == *"Library/Application Support/Claude"* ]] + [[ "$result" != *"$HOME/.claude"* ]] + [[ "$result" != *"$HOME/.Claude"* ]] +} + +@test "find_app_files preserves ~/.local/share/opencode when uninstalling OpenCode.app (#993)" { + mkdir -p "$HOME/.local/share/opencode/snapshot" + mkdir -p "$HOME/.config/opencode" + mkdir -p "$HOME/.opencode" + mkdir -p "$HOME/Library/Application Support/opencode" + + result=$(find_app_files "ai.opencode.desktop" "opencode") + + [[ "$result" == *"Library/Application Support/opencode"* ]] + [[ "$result" != *".local/share/opencode"* ]] + [[ "$result" != *".config/opencode"* ]] + [[ "$result" != *"$HOME/.opencode"* ]] +} + +@test "find_app_files preserves ~/.codex when uninstalling Codex.app (#993)" { + mkdir -p "$HOME/.codex" + mkdir -p "$HOME/.config/codex" + mkdir -p "$HOME/Library/Application Support/Codex" + + result=$(find_app_files "com.openai.codex" "Codex") + + [[ "$result" == *"Library/Application Support/Codex"* ]] + [[ "$result" != *"$HOME/.codex"* ]] + [[ "$result" != *".config/codex"* ]] +} + +@test "find_app_files still removes Zed XDG state (independent-CLI list must not over-protect)" { + # Sanity check that the deny-list does not break legitimate GUI-app XDG + # cleanup added for #377. Zed is a GUI app that owns ~/.config/zed and + # ~/.local/share/zed and must still be picked up on uninstall. + mkdir -p "$HOME/.config/zed" + mkdir -p "$HOME/.local/share/zed" + + result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly") + + [[ "$result" == *".config/zed"* ]] || [[ "$result" == *".local/share/zed"* ]] } diff --git a/Resources/mole/tests/uninstall_remove_file_list.bats b/Resources/mole/tests/uninstall_remove_file_list.bats new file mode 100644 index 0000000..f4c673c --- /dev/null +++ b/Resources/mole/tests/uninstall_remove_file_list.bats @@ -0,0 +1,192 @@ +#!/usr/bin/env bats + +# Tests for remove_file_list batching in lib/uninstall/batch.sh. +# Exercises the batched Trash path (single _mole_move_to_trash_batch call for +# eligible files) and the fallback when the batch helper fails. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT +} + +setup() { + SANDBOX="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-batch.XXXXXX")" + export SANDBOX + export MOLE_DELETE_LOG="$SANDBOX/deletions.log" + export MOLE_TEST_TRASH_DIR="$SANDBOX/Trash" + export MOLE_TEST_NO_AUTH=1 + export MOLE_DELETE_MODE=trash + unset MOLE_DRY_RUN + HOME="$SANDBOX/home" + mkdir -p "$HOME" + export HOME +} + +teardown() { + rm -rf "$SANDBOX" +} + +prelude() { + cat < "$f1" + : > "$f2" + : > "$f3" + : > "$f4" + : > "$f5" + local list + printf -v list '%s\n%s\n%s\n%s\n%s' "$f1" "$f2" "$f3" "$f4" "$f5" + + local count_file="$SANDBOX/batch_calls" + : > "$count_file" + + # Stub the batch helper to (1) record how many times it was called and + # how many paths each call covered, (2) emulate the real test-harness + # behavior by mv'ing each path into MOLE_TEST_TRASH_DIR. This lets the + # test assert both "called once" and "every file landed in trash". + run bash --noprofile --norc <> "$count_file" + local p dest + for p in "\$@"; do + dest="\$MOLE_TEST_TRASH_DIR/\$(basename "\$p").stub.\$RANDOM" + mv "\$p" "\$dest" 2>/dev/null || return 1 + done + return 0 +} +remove_file_list "$list" "false" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"5"* ]] # remove_file_list echoes count + + # All five files moved to the stub trash dir. + local in_trash + in_trash=$(find "$MOLE_TEST_TRASH_DIR" -type f | wc -l | tr -d ' ') + [ "$in_trash" -eq 5 ] + for f in "$f1" "$f2" "$f3" "$f4" "$f5"; do + [[ ! -e "$f" ]] + done + + # Single batch invocation, with all five paths. + local call_count + call_count=$(wc -l < "$count_file" | tr -d ' ') + [ "$call_count" -eq 1 ] + grep -q '^call 5$' "$count_file" + + # Audit log records one ok line per moved path. + local ok_lines + ok_lines=$(awk -F'\t' '$4 == "ok" && $2 == "trash"' "$MOLE_DELETE_LOG" | wc -l | tr -d ' ') + [ "$ok_lines" -eq 5 ] +} + +@test "remove_file_list falls through to per-file path when batch helper fails" { + local f1="$SANDBOX/x.plist" + local f2="$SANDBOX/y.plist" + : > "$f1" + : > "$f2" + local list + printf -v list '%s\n%s' "$f1" "$f2" + + local trace="$SANDBOX/trace" + : > "$trace" + + # Stub the batch helper to fail, and stub mole_delete to record per-file + # invocations and act on the file. This proves the fallback path runs once + # per file rather than silently dropping the batch. + run bash --noprofile --norc <> "$trace" + rm -f "\$1" + return 0 +} +remove_file_list "$list" "false" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"2"* ]] + + [[ ! -e "$f1" ]] + [[ ! -e "$f2" ]] + + local fallback_calls + fallback_calls=$(wc -l < "$trace" | tr -d ' ') + [ "$fallback_calls" -eq 2 ] + grep -qF "mole_delete $f1" "$trace" + grep -qF "mole_delete $f2" "$trace" +} + +@test "_mole_move_to_trash_batch returns 1 when trash CLI is missing under MOLE_TEST_NO_AUTH" { + local f1="$SANDBOX/p.plist" + : > "$f1" + + # Drop MOLE_TEST_TRASH_DIR so we exercise the real helper path; the + # MOLE_TEST_NO_AUTH guard must fail closed before any AppleScript runs. + run bash --noprofile --norc < "$f1" + : > "$f2" + local list + printf -v list '%s\n%s' "$f1" "$f2" + + local batch_count="$SANDBOX/batch_count" + local fallback_count="$SANDBOX/fallback_count" + : > "$batch_count" + : > "$fallback_count" + + run bash --noprofile --norc <> "$batch_count" + return 0 +} +mole_delete() { + printf '%s\n' "\$1" >> "$fallback_count" + rm -f "\$1" + return 0 +} +remove_file_list "$list" "true" +EOF + + [ "$status" -eq 0 ] + + # Sudo path must avoid the batch helper entirely. + [[ ! -s "$batch_count" ]] + + local n + n=$(wc -l < "$fallback_count" | tr -d ' ') + [ "$n" -eq 2 ] +} diff --git a/Resources/mole/tests/uninstall_safety.bats b/Resources/mole/tests/uninstall_safety.bats new file mode 100644 index 0000000..0c5435a --- /dev/null +++ b/Resources/mole/tests/uninstall_safety.bats @@ -0,0 +1,441 @@ +#!/usr/bin/env bats + +# Safety boundary tests for find_app_files() and ByHost cleanup. +# These guard against regressions where uninstalling a developer toolchain +# would silently delete user project source, signing keys, OAuth tokens, +# or other manually-curated data. + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${BATS_TMPDIR:-}" + if [[ -z "$ORIGINAL_HOME" ]]; then + ORIGINAL_HOME="${HOME:-}" + fi + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-safety-home.XXXXXX")" + export HOME +} + +teardown_file() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + # Safety: refuse to operate on a real home directory. + if [[ "$HOME" != "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + printf 'FATAL: HOME is not a test temp dir: %s\n' "$HOME" >&2 + return 1 + fi + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" +} + +@test "find_app_files preserves Android Studio project source and credentials" { + mkdir -p "$HOME/AndroidStudioProjects/my-app" + mkdir -p "$HOME/.android/avd/Pixel_5.avd" + mkdir -p "$HOME/.android/cache" + touch "$HOME/.android/debug.keystore" + touch "$HOME/.android/adbkey" + mkdir -p "$HOME/Library/Android/sdk/platform-tools" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.google.android.studio" "Android Studio" +EOF + )" + + [[ "$result" != *"AndroidStudioProjects"* ]] || { echo "leaked project source"; exit 1; } + [[ "$result" != *"/.android/avd"* ]] || { echo "leaked AVD images"; exit 1; } + [[ "$result" != *"/.android/debug.keystore"* ]] || { echo "leaked signing key"; exit 1; } + [[ "$result" != *"/.android/adbkey"* ]] || { echo "leaked adb key"; exit 1; } + [[ "$result" != *"Library/Android"* ]] || { echo "leaked SDK tree"; exit 1; } + [[ "$result" == *"/.android/cache"* ]] || { echo "missed safe cache subdir"; exit 1; } +} + +@test "find_app_files preserves Docker auth tokens and config" { + mkdir -p "$HOME/.docker" + touch "$HOME/.docker/config.json" + mkdir -p "$HOME/.docker/contexts/meta" + mkdir -p "$HOME/.docker/buildx" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.docker.docker" "Docker" +EOF + )" + + [[ "$result" != *"/.docker/config.json"* ]] || { echo "leaked Docker auth tokens"; exit 1; } + [[ "$result" != *"/.docker/contexts"* ]] || { echo "leaked Docker contexts"; exit 1; } + # An exact-match line for $HOME/.docker would route the entire tree (auth + # tokens, contexts, plugins) to deletion. Walk every line so the assertion + # cannot be silently satisfied. + while IFS= read -r line; do + [[ "$line" == "$HOME/.docker" ]] && { echo "leaked entire ~/.docker tree"; exit 1; } + done <<< "$result" + # Buildx cache is regenerable, safe to clean. + [[ "$result" == *"/.docker/buildx"* ]] || { echo "missed safe buildx cache"; exit 1; } +} + +@test "official uninstaller vendor blocks managed security apps" { + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +official_uninstaller_vendor "com.crowdstrike.falcon.UserAgent" "Falcon" "/Applications/Falcon.app" +official_uninstaller_vendor "com.jamf.management.Jamf" "Jamf Connect" "/Applications/Jamf Connect.app" +EOF + )" + + [[ "$result" == *"CrowdStrike"* ]] || { echo "missed CrowdStrike"; exit 1; } + [[ "$result" == *"Jamf"* ]] || { echo "missed Jamf"; exit 1; } +} + +@test "receipt payload allowlist rejects broad system roots" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +receipt_payload_path_is_allowlisted "/Library/LaunchAgents/com.example.foo.helper.plist" "com.example.foo" +receipt_payload_path_is_allowlisted "/Library/PrivilegedHelperTools/com.example.foo.helper" "com.example.foo" +! receipt_payload_path_is_allowlisted "/Library/Application Support/Foo" "com.example.foo" +! receipt_payload_path_is_allowlisted "/Applications/Foo.app" "com.example.foo" +! receipt_payload_path_is_allowlisted "/usr/local/bin/foo" "com.example.foo" +EOF + + [ "$status" -eq 0 ] +} + +@test "launch plist unload validates path and uses timeout" { + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.foo.plist" + + 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" + +run_with_timeout() { + printf '%s\n' "$*" > "$HOME/launchctl-call.log" + return 0 +} + +unload_launch_plist "$HOME/Library/LaunchAgents/com.example.foo.plist" "false" +grep -q "5 launchctl unload $HOME/Library/LaunchAgents/com.example.foo.plist" "$HOME/launchctl-call.log" +EOF + + [ "$status" -eq 0 ] +} + +@test "login item helper discovery reads embedded helper bundle ids" { + app="$HOME/Applications/Carrier.app" + helper="$app/Contents/Library/LoginItems/Carrier Helper.app/Contents" + mkdir -p "$helper" + cat > "$helper/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.carrier.helper + + +PLIST + + result="$( + 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" +discover_login_item_helper_bundle_ids "$HOME/Applications/Carrier.app" +EOF + )" + + [[ "$result" == "com.example.carrier.helper" ]] +} + +@test "find_app_files preserves Xcode user data and only collects regenerable caches" { + mkdir -p "$HOME/Library/Developer/Xcode/DerivedData/MyApp-abc/Build" + mkdir -p "$HOME/Library/Developer/Xcode/iOS DeviceSupport/17.0" + mkdir -p "$HOME/Library/Developer/Xcode/Archives/2026/03/MyApp.xcarchive" + mkdir -p "$HOME/Library/Developer/Xcode/UserData" + mkdir -p "$HOME/Library/Developer/Toolchains/swift-6.0.xctoolchain" + mkdir -p "$HOME/Library/Developer/CoreSimulator/Devices/abc" + mkdir -p "$HOME/Library/Developer/CoreSimulator/Caches/dyld" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.apple.dt.Xcode" "Xcode" +EOF + )" + + # Bare ~/Library/Developer must never appear, otherwise the whole tree + # (Archives, UserData, Toolchains, Devices) gets routed to deletion. + while IFS= read -r line; do + [[ "$line" == "$HOME/Library/Developer" ]] && { echo "leaked entire Library/Developer"; exit 1; } + done <<< "$result" + + [[ "$result" != *"/Library/Developer/Xcode/Archives"* ]] || { echo "leaked Xcode archives"; exit 1; } + [[ "$result" != *"/Library/Developer/Xcode/UserData"* ]] || { echo "leaked Xcode user data"; exit 1; } + [[ "$result" != *"/Library/Developer/Toolchains"* ]] || { echo "leaked toolchains"; exit 1; } + [[ "$result" != *"/Library/Developer/CoreSimulator/Devices"* ]] || { echo "leaked simulator devices"; exit 1; } + + [[ "$result" == *"/Library/Developer/Xcode/DerivedData"* ]] || { echo "missed DerivedData cache"; exit 1; } + [[ "$result" == *"/Library/Developer/Xcode/iOS DeviceSupport"* ]] || { echo "missed iOS DeviceSupport"; exit 1; } + [[ "$result" == *"/Library/Developer/CoreSimulator/Caches"* ]] || { echo "missed simulator caches"; exit 1; } +} + +@test "find_app_files preserves DevEco project source and Huawei account state" { + mkdir -p "$HOME/DevEcoStudioProjects/my-harmonyos-app" + mkdir -p "$HOME/HarmonyOS/projects" + mkdir -p "$HOME/DevEco-Studio/config" + mkdir -p "$HOME/Library/Application Support/Huawei/IdeaIC/options" + mkdir -p "$HOME/Library/Huawei/SDK" + mkdir -p "$HOME/.huawei/AppGallery" + mkdir -p "$HOME/.ohos/sdk" + mkdir -p "$HOME/Library/Caches/Huawei" + mkdir -p "$HOME/Library/Logs/Huawei" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.huawei.deveco" "DevEco-Studio" +EOF + )" + + [[ "$result" != *"DevEcoStudioProjects"* ]] || { echo "leaked DevEco project source"; exit 1; } + [[ "$result" != *"$HOME/HarmonyOS"* ]] || { echo "leaked HarmonyOS project root"; exit 1; } + [[ "$result" != *"$HOME/DevEco-Studio"* ]] || { echo "leaked DevEco IDE config + license state"; exit 1; } + [[ "$result" != *"Application Support/Huawei"* ]] || { echo "leaked Huawei IDE settings"; exit 1; } + [[ "$result" != *"$HOME/Library/Huawei"* ]] || { echo "leaked Huawei SDK tree"; exit 1; } + [[ "$result" != *"$HOME/.huawei"* ]] || { echo "leaked Huawei account state"; exit 1; } + [[ "$result" != *"$HOME/.ohos"* ]] || { echo "leaked OHOS SDK config"; exit 1; } + [[ "$result" == *"Caches/Huawei"* ]] || { echo "missed Huawei cache"; exit 1; } + [[ "$result" == *"Logs/Huawei"* ]] || { echo "missed Huawei logs"; exit 1; } +} + +@test "find_app_files rejects bundle ids with glob metacharacters" { + # Pre-stage Group Containers and ByHost entries that an over-broad + # wildcard could accidentally pick up. A malformed bundle id like + # "com.foo.*" must not expand into matches against unrelated containers. + mkdir -p "$HOME/Library/Group Containers/group.com.example.real" + mkdir -p "$HOME/Library/Group Containers/group.com.victim.unrelated" + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.real.ABC.plist" + touch "$HOME/Library/Preferences/ByHost/com.victim.unrelated.ABC.plist" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.real.plist" + touch "$HOME/Library/LaunchAgents/com.victim.unrelated.plist" + mkdir -p "$HOME/.ssh" + touch "$HOME/.ssh/id_rsa" + + for bad_id in "com.foo.*" "com.foo.?" "com.foo.[abc]" "../../.ssh/id_rsa" "../etc/passwd" "*"; do + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" BAD_ID="$bad_id" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "$BAD_ID" "FakeApp" +EOF + )" + + [[ "$result" != *"Group Containers/group.com.victim.unrelated"* ]] \ + || { echo "bundle id '$bad_id' over-matched Group Containers"; exit 1; } + [[ "$result" != *"ByHost/com.victim.unrelated"* ]] \ + || { echo "bundle id '$bad_id' over-matched ByHost"; exit 1; } + [[ "$result" != *"LaunchAgents/com.victim.unrelated"* ]] \ + || { echo "bundle id '$bad_id' over-matched LaunchAgents"; exit 1; } + [[ "$result" != *"/.ssh/id_rsa"* ]] \ + || { echo "bundle id '$bad_id' traversed into .ssh"; exit 1; } + done +} + +@test "find_app_files still resolves wildcards for legitimate reverse-DNS bundle ids" { + # Sanity check: the new validation must not regress the common case. + mkdir -p "$HOME/Library/Group Containers/group.com.example.real" + mkdir -p "$HOME/Library/LaunchAgents" + touch "$HOME/Library/LaunchAgents/com.example.real.plist" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.example.real" "RealApp" +EOF + )" + + [[ "$result" == *"Group Containers/group.com.example.real"* ]] \ + || { echo "missed legitimate Group Container match"; exit 1; } + [[ "$result" == *"LaunchAgents/com.example.real.plist"* ]] \ + || { echo "missed legitimate LaunchAgent match"; exit 1; } +} + +@test "find_app_files keeps bundle-id-derived paths on dot boundaries" { + mkdir -p "$HOME/Library/Preferences/ByHost" + mkdir -p "$HOME/Library/Group Containers/group.com.example.TestApp" + mkdir -p "$HOME/Library/Group Containers/group.com.example.TestApplication" + mkdir -p "$HOME/Library/Containers/com.example.TestApp.helper" + mkdir -p "$HOME/Library/Containers/com.example.TestApplication" + mkdir -p "$HOME/Library/Application Scripts/TEAM.com.example.TestApp.Extension" + mkdir -p "$HOME/Library/Application Scripts/TEAM.com.example.TestApplication.Extension" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApplication.ABC123.plist" + + result="$( + HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +find_app_files "com.example.TestApp" "TestApp" +EOF + )" + + [[ "$result" == *"ByHost/com.example.TestApp.ABC123.plist"* ]] || { echo "missed ByHost plist"; exit 1; } + [[ "$result" == *"Group Containers/group.com.example.TestApp"* ]] || { echo "missed group container"; exit 1; } + [[ "$result" == *"Containers/com.example.TestApp.helper"* ]] || { echo "missed helper container"; exit 1; } + [[ "$result" == *"Application Scripts/TEAM.com.example.TestApp.Extension"* ]] || { echo "missed prefixed app script"; exit 1; } + [[ "$result" != *"TestApplication"* ]] || { echo "matched sibling bundle prefix"; printf '%s\n' "$result"; exit 1; } +} + +@test "ByHost cleanup routes through user-mode mole_delete (no sudo prompt)" { + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Applications/TestApp.app" + + 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" + +trace="$HOME/mole_delete.log" +mole_delete() { + printf '%s|%s\n' "$1" "${2:-false}" >> "$trace" + return 0 +} +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/TestApp.app" + +related="$(find_app_files "com.example.TestApp" "TestApp")" +encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') + +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications + +if grep -q "ByHost.*com.example.TestApp.*plist|true" "$trace"; then + echo "ByHost plist routed through sudo mole_delete" + cat "$trace" >&2 + exit 1 +fi + +grep -q "ByHost.*com.example.TestApp.*plist|false" "$trace" +EOF + + [ "$status" -eq 0 ] +} + +@test "malformed bundle ids do not trigger defaults or ByHost side effects" { + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Applications/TestApp.app" + + 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" + +trace="$HOME/side_effects.log" + +defaults() { + printf 'defaults:%s\n' "$*" >> "$trace" + return 0 +} +mole_delete() { + printf 'mole_delete:%s|%s\n' "$1" "${2:-false}" >> "$trace" + return 0 +} +find_app_files() { return 0; } +find_app_system_files() { return 0; } +get_diagnostic_report_paths_for_app() { return 0; } +remove_login_item() { :; } +unregister_app_bundle() { :; } +force_kill_app() { return 0; } +request_sudo_access() { return 0; } +ensure_sudo_session() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +enter_alt_screen() { :; } +leave_alt_screen() { :; } +hide_cursor() { :; } +show_cursor() { :; } +pgrep() { return 1; } +pkill() { return 0; } +sudo() { return 0; } + +for bad_id in "-g" "NSGlobalDomain" "com-example"; do + : > "$trace" + selected_apps=() + selected_apps+=("0|$HOME/Applications/TestApp.app|TestApp|$bad_id|0|Never") + files_cleaned=0 + total_items=0 + total_size_cleaned=0 + + batch_uninstall_applications &2 + return 1 + fi + export TERM="dumb" +} + +teardown() { + if [[ "$HOME" == "${BATS_TEST_DIRNAME}/tmp-"* ]]; then + rm -rf "$HOME" + fi +} + +# Build a sourceable copy of bin/uninstall.sh: rewrites SCRIPT_DIR so library +# sources resolve, and strips the `main "$@"` invocation so we can drive +# scan_applications directly. +sourceable_uninstall_sh() { + local out="$1" + awk -v script_dir="$PROJECT_ROOT/bin" ' + /^SCRIPT_DIR=/ { print "SCRIPT_DIR=\"" script_dir "\""; next } + /^main "\$@"/ { print "# main skipped by test"; next } + { print } + ' "$PROJECT_ROOT/bin/uninstall.sh" > "$out" +} + +create_test_app_bundle() { + local app_path="$1" + local bundle_id="$2" + local display_name="$3" + + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" < + + + + CFBundleIdentifier + $bundle_id + CFBundleName + $display_name + + +PLIST +} + +@test "scan_applications: Pass 2 tolerates empty app_data_tuples on /bin/bash 3.2 (#863)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + mkdir -p "$apps_root/TestApp.app/Contents" + : > "$apps_root/TestApp.app/Contents/Info.plist" + + # Seed the warm metadata cache so that the one discovered app + # (TestApp.app) is a cache hit: matching mtime, non-empty bundle id + # and display name are the conditions the awk classifier and + # use_cached_scan_metadata require for the cached branch to "stick". + app_mtime="$(stat -f %m "$apps_root/TestApp.app")" + cache_dir="$HOME/.cache/mole" + mkdir -p "$cache_dir" + printf '%s|%s|4|0|0|com.test.TestApp|TestApp\n' \ + "$apps_root/TestApp.app" "$app_mtime" \ + > "$cache_dir/uninstall_app_metadata_v1" + + done_marker="$HOME/scan.done" + + # The bug not only emits "unbound variable"; the spinner subshell can + # keep running after the parent script errors out. The user-visible + # symptom is exactly "scanning forever". Mirror the marker-file watchdog + # from the #722 hang test (uninstall.bats: "uninstall_persist_cache_file + # does not hang...") so a regression surfaces as HANG rather than blocking + # the whole bats run. + ( + env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 \ + APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' > "$HOME/scan.out" 2> "$HOME/scan.err" +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +# Restrict the discovered search dirs to our sandboxed Applications folder +# so scan_applications does not pick up real /Applications and dilute the +# all-cached condition we are exercising. +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +# Bundle-id resolution would otherwise call /usr/bin/mdls and reject our +# placeholder Info.plist. The cached branch only needs an echo-through here. +uninstall_resolve_eligible_bundle_id() { printf '%s\n' "${2:-${1##*/}}"; } + +scan_applications > /dev/null +EOF + : > "$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 + + status_msg="" + if [[ ! -e "$done_marker" ]]; then + kill -TERM "$bgpid" 2> /dev/null || true + # Reap the orphaned spinner subshell so it does not leak into the + # next test or the rest of the run. + pkill -P "$bgpid" 2> /dev/null || true + status_msg="HANG" + fi + wait "$bgpid" 2> /dev/null || true + + [[ -z "$status_msg" ]] || { + echo "scan_applications hung — Pass 2 guard regressed" >&2 + echo "stderr captured:" >&2 + cat "$HOME/scan.err" >&2 2> /dev/null || true + false + } + # Use `run` + status check rather than bare `! grep`: bats SC2314 rejects + # a trailing `!` because earlier bats versions ignored it. `run` records + # the inverted status explicitly so the assertion is portable. + run grep -q 'unbound variable' "$HOME/scan.err" + [ "$status" -ne 0 ] +} + +@test "scan_applications surfaces inline app size before deferred refresh (#1126)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/SizedApp.app" + create_test_app_bundle "$app_path" "com.example.SizedApp" "SizedApp" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC=0 \ + MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC=0 \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } +mdls() { + if [[ "${2:-}" == "kMDItemLogicalSize" ]]; then + printf '4096\n' + return 0 + fi + printf '(null)\n' +} + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|SizedApp|com.example.SizedApp|4KB|"* ]] + [[ "$output" == *"|4" ]] +} + +@test "scan_applications includes Artpaper's two-segment bundle id (#861)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/Artpaper.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + andriiliakh.Artpaper + CFBundleName + Artpaper + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|Artpaper|andriiliakh.Artpaper|"* ]] +} + +@test "scan_applications includes top-level OneDrive even when background-only (#970)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/OneDrive.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.microsoft.OneDrive-mac + CFBundleName + OneDrive + LSBackgroundOnly + + + +PLIST + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|OneDrive|com.microsoft.OneDrive-mac|"* ]] +} + +@test "scan_applications dedupes backup Applications clones by bundle id (#975)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + backup_root="$HOME/BackupClone/Applications" + local_app="$apps_root/Dupe.app" + backup_app="$backup_root/Dupe.app" + create_test_app_bundle "$local_app" "com.example.Dupe" "Dupe" + create_test_app_bundle "$backup_app" "com.example.Dupe" "Dupe" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" BACKUP_ROOT="$backup_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT" "$BACKUP_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$local_app|Dupe|com.example.Dupe|"* ]] + [[ "$output" != *"|$backup_app|Dupe|com.example.Dupe|"* ]] +} + +@test "scan_applications keeps unique apps from backup Applications roots (#975)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + backup_root="$HOME/BackupClone/Applications" + backup_app="$backup_root/OnlyThere.app" + create_test_app_bundle "$backup_app" "com.example.OnlyThere" "OnlyThere" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 BACKUP_ROOT="$backup_root" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$BACKUP_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$backup_app|OnlyThere|com.example.OnlyThere|"* ]] +} + +@test "scan_applications keeps original rows when dedupe pass fails (#975)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" SRC_PATH="$src" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +scan_raw_file="$HOME/scan.raw" +printf '%s\n' "$HOME/Applications/Keep.app|Keep|com.example.Keep|1" > "$scan_raw_file" + +awk() { return 2; } + +_scan_dedupe_bundle_ids +cat "$scan_raw_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == "$HOME/Applications/Keep.app|Keep|com.example.Keep|1" ]] +} + +@test "scan_applications ignores PATH stat shims (#865)" { + src="$HOME/uninstall_source.sh" + sourceable_uninstall_sh "$src" + + apps_root="$HOME/Applications" + app_path="$apps_root/Plain.app" + mkdir -p "$app_path/Contents" + cat > "$app_path/Contents/Info.plist" <<'PLIST' + + + + + CFBundleIdentifier + com.example.Plain + CFBundleName + Plain + + +PLIST + + stub_dir="$HOME/stub-bin" + mkdir -p "$stub_dir" + cat > "$stub_dir/stat" <<'SH' +#!/bin/sh +exit 64 +SH + chmod +x "$stub_dir/stat" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" \ + MOLE_TEST_NO_AUTH=1 APPS_ROOT="$apps_root" SRC_PATH="$src" \ + PATH="$stub_dir:$PATH" \ + /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail + +# shellcheck source=/dev/null +source "$SRC_PATH" + +uninstall_print_app_search_dirs() { printf '%s\n' "$APPS_ROOT"; } + +apps_file=$(scan_applications) +cat "$apps_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"|$app_path|Plain|com.example.Plain|"* ]] +} diff --git a/Resources/mole/tests/update.bats b/Resources/mole/tests/update.bats index 19954eb..e4ea81a 100644 --- a/Resources/mole/tests/update.bats +++ b/Resources/mole/tests/update.bats @@ -1,647 +1,275 @@ #!/usr/bin/env bats setup_file() { - PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" - export PROJECT_ROOT - - CURRENT_VERSION="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\\(.*\\)\"/\\1/')" - export CURRENT_VERSION - - ORIGINAL_HOME="${HOME:-}" - export ORIGINAL_HOME - - HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-manager.XXXXXX")" - export HOME - - mkdir -p "${HOME}/.cache/mole" -} - -teardown_file() { - rm -rf "$HOME" - if [[ -n "${ORIGINAL_HOME:-}" ]]; then - export HOME="$ORIGINAL_HOME" - fi + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT } setup() { - BREW_OUTDATED_COUNT=0 - BREW_FORMULA_OUTDATED_COUNT=0 - BREW_CASK_OUTDATED_COUNT=0 - APPSTORE_UPDATE_COUNT=0 - MACOS_UPDATE_AVAILABLE=false - MOLE_UPDATE_AVAILABLE=false - - export MOCK_BIN_DIR="$BATS_TMPDIR/mole-mocks-$$" - mkdir -p "$MOCK_BIN_DIR" - export PATH="$MOCK_BIN_DIR:$PATH" + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-home.XXXXXX")" + TEST_ROOT="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-case.XXXXXX")" + export HOME TEST_ROOT } teardown() { - rm -rf "$MOCK_BIN_DIR" -} - -read_key() { - echo "ESC" - return 0 -} - -@test "ask_for_updates returns 1 when no updates available" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=0 -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=false -MOLE_UPDATE_AVAILABLE=false -ask_for_updates -EOF - - [ "$status" -eq 1 ] -} - -@test "ask_for_updates shows updates and waits for input" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=5 -BREW_FORMULA_OUTDATED_COUNT=3 -BREW_CASK_OUTDATED_COUNT=2 -APPSTORE_UPDATE_COUNT=1 -MACOS_UPDATE_AVAILABLE=true -MOLE_UPDATE_AVAILABLE=true - -read_key() { echo "ESC"; return 0; } - -ask_for_updates -EOF - - [ "$status" -eq 1 ] # ESC cancels - [[ "$output" == *"Update Mole now?"* ]] - [[ "$output" == *"Run "* ]] - [[ "$output" == *"brew upgrade"* ]] - [[ "$output" == *"Software Update"* ]] - [[ "$output" == *"App Store"* ]] - [[ "$output" != *"AVAILABLE UPDATES"* ]] -} - -@test "ask_for_updates with only macOS update shows settings hint without brew hint" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=0 -BREW_FORMULA_OUTDATED_COUNT=0 -BREW_CASK_OUTDATED_COUNT=0 -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=true -MOLE_UPDATE_AVAILABLE=false -ask_for_updates -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"Software Update"* ]] - [[ "$output" != *"brew upgrade"* ]] - [[ "$output" != *"AVAILABLE UPDATES"* ]] -} - -@test "ask_for_updates accepts Enter when updates exist" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=2 -BREW_FORMULA_OUTDATED_COUNT=2 -MOLE_UPDATE_AVAILABLE=true -read_key() { echo "ENTER"; return 0; } -ask_for_updates -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update Mole now?"* ]] - [[ "$output" == *"yes"* ]] -} - -@test "ask_for_updates auto-detects brew updates when counts are unset" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail - -cat > "$MOCK_BIN_DIR/brew" <<'SCRIPT' + case "${HOME:-}" in + "${BATS_TEST_DIRNAME}/tmp-update-home."*) rm -rf "$HOME" ;; + esac + case "${TEST_ROOT:-}" in + "${BATS_TEST_DIRNAME}/tmp-update-case."*) rm -rf "$TEST_ROOT" ;; + esac +} + +make_manual_mole_install() { + local install_dir="$1" + local config_dir="$2" + local version="$3" + mkdir -p "$install_dir" "$config_dir" + sed \ + -e "s|^SCRIPT_DIR=.*|SCRIPT_DIR=\"$config_dir\"|" \ + -e "s/^VERSION=\".*\"$/VERSION=\"$version\"/" \ + "$PROJECT_ROOT/mole" > "$install_dir/mole" + cp "$PROJECT_ROOT/mo" "$install_dir/mo" + cp -R "$PROJECT_ROOT/lib" "$config_dir/lib" + chmod +x "$install_dir/mole" "$install_dir/mo" +} + +make_homebrew_shadow() { + local bin_dir="$1" + local cellar_mole="$2" + mkdir -p "$bin_dir" "$(dirname "$cellar_mole")" + cp "$PROJECT_ROOT/mole" "$cellar_mole" + cp -R "$PROJECT_ROOT/lib" "$bin_dir/lib" + chmod +x "$cellar_mole" + ln -sf "$cellar_mole" "$bin_dir/mole" + ln -sf "$cellar_mole" "$bin_dir/mo" + + cat > "$bin_dir/brew" <<'SCRIPT' #!/usr/bin/env bash -if [[ "$1" == "outdated" && "$2" == "--formula" && "$3" == "--quiet" ]]; then - printf "wget\njq\n" - exit 0 -fi -if [[ "$1" == "outdated" && "$2" == "--cask" && "$3" == "--quiet" ]]; then - printf "iterm2\n" - exit 0 -fi +printf '%s\n' "$*" >> "$BREW_LOG" +case "${1:-}" in + list) + if [[ "${2:-}" == "--versions" ]]; then + printf 'mole 9.9.9\n' + fi + exit 0 + ;; + update | upgrade) + exit 0 + ;; +esac exit 0 SCRIPT -chmod +x "$MOCK_BIN_DIR/brew" - -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -unset BREW_OUTDATED_COUNT BREW_FORMULA_OUTDATED_COUNT BREW_CASK_OUTDATED_COUNT -APPSTORE_UPDATE_COUNT=0 -MACOS_UPDATE_AVAILABLE=false -MOLE_UPDATE_AVAILABLE=false - -set +e -ask_for_updates -ask_status=$? -set -e - -echo "COUNTS:${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" -exit "$ask_status" -EOF - - [ "$status" -eq 1 ] - [[ "$output" == *"brew upgrade"* ]] - [[ "$output" == *"COUNTS:3:2:1"* ]] - [[ "$output" != *"AVAILABLE UPDATES"* ]] -} - -@test "format_brew_update_label lists formula and cask counts" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" -BREW_OUTDATED_COUNT=5 -BREW_FORMULA_OUTDATED_COUNT=3 -BREW_CASK_OUTDATED_COUNT=2 -format_brew_update_label -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"3 formula"* ]] - [[ "$output" == *"2 cask"* ]] + chmod +x "$bin_dir/brew" } -@test "perform_updates handles Homebrew success and Mole update" { - run bash --noprofile --norc <<'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/manage/update.sh" - -BREW_FORMULA_OUTDATED_COUNT=1 -BREW_CASK_OUTDATED_COUNT=0 -MOLE_UPDATE_AVAILABLE=true - -FAKE_DIR="$HOME/fake-script-dir" -mkdir -p "$FAKE_DIR/lib/manage" -cat > "$FAKE_DIR/mole" <<'SCRIPT' +make_update_curl_stub() { + local bin_dir="$1" + local latest_version="$2" + cat > "$bin_dir/curl" <