diff --git a/.github/workflow/workflow.yml b/.github/workflows/ci.yml similarity index 63% rename from .github/workflow/workflow.yml rename to .github/workflows/ci.yml index 12470b5..1e9d715 100644 --- a/.github/workflow/workflow.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: branches: [main, develop] workflow_dispatch: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -15,8 +18,13 @@ jobs: lint: name: Lint & Type Check runs-on: macos-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false - name: Set up Python 3.11 uses: actions/setup-python@v5 @@ -25,25 +33,17 @@ jobs: cache: pip - name: Install dev dependencies - run: pip install ruff mypy types-PyYAML - - - name: Ruff lint - run: ruff check src/ tests/ - - - name: Ruff format check - run: ruff format --check src/ tests/ - - - name: Mypy type check - run: mypy src/ --ignore-missing-imports + run: pip install --upgrade pip && pip install ruff mypy types-PyYAML test: name: Test / Python ${{ matrix.python-version }} / ${{ matrix.os }} needs: lint runs-on: ${{ matrix.os }} + timeout-minutes: 40 strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] os: [macos-13, macos-14, macos-latest] exclude: # Python 3.9 not available on macos-14 (arm64 runner) @@ -51,7 +51,11 @@ jobs: python-version: "3.9" steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -59,40 +63,31 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - name: Install package (editable) + dev extras - run: pip install -e ".[dev]" - - - name: Run pytest with coverage + - name: CI install + smoke test (venv) run: | - pytest tests/ \ - --tb=short \ - --cov=src \ - --cov-report=term-missing \ - --cov-report=xml \ - -v - - - name: Upload coverage to Codecov - if: matrix.python-version == '3.11' && matrix.os == 'macos-latest' - uses: codecov/codecov-action@v4 - with: - files: coverage.xml - flags: unittests - fail_ci_if_error: false + bash scripts/build.sh venv + bash scripts/build.sh test build-check: name: Build Distribution Check needs: test runs-on: macos-latest + timeout-minutes: 25 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false - name: Set up Python 3.11 uses: actions/setup-python@v5 with: python-version: "3.11" + cache: pip - name: Install build tools - run: pip install build twine + run: pip install --upgrade pip && pip install build twine - name: Build wheel + sdist run: python -m build @@ -111,4 +106,5 @@ jobs: with: name: dist-packages path: dist/ - retention-days: 7 \ No newline at end of file + retention-days: 7 + diff --git a/CHANGELOG.md b/CHANGELOG.md index 663d984..b71b04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,37 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. ## Unreleased + +## v1.5.0 (2026-05-12) ### Added -- Debug logging flags (`--verbose`, `--log-file`) with file rotation -- Basic test coverage for utilities and matching +#### P0 (baseline UX and safety) +- [x] Global --dry-run flag (src/core/dry_run.py) +- [x] Shell completion command (src/core/completions.py) +- [x] Full app uninstaller (src/core/uninstaller.py) + +#### P1 (highest demand data and visibility) +- [x] Browser data cleaner (src/scanners/browser_data.py) +- [x] Visual disk space map (src/scanners/space_map.py) +- [x] Photo library analyzer (src/scanners/photos_analyzer.py) +- [x] iOS simulator deep cleaner (src/scanners/simulators.py) + +#### P2 (system utilities and maintenance) +- [x] Memory pressure reliever (src/core/memory_pressure.py) +- [x] Homebrew deep manager (src/core/brew_manager.py) +- [x] Storage trend tracker (src/reporting/storage_trend.py) +- [x] Recent files and activity cleaner (src/scanners/recent_activity.py) + +#### P3 (advanced and higher risk features) +- [x] Permissions auditor (src/core/permissions_auditor.py) +- [x] APFS snapshot guard (src/core/apfs_snapshots.py) +- [x] Menu bar companion (src/core/menubar.py) +- [x] Data breach monitor (src/core/breach_monitor.py) +- [x] Cloud storage junk scanner (src/scanners/cloud_junk.py) ### Changed +- CLI wiring for new P0/P1 commands and dry-run behavior +- Module layout aligned to core/scanners (removed features package) +- README, checklist, and roadmap paths updated for the new layout - Improved error handling with debug logs across filesystem and subprocess paths - Dev junk scanner traversal now uses a deque for better performance @@ -21,7 +47,7 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. ### Changed - Live dashboard now shows top findings and dev junk totals - Scan history schema extended with developer junk totals -- Version bump to v1.2.0 across docs and UI +- Version bump to v1.5.0 across docs and UI ## v1.0.0 (2026-05-10) ### Added diff --git a/README.md b/README.md index da3933f..4476eee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Mac Deep Cleaner v1.2.0 +# Mac Deep Cleaner v1.5.0 **Professional macOS cleanup tool — Smart App Orphan Detector** @@ -23,6 +23,12 @@ mdc scan - **Duplicate finder** — SHA-256 content hashing, two-phase (head + full), sorted by wasted space - **Large file scanner** — finds files ≥100 MB (configurable), categorised by type - **Broken symlink detector** — walks `/usr/local`, `/opt/homebrew`, `~/bin`, etc. +- **Browser data cleaner** — cache, cookies, history, sessions (opt-in delete) +- **Space map** — disk usage overview by folder tree +- **Photos analyzer** — summaries of Photos libraries and originals +- **iOS simulator cleaner** — shows simulator sizes and can purge +- **Full app uninstaller** — remove app bundle plus known data +- **Shell completions** — bash, zsh, fish - **iOS backup finder** — parses `MobileSync/Backup` manifests, shows device/age/size - **Language pack stripper** — detects removable `.lproj` dirs in every installed app - **Universal binary thinner** — uses `ditto --arch` safely; creates `.fat_backup` by default @@ -32,6 +38,15 @@ mdc scan - **Diff** — compare any two scans to see what's new or resolved - **HTML report** — self-contained with Chart.js doughnut + collapsible sections - **System inspector** — LaunchAgents, LaunchDaemons, login items, SIP status +- **Memory pressure reliever** — reports pressure, optional cache purge +- **Homebrew manager** — cache sizes, outdated list, cleanup and autoremove +- **Storage trend tracker** — snapshots disk usage over time +- **Recent activity cleaner** — scans recent-items files (safe clear) +- **Permissions auditor** — TCC privacy access audit (read-only) +- **APFS snapshot guard** — list and prune local snapshots +- **Menu bar companion** — SwiftBar/xbar plugin for last scan summary +- **Data breach monitor** — checks emails via HIBP API (opt-in) +- **Cloud storage junk** — scans Dropbox/Drive/OneDrive/Box caches - **Scheduler** — installs a LaunchAgent for weekly auto-scans - **macOS notifications** — via `osascript`, no dependencies - **CI mode** — JSON-only scan summary with threshold-based exit code @@ -84,6 +99,9 @@ mac-cleaner clean # Auto-delete everything detected mac-cleaner clean --auto +# Force preview mode (no deletes anywhere) +mac-cleaner --dry-run clean + # Permanently delete (skip undo staging) mac-cleaner clean --no-undo @@ -125,6 +143,26 @@ mac-cleaner scan --log-file ~/mac-cleaner.log ### New scanners ```bash +# Shell completions +mac-cleaner completions --shell zsh --instructions + +# Full app uninstall +mac-cleaner uninstall "Slack" + +# Browser data cleanup +mac-cleaner browser-data +mac-cleaner browser-data --browser chrome --category cache --clean + +# Disk usage map +mac-cleaner space-map --depth 2 --limit 12 + +# Photos library analyzer +mac-cleaner photos --details + +# iOS simulator cleaner +mac-cleaner simulators +mac-cleaner simulators --purge-unavailable --yes + # Find duplicate files (default: ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures) mac-cleaner duplicates mac-cleaner duplicates --path ~/Movies --min-size 500 @@ -189,6 +227,44 @@ mac-cleaner system --login-items mac-cleaner system --health ``` +### P2/P3 system utilities +```bash +# Memory pressure +mac-cleaner memory-pressure +mac-cleaner memory-pressure --relieve + +# Homebrew manager +mac-cleaner brew --outdated +mac-cleaner brew --cleanup --yes + +# Storage trend snapshots +mac-cleaner storage-trend --record +mac-cleaner storage-trend --days 7 + +# Recent activity cleanup (Recent Items folder only) +mac-cleaner recent-activity +mac-cleaner recent-activity --clear + +# Permissions audit (TCC) +mac-cleaner permissions +mac-cleaner permissions --system --export tcc.json + +# APFS snapshots +mac-cleaner snapshots +mac-cleaner snapshots --delete-older-than 14 --yes + +# Menu bar companion +mac-cleaner menubar install --interval 15 +mac-cleaner menubar status --format swiftbar + +# Breach monitor (HIBP) +mac-cleaner breach --email you@example.com --api-key $HIBP_API_KEY + +# Cloud storage junk +mac-cleaner cloud-junk +mac-cleaner cloud-junk --provider dropbox --clean +``` + ### Scheduler ```bash mac-cleaner schedule install diff --git a/SECURITY.md b/SECURITY.md index 034e848..20943ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,31 @@ # Security Policy -## Supported Versions +## Reporting a Vulnerability -Use this section to tell people about which versions of your project are -currently being supported with security updates. +### Security contact +For security issues, please report privately by opening a GitHub issue on the project with the label `security` (or describing it as a security report in the issue title). -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | +**Where to report:** +- https://github.com/NK2552003/Mac-Cleaner/issues -## Reporting a Vulnerability +### What to include +When reporting, please include: +- A clear description of the vulnerability +- Steps to reproduce (if applicable) +- Expected vs actual behavior +- Impact (e.g., data exposure, privilege escalation, remote code execution) +- Affected versions (if known) + +### Disclosure expectations +- We will acknowledge receipt of your report as soon as possible (typically within **2–3 business days**). +- We will provide a response on whether the report is accepted and what the remediation plan/timeline looks like. +- If accepted, we’ll work to release a fix in a subsequent release for the supported versions listed above. + +### Responsible disclosure +- Do **not** publish exploit details publicly until a fix is released (or we confirm the issue is non-actionable). +- Do **not** attempt to cause real-world damage. +- Avoid including sensitive personal data or credentials in the report. -Use this section to tell people how to report a vulnerability. +### If you can’t use GitHub issues +If GitHub issues are not appropriate, add a note in your report explaining why and include as much of the above information as possible. -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/checklist.md b/checklist.md index d1a170b..0d2e7c0 100644 --- a/checklist.md +++ b/checklist.md @@ -2,45 +2,19 @@ Date: 2026-05-11 -Use this file as the execution order. Check items only after the feature is fully implemented, wired to CLI, and covered by tests when feasible. - -## P0 (baseline UX and safety) -- [ ] Global --dry-run flag (src/features/ux/dry_run.py) -- [ ] Shell completion command (src/features/ux/completions.py) -- [ ] Full app uninstaller (src/features/apps/uninstaller.py) - -## P1 (highest demand data and visibility) -- [ ] Browser data cleaner (src/features/privacy/browser_data.py) -- [ ] Visual disk space map (src/features/storage/space_map.py) -- [ ] Photo library analyzer (src/features/storage/photos_analyzer.py) -- [ ] iOS simulator deep cleaner (src/features/dev/simulators.py) - -## P2 (system utilities and maintenance) -- [ ] Memory pressure reliever (src/features/system/memory_pressure.py) -- [ ] Homebrew deep manager (src/features/apps/brew_manager.py) -- [ ] Storage trend tracker (src/features/reporting/storage_trend.py) -- [ ] Recent files and activity cleaner (src/features/privacy/recent_activity.py) - -## P3 (advanced and higher risk features) -- [ ] Permissions auditor (src/features/security/permissions_auditor.py) -- [ ] APFS snapshot guard (src/features/safety/apfs_snapshots.py) -- [ ] Menu bar companion (src/features/ux/menubar.py) -- [ ] Data breach monitor (src/features/security/breach_monitor.py) -- [ ] Cloud storage junk scanner (src/features/storage/cloud_junk.py) - ## Additional (not yet scheduled) -- [ ] Purgeable space reclaimer (src/features/storage/purgeable.py) -- [ ] Installer and PKG file hunter (src/features/storage/installer_hunter.py) -- [ ] DNS cache flush (src/features/system/dns_cache.py) -- [ ] Font cache rebuild (src/features/system/font_cache.py) -- [ ] Spotlight re-index (src/features/system/spotlight.py) -- [ ] Sleep and power optimizer (src/features/system/power_optimizer.py) -- [ ] App update checker (src/features/apps/update_checker.py) -- [ ] PKG receipt manager (src/features/apps/pkg_receipts.py) -- [ ] Xcode derived data cleaner (src/features/dev/xcode_cleaner.py) -- [ ] Weekly digest report (src/features/reporting/weekly_digest.py) -- [ ] Cleaning impact score (src/features/reporting/impact_score.py) -- [ ] Interactive TUI app picker (src/features/ux/tui_picker.py) -- [ ] Multi-Mac config sync (src/features/ux/config_sync.py) -- [ ] Time Machine backup guard (src/features/safety/time_machine_guard.py) -- [ ] Restore checksum verification (src/features/safety/restore_checksums.py) +- [ ] Purgeable space reclaimer (src/scanners/purgeable.py) +- [ ] Installer and PKG file hunter (src/scanners/installer_hunter.py) +- [ ] DNS cache flush (src/core/dns_cache.py) +- [ ] Font cache rebuild (src/core/font_cache.py) +- [ ] Spotlight re-index (src/core/spotlight.py) +- [ ] Sleep and power optimizer (src/core/power_optimizer.py) +- [ ] App update checker (src/core/update_checker.py) +- [ ] PKG receipt manager (src/core/pkg_receipts.py) +- [ ] Xcode derived data cleaner (src/scanners/xcode_cleaner.py) +- [ ] Weekly digest report (src/reporting/weekly_digest.py) +- [ ] Cleaning impact score (src/reporting/impact_score.py) +- [ ] Interactive TUI app picker (src/core/tui_picker.py) +- [ ] Multi-Mac config sync (src/core/config_sync.py) +- [ ] Time Machine backup guard (src/core/time_machine_guard.py) +- [ ] Restore checksum verification (src/core/restore_checksums.py) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f0911bd..9386be4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,4 +1,4 @@ -# Mac Deep Cleaner — Features (v1.2.0) +# Mac Deep Cleaner — Features (v1.5.0) **Mac Deep Cleaner** is a professional macOS cleanup tool that safely detects and helps you remove leftover data from uninstalled apps, along with general junk that accumulates over time. It is designed to be **safe by default**, with **preview-first** workflows and **undo/restore support**. diff --git a/docs/PYPI_PUBLISHING.md b/docs/PYPI_PUBLISHING.md index 0d2e400..5c3bd45 100644 --- a/docs/PYPI_PUBLISHING.md +++ b/docs/PYPI_PUBLISHING.md @@ -1,4 +1,4 @@ -# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v1.2.0) +# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v1.5.0) ## What you’ll publish This project is configured to build with `setuptools` from `pyproject.toml` (PEP 621). The package name is: @@ -50,8 +50,8 @@ pip install mac-deep-cleaner==1.2.0 If you want to keep credentials out of shell history: - Use your CI secret store to set `TWINE_USERNAME` and `TWINE_PASSWORD`. -## Quick checklist for v1.2.0 +## Quick checklist for v1.5.0 - `pyproject.toml` → `project.version = "1.2.0"` -- `README.md` / docs reflect v1.2.0 +- `README.md` / docs reflect v1.5.0 - `python3 -m build` produces valid wheel + sdist - `twine upload dist/*` succeeds diff --git a/pyproject.toml b/pyproject.toml index 82a2739..22bce5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mac-deep-cleaner" -version = "1.2.0" +version = "1.5.0" description = "Professional Mac cleanup tool — Smart App Orphan Detector" readme = "README.md" license = "Apache-2.0" @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: System :: Systems Administration", "Topic :: Utilities", ] diff --git a/roadmap.md b/roadmap.md index 1b834e4..b6da033 100644 --- a/roadmap.md +++ b/roadmap.md @@ -10,79 +10,6 @@ Date: 2026-05-11 ## Proposed Feature Modules (one per feature) -Privacy and security -- src/features/privacy/browser_data.py -- src/features/privacy/recent_activity.py -- src/features/security/breach_monitor.py -- src/features/security/permissions_auditor.py - -Storage intelligence -- src/features/storage/space_map.py -- src/features/storage/purgeable.py -- src/features/storage/cloud_junk.py -- src/features/storage/photos_analyzer.py -- src/features/storage/installer_hunter.py - -Performance and system -- src/features/system/memory_pressure.py -- src/features/system/dns_cache.py -- src/features/system/font_cache.py -- src/features/system/spotlight.py -- src/features/system/power_optimizer.py - -Application management -- src/features/apps/uninstaller.py -- src/features/apps/update_checker.py -- src/features/apps/brew_manager.py -- src/features/apps/pkg_receipts.py - -Simulation and development -- src/features/dev/simulators.py -- src/features/dev/xcode_cleaner.py - -Reporting and insights -- src/features/reporting/weekly_digest.py -- src/features/reporting/storage_trend.py -- src/features/reporting/impact_score.py - -UX and workflow -- src/features/ux/completions.py -- src/features/ux/tui_picker.py -- src/features/ux/dry_run.py -- src/features/ux/config_sync.py -- src/features/ux/menubar.py - -Safety enhancements -- src/features/safety/time_machine_guard.py -- src/features/safety/apfs_snapshots.py -- src/features/safety/restore_checksums.py - -## Phases and Order - -P0 (baseline UX and safety) -- Global --dry-run flag (ux/dry_run) -- Shell completion command (ux/completions) -- Full app uninstaller (apps/uninstaller) - -P1 (highest demand data and visibility) -- Browser data cleaner (privacy/browser_data) -- Visual disk space map (storage/space_map) -- Photo library analyzer (storage/photos_analyzer) -- iOS simulator deep cleaner (dev/simulators) - -P2 (system utilities and maintenance) -- Memory pressure reliever (system/memory_pressure) -- Homebrew deep manager (apps/brew_manager) -- Storage trend tracker (reporting/storage_trend) -- Recent files and activity cleaner (privacy/recent_activity) - -P3 (advanced and higher risk features) -- Permissions auditor (security/permissions_auditor) -- APFS snapshot guard (safety/apfs_snapshots) -- Menu bar companion (ux/menubar) -- Data breach monitor (security/breach_monitor) -- Cloud storage junk scanner (storage/cloud_junk) - ## Cross-cutting Work - Add new CLI subcommands and options in src/cli.py - Extend config schema in src/config/config.py for new features diff --git a/scripts/build.sh b/scripts/build.sh index 1b691ec..809aef3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# Mac Deep Cleaner v1.2.0 — Build & Install Script +# Mac Deep Cleaner v1.5.0 — Build & Install Script # ============================================================================= # Usage: # bash build.sh → default: build wheel + sdist diff --git a/src/__init__.py b/src/__init__.py index 71bd087..12f1817 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Professional Edition +Mac Deep Cleaner v1.5.0 — Professional Edition Smart App Orphan Detector & System Cleanup Tool for macOS """ diff --git a/src/cli.py b/src/cli.py index 3b5d6c8..8b3c0b5 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Mac Deep Cleaner v1.2.0 — CLI Entry Point +Mac Deep Cleaner v1.5.0 — CLI Entry Point ======================================= All subcommands, new and updated. @@ -9,6 +9,12 @@ scan Preview scan (orphans + junk) — safe clean Interactive or auto cleanup info Safety guarantees + completions Generate shell completion scripts + uninstall Full app uninstaller + browser-data Clean browser caches/history/cookies + space-map Visual disk space map + photos Photo library analyzer + simulators iOS simulator deep cleaner duplicates Find duplicate files by hash large-files Find files over a size threshold symlinks Find broken symbolic links @@ -18,6 +24,15 @@ history Show past scan records diff Compare two scans system Launch items + SIP + login items + memory-pressure Inspect memory pressure, optional cache purge + brew Homebrew manager (cache + cleanup) + storage-trend Storage usage trend tracker + recent-activity Recent files/activity scanner + permissions Audit macOS privacy permissions (TCC) + snapshots APFS local snapshot guard + menubar Menu bar companion (SwiftBar/xbar) + breach Data breach monitor (HIBP API) + cloud-junk Cloud storage cache/log scanner schedule Install / remove / status of weekly scan update Check for and apply upgrades config Show / init config file @@ -141,13 +156,22 @@ def _ensure_first_run_profile(profile: Optional[str], ci: bool) -> Optional[str] help="Enable debug logging to file.") @click.option("--log-file", type=click.Path(), default=None, help="Write logs to a file (default: ~/.config/mac-cleaner/mac-cleaner.log).") +@click.option("--dry-run", is_flag=True, default=False, + help="Do not modify anything (disables deletions and writes).") @click.pass_context -def main(ctx: click.Context, verbose: bool, log_file: Optional[str]) -> None: - """Mac Deep Cleaner v1.2.0 — Professional macOS cleanup tool.""" +def main( + ctx: click.Context, + verbose: bool, + log_file: Optional[str], + dry_run: bool, +) -> None: + """Mac Deep Cleaner v1.5.0 — Professional macOS cleanup tool.""" + from core.dry_run import set_dry_run configure_logging( verbose=verbose, log_file=Path(log_file) if log_file else None, ) + set_dry_run(ctx, dry_run) if ctx.invoked_subcommand is None: ctx.invoke(scan) @@ -437,7 +461,9 @@ def render() -> Layout: @click.option("--notify", is_flag=True, default=False) @click.option("--no-undo", is_flag=True, default=False, help="Permanently delete instead of staging for undo.") +@click.pass_context def clean( + ctx: click.Context, auto: bool, skip_junk: bool, whitelist: Tuple[str, ...], @@ -457,6 +483,7 @@ def clean( and can be restored with: mac-cleaner undo Pass --no-undo to permanently delete (faster, no recovery). """ + from core.dry_run import dry_run_enabled profile = _ensure_first_run_profile(profile=profile, ci=False) cfg = load_config(profile=profile) wl = cfg.whitelist_set | { @@ -465,9 +492,12 @@ def clean( cfg.custom_scan_roots.extend(Path(p).expanduser().resolve() for p in custom_roots) cfg.dev_junk_roots.extend(Path(p).expanduser().resolve() for p in dev_roots) undo_mode = cfg.undo_mode and not no_undo + dry_run = dry_run_enabled(ctx) + if dry_run: + console.print("[yellow]Dry-run enabled; clean will run in preview mode.[/yellow]") _run( - delete=True, + delete=not dry_run, auto=auto, skip_junk=skip_junk, export_path=export_path, @@ -511,6 +541,441 @@ def info() -> None: console.print() +# ══════════════════════════════════════════════════════════════════════════════ +# COMPLETIONS +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("completions") +@click.option("--shell", default=None, + type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False), + help="Shell type (bash, zsh, fish).") +@click.option("--instructions", is_flag=True, default=False, + help="Show install instructions for your shell.") +def cmd_completions(shell: Optional[str], instructions: bool) -> None: + """Generate shell completion scripts.""" + from core.completions import completion_script, detect_shell, install_instructions + + resolved_shell = (shell or detect_shell()).lower() + script = completion_script(resolved_shell, "mac-cleaner", main) + console.print(script) + if instructions: + console.print() + console.print(install_instructions(resolved_shell, "mac-cleaner")) + + +# ══════════════════════════════════════════════════════════════════════════════ +# UNINSTALL +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("uninstall") +@click.argument("app_query") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation and uninstall immediately.") +@click.option("--no-undo", is_flag=True, default=False, + help="Permanently delete instead of staging for undo.") +@click.option("--keep-preferences", is_flag=True, default=False, + help="Keep Preferences and Saved State data.") +@click.option("--force", is_flag=True, default=False, + help="Allow uninstall even if the app appears to be running.") +@click.pass_context +def cmd_uninstall( + ctx: click.Context, + app_query: str, + yes: bool, + no_undo: bool, + keep_preferences: bool, + force: bool, +) -> None: + """Remove an app and its data (full uninstall).""" + from core.uninstaller import build_uninstall_plan, execute_uninstall, find_app_candidates + from core.dry_run import dry_run_enabled + + apps = discover_installed_apps() + matches = find_app_candidates(app_query, apps) + + if not matches: + console.print(f"[yellow]No installed app matched '{app_query}'.[/yellow]") + return + + app = matches[0] + if len(matches) > 1: + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("#", style="dim", width=4, justify="right") + table.add_column("App") + table.add_column("Bundle ID", style="dim") + table.add_column("Path", style="dim") + for i, a in enumerate(matches, 1): + table.add_row(str(i), a.name, a.bundle_id, str(a.path)) + console.print(table) + choice = click.prompt("Select app", type=click.IntRange(1, len(matches))) + app = matches[choice - 1] + + running = running_bundle_ids() + if app.bundle_id.lower() in running and not force: + console.print( + "[yellow]App appears to be running. Quit it or pass --force to continue.[/yellow]" + ) + return + + cfg = load_config() + plan = build_uninstall_plan( + app=app, + whitelist_set=cfg.whitelist_set, + keep_preferences=keep_preferences, + ) + + if not plan.deletable_items and not plan.protected_items: + console.print("[yellow]No removable data found for this app.[/yellow]") + return + + console.print() + console.print(Panel( + f"[bold cyan]Uninstall Plan[/bold cyan] [dim]{app.name}[/dim]", + border_style="cyan", padding=(0, 2), + )) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("#", style="dim", width=4, justify="right") + table.add_column("Category", width=16) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + + for i, item in enumerate(plan.deletable_items[:50], 1): + table.add_row(str(i), item.category, bytes_human(item.size), str(item.path)) + + console.print(table) + if len(plan.deletable_items) > 50: + console.print(f" [dim]... {len(plan.deletable_items) - 50} more items omitted[/dim]") + if plan.protected_items: + console.print( + f" [dim]{len(plan.protected_items)} item(s) protected by safety checks[/dim]" + ) + + console.print( + f"\n Total removable: [yellow]{bytes_human(plan.total_size)}[/yellow]" + ) + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; uninstall skipped.[/yellow]") + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Proceed with uninstall?", default=False) + if not do_it: + return + + session = None + if cfg.undo_mode and not no_undo: + session = new_session() + + result = execute_uninstall(plan, session=session) + + if session and result.staged > 0: + session.save() + console.print( + f"\n [green]Staged {bytes_human(result.bytes_freed)} for undo[/green]" + ) + console.print( + f" [dim]Restore with: mac-cleaner undo --session {session.session_id[:8]}[/dim]" + ) + else: + console.print( + f"\n [green]Removed {bytes_human(result.bytes_freed)}[/green]" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# BROWSER DATA CLEANER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("browser-data") +@click.option("--browser", "browsers", multiple=True, + type=click.Choice(["safari", "chrome", "firefox", "edge", "brave"], + case_sensitive=False), + help="Limit to specific browsers.") +@click.option("--category", "categories", multiple=True, + type=click.Choice(["cache", "cookies", "history", "downloads", "site-data", "sessions"], + case_sensitive=False), + help="Limit to specific data categories.") +@click.option("--clean", is_flag=True, default=False, + help="Delete selected data (requires --category or --all).") +@click.option("--all", "clean_all", is_flag=True, default=False, + help="Delete all supported categories for selected browsers.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for deletions.") +@click.pass_context +def cmd_browser_data( + ctx: click.Context, + browsers: Tuple[str, ...], + categories: Tuple[str, ...], + clean: bool, + clean_all: bool, + yes: bool, +) -> None: + """Analyze and optionally clean browser data.""" + from scanners.browser_data import ( + collect_browser_data, + delete_browser_data, + summarize_browser_data, + ) + from core.dry_run import dry_run_enabled + + items = collect_browser_data(browsers=list(browsers) or None) + if not items: + console.print("[green]No browser data found.[/green]") + return + + summary = summarize_browser_data(items) + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Browser", width=14) + table.add_column("Category", width=14) + table.add_column("Items", justify="right", width=7) + table.add_column("Size", justify="right", style="yellow", width=12) + + for row in summary: + table.add_row(row[0], row[1], str(row[2]), bytes_human(row[3])) + + console.print() + console.print(Panel("[bold cyan]Browser Data Summary[/bold cyan]", + border_style="cyan", padding=(0, 2))) + console.print(table) + + if not (clean or clean_all): + return + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; browser data cleanup skipped.[/yellow]") + return + + target_categories = [c.lower() for c in categories] + if not clean_all and not target_categories: + console.print("[yellow]Specify --category or use --all to clean.[/yellow]") + return + + if clean_all: + target_categories = [] # empty means all in delete_browser_data + + if not yes: + from rich.prompt import Confirm + if not Confirm.ask("Proceed with browser data deletion?", default=False): + return + + result = delete_browser_data(items, categories=target_categories) + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped by safety checks[/dim]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# SPACE MAP +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("space-map") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Root directories to map (default: HOME).") +@click.option("--depth", default=2, show_default=True, + help="Folder depth to include.") +@click.option("--limit", default=12, show_default=True, + help="Maximum child entries shown per directory.") +@click.option("--min-mb", default=1, show_default=True, + help="Minimum size per entry (MB).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export map to JSON.") +def cmd_space_map( + roots: Tuple[str, ...], + depth: int, + limit: int, + min_mb: int, + export_path: Optional[str], +) -> None: + """Visual disk space map.""" + from scanners.space_map import build_usage_tree, render_usage_tree + from constants import HOME + + min_bytes = min_mb * 1024 * 1024 + root_paths = [Path(p).expanduser().resolve() for p in roots] or [HOME] + + trees = [] + for root in root_paths: + node = build_usage_tree(root, max_depth=depth, min_size=min_bytes) + trees.append(node) + console.print() + console.print(Panel( + f"[bold cyan]Space Map[/bold cyan] [dim]{root}[/dim]", + border_style="cyan", padding=(0, 2), + )) + console.print(render_usage_tree(node, limit=limit)) + + if export_path: + import json + data = [t.to_dict() for t in trees] + with open(export_path, "w") as f: + json.dump(data, f, indent=2, default=str) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# PHOTOS ANALYZER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("photos") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Search roots for Photos libraries (default: ~/Pictures).") +@click.option("--details", is_flag=True, default=False, + help="Show file type breakdown for originals.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export analysis to JSON.") +def cmd_photos( + roots: Tuple[str, ...], + details: bool, + export_path: Optional[str], +) -> None: + """Analyze Photos libraries and storage usage.""" + from scanners.photos_analyzer import analyze_photo_library, find_photo_libraries + from constants import HOME + + search_roots = [Path(p).expanduser().resolve() for p in roots] or [HOME / "Pictures"] + libs = find_photo_libraries(search_roots=search_roots) + if not libs: + console.print("[yellow]No Photos libraries found in the selected roots.[/yellow]") + return + + reports = [analyze_photo_library(p) for p in libs] + + console.print() + console.print(Panel("[bold cyan]Photos Library Analysis[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Library", min_width=22) + table.add_column("Total", justify="right", style="yellow", width=10) + table.add_column("Originals", justify="right", width=10) + table.add_column("Previews", justify="right", width=10) + table.add_column("Database", justify="right", width=10) + table.add_column("Originals Count", justify="right", width=16) + + for r in reports: + table.add_row( + r.name, + bytes_human(r.size), + bytes_human(r.originals_size), + bytes_human(r.previews_size), + bytes_human(r.database_size), + str(r.originals_count), + ) + + console.print(table) + + if details: + for r in reports: + console.print(f"\n [bold]{r.name}[/bold]") + for ext, count, size in r.top_extensions(8): + console.print(f" {ext:>6} {count:>6} files {bytes_human(size)}") + + if export_path: + import json + with open(export_path, "w") as f: + json.dump([r.to_dict() for r in reports], f, indent=2, default=str) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# SIMULATOR CLEANER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("simulators") +@click.option("--purge-unavailable", is_flag=True, default=False, + help="Delete data for unavailable simulators only.") +@click.option("--purge-all", is_flag=True, default=False, + help="Delete data for all simulators (destructive).") +@click.option("--purge-caches", is_flag=True, default=False, + help="Delete CoreSimulator caches and logs.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_simulators( + ctx: click.Context, + purge_unavailable: bool, + purge_all: bool, + purge_caches: bool, + yes: bool, +) -> None: + """Inspect and clean iOS Simulator data.""" + from scanners.simulators import ( + find_simulator_caches, + find_simulator_devices, + purge_simulator_caches, + purge_simulator_devices, + ) + from core.dry_run import dry_run_enabled + + devices = find_simulator_devices() + caches = find_simulator_caches() + + console.print() + console.print(Panel("[bold cyan]iOS Simulator Data[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if devices: + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Name", min_width=20) + table.add_column("Runtime", width=18) + table.add_column("State", width=12) + table.add_column("Available", width=10) + table.add_column("Size", justify="right", style="yellow", width=10) + for d in devices: + table.add_row(d.name, d.runtime, d.state, "yes" if d.is_available else "no", bytes_human(d.size)) + console.print(table) + else: + console.print(" [dim]No simulator devices found.[/dim]") + + if caches: + cache_total = sum(c.size for c in caches) + console.print(f"\n Caches: {bytes_human(cache_total)}") + for c in caches: + console.print(f" {c.category}: {bytes_human(c.size)}") + + if not (purge_unavailable or purge_all or purge_caches): + return + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; simulator cleanup skipped.[/yellow]") + return + + if purge_unavailable or purge_all: + targets = devices if purge_all else [d for d in devices if not d.is_available] + if targets: + if not yes: + from rich.prompt import Confirm + if not Confirm.ask( + f"Delete simulator data for {len(targets)} device(s)?", + default=False, + ): + targets = [] + if targets: + result = purge_simulator_devices(targets) + console.print( + f"\n [green]Deleted {result.deleted} device(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + else: + console.print(" [dim]No devices matched purge criteria.[/dim]") + + if purge_caches and caches: + if yes: + proceed = True + else: + from rich.prompt import Confirm + proceed = Confirm.ask("Delete CoreSimulator caches?", default=False) + if proceed: + result = purge_simulator_caches(caches) + console.print( + f" [green]Deleted {result.deleted} cache item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + # ══════════════════════════════════════════════════════════════════════════════ # DUPLICATES # ══════════════════════════════════════════════════════════════════════════════ @@ -524,7 +989,9 @@ def info() -> None: help="Export results to JSON.") @click.option("--delete", is_flag=True, default=False, help="Interactively delete duplicates (keeps the first copy).") +@click.pass_context def cmd_duplicates( + ctx: click.Context, paths: Tuple[str, ...], min_size: int, export_path: Optional[str], @@ -601,6 +1068,9 @@ def _cb(n: int) -> None: console.print(f"\n [green]✓ Exported to {export_path}[/green]") if delete: + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "duplicate deletions"): + return from rich.prompt import Confirm console.print() for g in groups: @@ -698,7 +1168,8 @@ def cmd_large_files( @click.option("--path", "paths", multiple=True, type=click.Path()) @click.option("--delete", is_flag=True, default=False, help="Delete broken symlinks after confirmation.") -def cmd_symlinks(paths: Tuple[str, ...], delete: bool) -> None: +@click.pass_context +def cmd_symlinks(ctx: click.Context, paths: Tuple[str, ...], delete: bool) -> None: """Find broken (dangling) symbolic links in developer directories.""" from scanners.symlinks import find_broken_symlinks, DEFAULT_ROOTS @@ -734,6 +1205,9 @@ def cmd_symlinks(paths: Tuple[str, ...], delete: bool) -> None: console.print(table) if delete: + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "symlink deletions"): + return from rich.prompt import Confirm if Confirm.ask(f"\n Delete all {len(broken)} broken symlinks?", default=False): deleted = 0 @@ -761,7 +1235,9 @@ def cmd_symlinks(paths: Tuple[str, ...], delete: bool) -> None: help="Interactively delete old iOS backups.") @click.option("--strip-languages", is_flag=True, default=False, help="Interactively strip unused language packs.") +@click.pass_context def cmd_extras( + ctx: click.Context, ios_backups: bool, language_packs: bool, all_extras: bool, @@ -776,8 +1252,13 @@ def cmd_extras( mac-cleaner extras --ios-backups --delete-backups mac-cleaner extras --language-packs --strip-languages """ + from core.dry_run import dry_run_enabled from scanners.extras import find_ios_backups, find_language_packs + dry_run = dry_run_enabled(ctx) + if dry_run and (delete_backups or strip_languages): + console.print("[yellow]Dry-run enabled; delete actions are skipped.[/yellow]") + do_ios = ios_backups or all_extras do_lang = language_packs or all_extras @@ -815,7 +1296,7 @@ def cmd_extras( table.add_row(b.device_name, b.ios_version, age, b.size_human, str(b.path)) console.print(table) - if delete_backups: + if delete_backups and not dry_run: from rich.prompt import Confirm console.print() for b in backups: @@ -854,7 +1335,7 @@ def cmd_extras( table.add_row(e.app_name, str(len(e.removable_lprojs)), e.removable_size_human) console.print(table) - if strip_languages: + if strip_languages and not dry_run: from rich.prompt import Confirm console.print() for e in lang_entries: @@ -884,7 +1365,9 @@ def cmd_extras( help="Interactively thin fat binaries.") @click.option("--no-backup", is_flag=True, default=False, help="Skip .fat_backup copy (irreversible!).") +@click.pass_context def cmd_binary( + ctx: click.Context, paths: Tuple[str, ...], arch: Optional[str], thin: bool, @@ -936,6 +1419,9 @@ def cmd_binary( console.print(table) if thin: + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "binary thinning"): + return from rich.prompt import Confirm keep_backup = not no_backup freed = 0 @@ -972,15 +1458,30 @@ def cmd_binary( help="Permanently purge old staged files beyond retention period.") @click.option("--purge-all", "purge_all", is_flag=True, default=False, help="Permanently purge ALL staged sessions regardless of age.") -def cmd_undo(list_only: bool, session_id: Optional[str], purge: bool, purge_all: bool) -> None: +@click.pass_context +def cmd_undo( + ctx: click.Context, + list_only: bool, + session_id: Optional[str], + purge: bool, + purge_all: bool, +) -> None: """Restore files from the staging area (undo a clean operation). \b Files are staged in ~/.mac_cleaner_trash/ during clean. Sessions older than 30 days are purged automatically. """ + from core.dry_run import dry_run_enabled from core.undo import list_sessions, restore_session, purge_old_sessions, purge_all_sessions + dry_run = dry_run_enabled(ctx) + if dry_run and (purge or purge_all or not list_only): + console.print("[yellow]Dry-run enabled; restore and purge actions are skipped.[/yellow]") + purge = False + purge_all = False + list_only = True + sessions = list_sessions() if purge_all: @@ -1263,6 +1764,654 @@ def cmd_system( console.print() +# ══════════════════════════════════════════════════════════════════════════════ +# MEMORY PRESSURE +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("memory-pressure") +@click.option("--relieve", is_flag=True, default=False, + help="Run purge to relieve memory pressure.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for purge.") +@click.pass_context +def cmd_memory_pressure( + ctx: click.Context, + relieve: bool, + yes: bool, +) -> None: + """Inspect memory pressure and optionally purge caches.""" + from core.dry_run import skip_if_dry_run + from core.memory_pressure import collect_memory_stats, relieve_memory_pressure + + stats = collect_memory_stats() + if stats is None: + console.print("[yellow]Could not read memory statistics.[/yellow]") + return + + free_percent = stats.free_percent + if free_percent is None and stats.total_bytes > 0: + free_percent = (stats.free_bytes / stats.total_bytes) * 100 + + console.print() + console.print(Panel("[bold cyan]Memory Pressure[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Metric", style="bold") + table.add_column("Value", style="yellow") + table.add_row("Total", bytes_human(stats.total_bytes)) + table.add_row("Used", bytes_human(stats.used_bytes)) + table.add_row("Free", bytes_human(stats.free_bytes)) + table.add_row("Compressed", bytes_human(stats.compressed_bytes)) + if free_percent is not None: + table.add_row("Free %", f"{free_percent:.1f}%") + if stats.pressure_level: + table.add_row("Pressure", stats.pressure_level) + if stats.swap_used_bytes is not None: + table.add_row("Swap Used", bytes_human(stats.swap_used_bytes)) + if stats.swap_free_bytes is not None: + table.add_row("Swap Free", bytes_human(stats.swap_free_bytes)) + + console.print(table) + + if not relieve: + return + + if skip_if_dry_run(ctx, console, "memory pressure purge"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Run purge to clear inactive caches?", default=False) + if not do_it: + return + + result = relieve_memory_pressure() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# HOMEBREW MANAGER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("brew") +@click.option("--outdated", is_flag=True, default=False, + help="Check for outdated formulae and casks.") +@click.option("--cleanup", is_flag=True, default=False, + help="Run brew cleanup.") +@click.option("--prune-all", is_flag=True, default=False, + help="Run brew cleanup --prune=all.") +@click.option("--autoremove", is_flag=True, default=False, + help="Run brew autoremove.") +@click.option("--doctor", is_flag=True, default=False, + help="Run brew doctor.") +@click.option("--update", is_flag=True, default=False, + help="Run brew update.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for maintenance actions.") +@click.pass_context +def cmd_brew( + ctx: click.Context, + outdated: bool, + cleanup: bool, + prune_all: bool, + autoremove: bool, + doctor: bool, + update: bool, + yes: bool, +) -> None: + """Manage Homebrew caches and maintenance.""" + from core.brew_manager import ( + brew_autoremove, + brew_cleanup, + brew_doctor, + brew_installed, + brew_update, + collect_brew_status, + ) + from core.dry_run import skip_if_dry_run + + if not brew_installed(): + console.print("[yellow]Homebrew not found in PATH.[/yellow]") + return + + status = collect_brew_status(include_outdated=outdated) + + console.print() + console.print(Panel("[bold cyan]Homebrew Manager[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=False, border_style="dim", padding=(0, 1)) + table.add_column("Key", style="bold") + table.add_column("Value", style="yellow") + table.add_row("Version", status.version or "unknown") + table.add_row("Prefix", str(status.prefix) if status.prefix else "unknown") + table.add_row("Cache", f"{status.cache} ({bytes_human(status.cache_size)})" if status.cache else "unknown") + table.add_row("Cellar", f"{status.cellar} ({bytes_human(status.cellar_size)})" if status.cellar else "unknown") + table.add_row("Formulae", str(status.formulae)) + table.add_row("Casks", str(status.casks)) + console.print(table) + + if outdated: + if status.outdated_formulae: + console.print(f"\n Outdated formulae: {len(status.outdated_formulae)}") + for name in status.outdated_formulae[:10]: + console.print(f" [dim]- {name}[/dim]") + if len(status.outdated_formulae) > 10: + console.print(f" [dim]... {len(status.outdated_formulae) - 10} more[/dim]") + if status.outdated_casks: + console.print(f"\n Outdated casks: {len(status.outdated_casks)}") + for name in status.outdated_casks[:10]: + console.print(f" [dim]- {name}[/dim]") + if len(status.outdated_casks) > 10: + console.print(f" [dim]... {len(status.outdated_casks) - 10} more[/dim]") + + if not any([cleanup, prune_all, autoremove, doctor, update]): + return + + if skip_if_dry_run(ctx, console, "Homebrew maintenance"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Run selected Homebrew actions?", default=False) + if not do_it: + return + + if update: + result = brew_update() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if doctor: + result = brew_doctor() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if cleanup or prune_all: + result = brew_cleanup(prune_all=prune_all) + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if autoremove: + result = brew_autoremove() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# STORAGE TREND +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("storage-trend") +@click.option("--record", is_flag=True, default=False, + help="Record a new snapshot before showing results.") +@click.option("--limit", default=12, show_default=True, + help="Maximum snapshots to display.") +@click.option("--days", default=None, type=int, + help="Summarize only the last N days.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export snapshots to JSON.") +@click.option("--volume", default="/", show_default=True, + help="Volume path to record.") +def cmd_storage_trend( + record: bool, + limit: int, + days: Optional[int], + export_path: Optional[str], + volume: str, +) -> None: + """Track disk usage trends over time.""" + from reporting.storage_trend import append_snapshot, load_snapshots, record_snapshot, summarize_trend + + if record: + snapshot = record_snapshot(Path(volume)) + append_snapshot(snapshot) + + resolved_volume = str(Path(volume)) + snapshots = load_snapshots(volume=resolved_volume) + if not snapshots: + console.print("[yellow]No storage snapshots yet. Run with --record.[/yellow]") + return + + console.print() + console.print(Panel("[bold cyan]Storage Trend[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Timestamp", min_width=20) + table.add_column("Used", justify="right", style="yellow", width=12) + table.add_column("Free", justify="right", width=12) + + for snap in snapshots[-limit:]: + table.add_row(snap.timestamp[:19], snap.used_human, snap.free_human) + console.print(table) + + summary = summarize_trend(snapshots, days=days) + if summary: + direction = "more" if summary.delta_used > 0 else "less" + console.print( + f"\n Used {direction} space by {summary.delta_used_human} over {summary.days} day(s)." + ) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "volume": resolved_volume, + "snapshots": [s.to_dict() for s in snapshots], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# RECENT ACTIVITY +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("recent-activity") +@click.option("--clear", is_flag=True, default=False, + help="Clear items under ~/Library/Recent Items.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for clearing.") +@click.pass_context +def cmd_recent_activity(ctx: click.Context, clear: bool, yes: bool) -> None: + """Scan and optionally clear recent activity files.""" + from core.dry_run import skip_if_dry_run + from scanners.recent_activity import clear_recent_items, collect_recent_activity + + items = collect_recent_activity() + if not items: + console.print("[green]No recent activity files found.[/green]") + return + + by_cat: dict = {} + for item in items: + by_cat.setdefault(item.category, []).append(item) + + console.print() + console.print(Panel("[bold cyan]Recent Activity[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Category", min_width=18) + table.add_column("Items", justify="right", width=8) + table.add_column("Size", justify="right", style="yellow", width=12) + table.add_column("Removable", justify="center", width=10) + + for category, entries in by_cat.items(): + total = sum(e.size for e in entries) + removable = all(e.safe_to_delete for e in entries) + table.add_row(category, str(len(entries)), bytes_human(total), "yes" if removable else "no") + + console.print(table) + + if not clear: + return + + if skip_if_dry_run(ctx, console, "recent activity clearing"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Clear Recent Items folder?", default=False) + if not do_it: + return + + result = clear_recent_items() + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# PERMISSIONS AUDITOR +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("permissions") +@click.option("--system", is_flag=True, default=False, + help="Include the system-wide TCC database (may require privileges).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export entries to JSON.") +def cmd_permissions(system: bool, export_path: Optional[str]) -> None: + """Audit macOS privacy permissions.""" + from core.permissions_auditor import audit_permissions + + report = audit_permissions(include_system=system) + if not report.entries: + console.print("[yellow]No TCC entries found or access denied.[/yellow]") + return + + console.print() + console.print(Panel("[bold cyan]Permissions Audit[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + grouped = report.by_service() + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Service", min_width=28) + table.add_column("Allowed", justify="right", width=8) + table.add_column("Denied", justify="right", width=8) + + for service, entries in grouped.items(): + allowed = sum(1 for e in entries if e.allowed) + denied = len(entries) - allowed + name = entries[0].service_name if entries else service + table.add_row(name, str(allowed), str(denied)) + + console.print(table) + + for warning in report.warnings: + console.print(f"\n [yellow]⚠ {warning}[/yellow]") + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "entries": [e.__dict__ for e in report.entries], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# APFS SNAPSHOTS +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("snapshots") +@click.option("--volume", default="/", show_default=True, + help="Volume path to inspect.") +@click.option("--delete-older-than", default=None, type=int, + help="Delete snapshots older than N days.") +@click.option("--keep", default=None, type=int, + help="Keep the newest N snapshots.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for deletions.") +@click.pass_context +def cmd_snapshots( + ctx: click.Context, + volume: str, + delete_older_than: Optional[int], + keep: Optional[int], + yes: bool, +) -> None: + """Inspect and prune APFS local snapshots.""" + from core.apfs_snapshots import list_snapshots, select_snapshots_to_delete, delete_snapshot + from core.dry_run import skip_if_dry_run + + snapshots = list_snapshots(volume) + console.print() + console.print(Panel("[bold cyan]APFS Snapshots[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not snapshots: + console.print(" [dim]No local snapshots found.[/dim]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Snapshot", min_width=34) + table.add_column("Created", width=20) + for snap in snapshots: + created = snap.created_at.strftime("%Y-%m-%d %H:%M") if snap.created_at else "unknown" + table.add_row(snap.name, created) + console.print(table) + + if delete_older_than is None and keep is None: + return + + targets = select_snapshots_to_delete(snapshots, keep=keep, older_than_days=delete_older_than) + if not targets: + console.print(" [dim]No snapshots matched the prune criteria.[/dim]") + return + + if skip_if_dry_run(ctx, console, "snapshot deletion"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask(f"Delete {len(targets)} snapshot(s)?", default=False) + if not do_it: + return + + deleted = 0 + for snap in targets: + if delete_snapshot(snap): + deleted += 1 + console.print(f"\n [green]Deleted {deleted} snapshot(s)[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# MENU BAR COMPANION +# ══════════════════════════════════════════════════════════════════════════════ + +@main.group("menubar") +def cmd_menubar() -> None: + """Menu bar companion for SwiftBar/xbar.""" + + +@cmd_menubar.command("status") +@click.option("--format", "output_format", default="plain", + type=click.Choice(["plain", "swiftbar"], case_sensitive=False)) +def menubar_status(output_format: str) -> None: + """Emit status for menu bar tools.""" + from core.menubar import build_status_from_history, format_swiftbar + + status = build_status_from_history() + if status is None: + console.print("No scan history yet. Run 'mac-cleaner scan' first.") + return + + if output_format.lower() == "swiftbar": + click.echo(format_swiftbar(status)) + return + + console.print(status.label) + console.print(status.subtitle) + + +@cmd_menubar.command("install") +@click.option("--target", default=None, + type=click.Choice(["swiftbar", "xbar"], case_sensitive=False), + help="Choose SwiftBar or xbar plugin directory.") +@click.option("--interval", default=15, show_default=True, + help="Refresh interval in minutes.") +@click.option("--dir", "custom_dir", default=None, type=click.Path(), + help="Custom plugin directory.") +def menubar_install(target: Optional[str], interval: int, custom_dir: Optional[str]) -> None: + """Install a menu bar plugin script.""" + from core.menubar import detect_plugin_dirs, install_plugin + + if custom_dir: + path = install_plugin(Path(custom_dir), interval_minutes=interval) + console.print(f"[green]Installed plugin at {path}[/green]") + return + + dirs = detect_plugin_dirs() + if not dirs: + console.print("[yellow]No SwiftBar/xbar plugin folder found.[/yellow]") + return + + chosen = target.lower() if target else ("swiftbar" if "swiftbar" in dirs else "xbar") + plugin_dir = dirs.get(chosen) + if not plugin_dir: + console.print("[yellow]Selected plugin directory not found.[/yellow]") + return + + path = install_plugin(plugin_dir, interval_minutes=interval) + console.print(f"[green]Installed plugin at {path}[/green]") + + +@cmd_menubar.command("remove") +@click.option("--target", default=None, + type=click.Choice(["swiftbar", "xbar"], case_sensitive=False)) +@click.option("--dir", "custom_dir", default=None, type=click.Path()) +def menubar_remove(target: Optional[str], custom_dir: Optional[str]) -> None: + """Remove menu bar plugin scripts.""" + from core.menubar import detect_plugin_dirs, remove_plugin + + removed = 0 + if custom_dir: + removed = remove_plugin(Path(custom_dir)) + else: + dirs = detect_plugin_dirs() + if target: + t = target.lower() + if t in dirs: + removed = remove_plugin(dirs[t]) + else: + for path in dirs.values(): + removed += remove_plugin(path) + + console.print(f"Removed {removed} plugin(s).") + + +# ══════════════════════════════════════════════════════════════════════════════ +# BREACH MONITOR +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("breach") +@click.option("--email", "emails", multiple=True, + help="Email address to check. Can be repeated.") +@click.option("--api-key", default=None, + help="HIBP API key (or set HIBP_API_KEY env var).") +@click.option("--delay", default=1.6, show_default=True, + help="Delay between requests in seconds.") +@click.option("--use-watchlist", is_flag=True, default=False, + help="Check addresses saved in the watchlist.") +@click.option("--save", is_flag=True, default=False, + help="Save provided emails to the watchlist.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +def cmd_breach( + emails: Tuple[str, ...], + api_key: Optional[str], + delay: float, + use_watchlist: bool, + save: bool, + export_path: Optional[str], +) -> None: + """Check emails against Have I Been Pwned.""" + from core.breach_monitor import check_emails, load_watchlist, resolve_api_key, save_watchlist + + email_list = list(emails) + if use_watchlist: + email_list.extend(load_watchlist()) + email_list = [e.strip() for e in email_list if e.strip()] + + if not email_list: + console.print("[yellow]No email addresses provided.[/yellow]") + return + + if save: + save_watchlist(email_list) + + key = resolve_api_key(api_key) + if not key: + console.print("[yellow]HIBP API key missing. Use --api-key or set HIBP_API_KEY.[/yellow]") + return + results = check_emails(email_list, key, min_delay=delay) + + console.print() + console.print(Panel("[bold cyan]Breach Monitor[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Email", min_width=28) + table.add_column("Breached", width=10) + table.add_column("Count", justify="right", width=6) + table.add_column("Error", style="red") + + for r in results: + table.add_row( + r.email, + "yes" if r.breached else "no", + str(len(r.breaches)), + r.error or "", + ) + + console.print(table) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "results": [r.__dict__ for r in results], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# CLOUD STORAGE JUNK +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("cloud-junk") +@click.option("--provider", "providers", multiple=True, + type=click.Choice(["dropbox", "google-drive", "onedrive", "box"], case_sensitive=False), + help="Limit to a specific provider.") +@click.option("--clean", is_flag=True, default=False, + help="Delete detected cache/log directories.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for deletions.") +@click.pass_context +def cmd_cloud_junk( + ctx: click.Context, + providers: Tuple[str, ...], + clean: bool, + yes: bool, +) -> None: + """Scan cloud storage caches and logs.""" + from core.dry_run import skip_if_dry_run + from scanners.cloud_junk import collect_cloud_junk, delete_cloud_junk + + items = collect_cloud_junk(providers=providers or None) + if not items: + console.print("[green]No cloud cache data found.[/green]") + return + + console.print() + console.print(Panel("[bold cyan]Cloud Storage Junk[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Provider", width=14) + table.add_column("Category", width=10) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + + for item in items: + table.add_row(item.provider, item.category, bytes_human(item.size), str(item.path)) + console.print(table) + + if not clean: + return + + if skip_if_dry_run(ctx, console, "cloud cache cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask(f"Delete {len(items)} cache/log item(s)?", default=False) + if not do_it: + return + + result = delete_cloud_junk(items) + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + # ══════════════════════════════════════════════════════════════════════════════ # SCHEDULE # ══════════════════════════════════════════════════════════════════════════════ @@ -1274,8 +2423,12 @@ def cmd_schedule() -> None: @cmd_schedule.command("install") @click.option("--no-notify", is_flag=True, default=False) -def schedule_install(no_notify: bool) -> None: +@click.pass_context +def schedule_install(ctx: click.Context, no_notify: bool) -> None: """Install a weekly LaunchAgent to run scans automatically.""" + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "schedule install"): + return from core.scheduler import install_schedule ok, msg = install_schedule(notify=not no_notify) color = "green" if ok else "red" @@ -1283,8 +2436,12 @@ def schedule_install(no_notify: bool) -> None: @cmd_schedule.command("remove") -def schedule_remove() -> None: +@click.pass_context +def schedule_remove(ctx: click.Context) -> None: """Remove the weekly scan LaunchAgent.""" + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "schedule removal"): + return from core.scheduler import remove_schedule ok, msg = remove_schedule() color = "green" if ok else "yellow" @@ -1315,9 +2472,11 @@ def schedule_status() -> None: help="Check only — do not upgrade.") @click.option("--yes", "-y", is_flag=True, default=False, help="Upgrade without prompting.") -def cmd_update(check: bool, yes: bool) -> None: +@click.pass_context +def cmd_update(ctx: click.Context, check: bool, yes: bool) -> None: """Check for a newer version on PyPI and optionally upgrade.""" from core.updater import check_for_update, perform_upgrade + from core.dry_run import dry_run_enabled console.print() console.print(f" Current version: [bold cyan]{__version__}[/bold cyan]") @@ -1340,6 +2499,10 @@ def cmd_update(check: bool, yes: bool) -> None: if check: return + if dry_run_enabled(ctx): + console.print(" [yellow]Dry-run enabled; upgrade skipped.[/yellow]") + return + do_it = yes if not do_it: from rich.prompt import Confirm diff --git a/src/config/config.py b/src/config/config.py index dbbb63b..b9fddf7 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Configuration Manager +Mac Deep Cleaner v1.5.0 — Configuration Manager ============================================= Reads and writes a YAML config file at ~/.config/mac-cleaner/config.yaml. diff --git a/src/config/history.py b/src/config/history.py index 9778520..b8c50f7 100644 --- a/src/config/history.py +++ b/src/config/history.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Scan History & Diff +Mac Deep Cleaner v1.5.0 — Scan History & Diff ========================================== Stores past scan results in ~/.config/mac-cleaner/history/ as JSON files. Allows comparing two scans to show what's new or resolved since the last run. diff --git a/src/config/models.py b/src/config/models.py index b37fe8a..631c461 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Data Models +Mac Deep Cleaner v1.5.0 — Data Models ================================== Immutable data classes for apps, orphan entries, and junk entries. """ diff --git a/src/constants.py b/src/constants.py index a194f58..72b5215 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,19 +1,8 @@ """ -Mac Deep Cleaner v1.2.0 — Constants & Configuration +Mac Deep Cleaner v1.5.0 — Constants & Configuration ================================================= All safelists, alias tables, search roots, and configuration constants. -Changes from v1.2.0 ---------------- -- Added CONFIG_DIR (used by config.py, history.py, scheduler.py) -- Expanded APP_DIR_ALIASES: legacy aliases retained, ~30 new entries added -- Expanded TEAM_ID_MAP: legacy mappings retained, additional vendor IDs added -- SYSTEM_GROUP_PREFIXES: unchanged (already a proper set) -- SYSTEM_KEYWORD_SAFELIST: unchanged -- SYSTEM_EXACT_SAFELIST: legacy entries retained, ~50 daemon names added -- SYSTEM_CACHE_PREFIXES: unchanged -- SYSTEM_FILE_EXTENSIONS: unchanged -- SYSTEM_PREF_PATTERNS: unchanged """ from pathlib import Path @@ -21,7 +10,7 @@ HOME = Path.home() LOG_FILE = HOME / ".mac_cleaner_deleted.log" -CONFIG_DIR = HOME / ".config" / "mac-cleaner" # NEW in v1.2.0 +CONFIG_DIR = HOME / ".config" / "mac-cleaner" # NEW in v1.5.0 # ── Scan roots ──────────────────────────────────────────────────────────────── @@ -79,7 +68,7 @@ "microsoft teams": "com.microsoft.teams", "microsoft teams (work or school)": "com.microsoft.teams2", "microsoft teams classic": "com.microsoft.teams", - # v1.2.0: short-form aliases for Microsoft apps + # v1.5.0: short-form aliases for Microsoft apps "excel": "com.microsoft.excel", "word": "com.microsoft.word", "powerpoint": "com.microsoft.powerpoint", @@ -97,7 +86,7 @@ "google chrome canary": "com.google.chrome.canary", "google drive": "com.google.drivefs", "google earth pro": "com.google.googleearthpro", - # v1.2.0 + # v1.5.0 "googledrive": "com.google.drivefs", # ── JetBrains ───────────────────────────────────────────────────────── @@ -116,7 +105,7 @@ "appcode": "com.jetbrains.appcode", "fleet": "com.jetbrains.fleet", "jetbrains toolbox": "com.jetbrains.toolbox", - # v1.2.0 + # v1.5.0 "jetbrains toolbox app": "com.jetbrains.toolbox", # ── Browsers ────────────────────────────────────────────────────────── @@ -134,7 +123,7 @@ "tor browser": "org.torproject.torbrowser", "waterfox": "net.nickolaj.nickelodeon", "sidekick": "com.nicklodeon.nickelodeon", - # v1.2.0: extra short-form browser aliases + # v1.5.0: extra short-form browser aliases "brave": "com.brave.browser", "edge": "com.microsoft.edgemac", @@ -184,7 +173,7 @@ "ia writer": "pro.writer.mac", "devonthink 3": "com.devon-technologies.think3", "devonthink": "com.devon-technologies.think3", - # v1.2.0 + # v1.5.0 "linear": "com.linear.linear", "superhuman": "com.superhuman.desktop", @@ -204,7 +193,7 @@ "gimp": "org.gimp.gimp-2.10", "canva": "com.canva.canva", "principle": "com.principleformac.principle", - # v1.2.0 + # v1.5.0 "pixelmator": "com.pixelmatorteam.pixelmator", # ── Dev tools ───────────────────────────────────────────────────────── @@ -236,7 +225,7 @@ "coderunner": "com.krill.coderunner", "coteditor": "com.coteditor.coteditor", "textedit": "com.apple.textedit", - # v1.2.0 + # v1.5.0 "simulator": "com.apple.iphonesimulator", "xcode": "com.apple.dt.xcode", "textmate": "com.macromates.textmate", @@ -353,7 +342,7 @@ # ── Apple Developer Team ID → owner name ───────────────────────────────────── TEAM_ID_MAP: Dict[str, str] = { - # ── v1.2.0 original entries (verbatim) ──────────────────────────────────── + # ── v1.5.0 original entries (verbatim) ──────────────────────────────────── "ubf8t346g9": "Microsoft Office", "2bua8c4s2c": "1Password", "7pkpll4vld": "Dropbox", @@ -393,7 +382,7 @@ "t9um3f5r6t": "Spark / Readdle", "w5364u7y5r": "Canva", - # ── v1.2.0 additions ────────────────────────────────────────────────────── + # ── v1.5.0 additions ────────────────────────────────────────────────────── "ug75gva3v9": "Microsoft (general)", "jq525l2msd": "Adobe", "g7hh3359t7": "Dropbox", @@ -500,7 +489,7 @@ # ── Exact-stem safelist — the stem (lowercased) matches exactly ─────────────── SYSTEM_EXACT_SAFELIST: Set[str] = { - # ── v1.2.0 original entries (verbatim) ──────────────────────────────────── + # ── v1.5.0 original entries (verbatim) ──────────────────────────────────── # Networking / directory "systemconfiguration", "opendirectory", "directoryservice", @@ -598,7 +587,7 @@ "storedownloadd", "commerced", - # ── v1.2.0 additions ────────────────────────────────────────────────────── + # ── v1.5.0 additions ────────────────────────────────────────────────────── "apsd", "appleid", "airplay", diff --git a/src/core/apfs_snapshots.py b/src/core/apfs_snapshots.py new file mode 100644 index 0000000..444b696 --- /dev/null +++ b/src/core/apfs_snapshots.py @@ -0,0 +1,108 @@ +"""APFS snapshot listing and pruning helpers.""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Optional, cast + + +_TMUTIL_RE = re.compile( + r"com\.apple\.TimeMachine\.(\d{4}-\d{2}-\d{2}-\d{6})\.local" +) + + +@dataclass +class Snapshot: + """One APFS local snapshot entry.""" + name: str + token: str + created_at: Optional[datetime] + + +def parse_tmutil_output(output: str) -> List[Snapshot]: + """Parse tmutil listlocalsnapshots output.""" + snapshots: List[Snapshot] = [] + for line in output.splitlines(): + match = _TMUTIL_RE.search(line) + if match: + token = match.group(1) + created_at = None + try: + created_at = datetime.strptime(token, "%Y-%m-%d-%H%M%S") + except ValueError: + created_at = None + name = f"com.apple.TimeMachine.{token}.local" + snapshots.append(Snapshot(name=name, token=token, created_at=created_at)) + return snapshots + + +def list_snapshots( + volume: str = "/", + runner=subprocess.run, +) -> List[Snapshot]: + """List local APFS snapshots for a volume using tmutil.""" + try: + result = runner( + ["tmutil", "listlocalsnapshots", volume], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + return parse_tmutil_output(result.stdout) + + +def select_snapshots_to_delete( + snapshots: List[Snapshot], + keep: Optional[int] = None, + older_than_days: Optional[int] = None, +) -> List[Snapshot]: + """Select snapshots to delete based on age or keep count.""" + ordered = sorted( + [s for s in snapshots if s.created_at is not None], + key=lambda s: cast(datetime, s.created_at), + ) + if not ordered: + return [] + + to_delete: List[Snapshot] = [] + + if older_than_days is not None and older_than_days > 0: + cutoff = datetime.now() - timedelta(days=older_than_days) + for snap in ordered: + if snap.created_at and snap.created_at < cutoff: + to_delete.append(snap) + + if keep is not None and keep >= 0: + excess = len(ordered) - keep + if excess > 0: + to_delete.extend(ordered[:excess]) + + unique = {s.name: s for s in to_delete} + return list(unique.values()) + + +def delete_snapshot( + snapshot: Snapshot, + runner=subprocess.run, +) -> bool: + """Delete a snapshot by token using tmutil.""" + try: + result = runner( + ["tmutil", "deletelocalsnapshots", snapshot.token], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired): + return False + + return result.returncode == 0 diff --git a/src/core/breach_monitor.py b/src/core/breach_monitor.py new file mode 100644 index 0000000..d8db637 --- /dev/null +++ b/src/core/breach_monitor.py @@ -0,0 +1,134 @@ +"""Data breach monitoring via Have I Been Pwned API.""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Iterable, List, Optional + +from constants import CONFIG_DIR + +HIBP_API_BASE = "https://haveibeenpwned.com/api/v3/breachedaccount" +WATCHLIST_FILE = CONFIG_DIR / "breach_watchlist.json" +DEFAULT_USER_AGENT = "mac-cleaner/1.2.0" +DEFAULT_MIN_DELAY = 1.6 + + +@dataclass +class BreachResult: + """Result for one email address.""" + email: str + breached: bool + breaches: List[dict] = field(default_factory=list) + error: Optional[str] = None + checked_at: str = field(default_factory=lambda: datetime.now().isoformat()) + status_code: Optional[int] = None + retry_after: Optional[int] = None + + +def _build_request(email: str, api_key: str) -> urllib.request.Request: + url = f"{HIBP_API_BASE}/{urllib.parse.quote(email)}?truncateResponse=true" + return urllib.request.Request( + url, + headers={ + "hibp-api-key": api_key, + "user-agent": DEFAULT_USER_AGENT, + }, + ) + + +def parse_breach_response(payload: str) -> List[dict]: + """Parse HIBP response JSON into a list.""" + try: + data = json.loads(payload) + except json.JSONDecodeError: + return [] + if isinstance(data, list): + return data + return [] + + +def check_email(email: str, api_key: str) -> BreachResult: + """Check a single email using the HIBP API.""" + if not api_key: + return BreachResult(email=email, breached=False, error="HIBP API key missing") + + req = _build_request(email, api_key) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + body = resp.read().decode("utf-8") + breaches = parse_breach_response(body) + return BreachResult(email=email, breached=bool(breaches), breaches=breaches, status_code=resp.status) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return BreachResult(email=email, breached=False, status_code=exc.code) + if exc.code in (401, 403): + return BreachResult(email=email, breached=False, error="Invalid API key", status_code=exc.code) + if exc.code == 429: + retry = exc.headers.get("Retry-After") if exc.headers else None + retry_after = int(retry) if retry and retry.isdigit() else None + message = "Rate limited by HIBP" + if retry_after is not None: + message = f"Rate limited by HIBP (retry after {retry_after}s)" + return BreachResult( + email=email, + breached=False, + error=message, + status_code=exc.code, + retry_after=retry_after, + ) + return BreachResult(email=email, breached=False, error=f"HTTP {exc.code}", status_code=exc.code) + except (urllib.error.URLError, OSError) as exc: + return BreachResult(email=email, breached=False, error=str(exc)) + + +def check_emails( + emails: Iterable[str], + api_key: str, + min_delay: float = DEFAULT_MIN_DELAY, +) -> List[BreachResult]: + """Check multiple emails with basic rate limiting.""" + results: List[BreachResult] = [] + email_list = list(emails) + for idx, email in enumerate(email_list): + results.append(check_email(email, api_key)) + if idx >= len(email_list) - 1: + continue + delay = max(min_delay, 0) + last = results[-1] + if last.status_code == 429 and last.retry_after: + delay = max(delay, float(last.retry_after)) + if delay > 0: + time.sleep(delay) + return results + + +def load_watchlist(path: Path = WATCHLIST_FILE) -> List[str]: + if not path.exists(): + return [] + try: + data = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return [] + emails = data.get("emails", []) + return [str(e) for e in emails if isinstance(e, str)] + + +def save_watchlist(emails: List[str], path: Path = WATCHLIST_FILE) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "updated_at": datetime.now().isoformat(), + "emails": sorted(set(emails)), + } + path.write_text(json.dumps(payload, indent=2)) + + +def resolve_api_key(explicit: Optional[str] = None) -> Optional[str]: + return explicit or os.environ.get("HIBP_API_KEY") diff --git a/src/core/brew_manager.py b/src/core/brew_manager.py new file mode 100644 index 0000000..a198f9b --- /dev/null +++ b/src/core/brew_manager.py @@ -0,0 +1,172 @@ +"""Homebrew status and maintenance helpers.""" + +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from utils import size_of + + +@dataclass +class BrewStatus: + """Summary of Homebrew installation and caches.""" + installed: bool + version: Optional[str] = None + prefix: Optional[Path] = None + cache: Optional[Path] = None + cellar: Optional[Path] = None + cache_size: int = 0 + cellar_size: int = 0 + formulae: int = 0 + casks: int = 0 + outdated_formulae: List[str] = field(default_factory=list) + outdated_casks: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +@dataclass +class BrewActionResult: + """Summary of a Homebrew command execution.""" + success: bool + message: str + stdout: str = "" + stderr: str = "" + + +def _run_brew(args: List[str], runner=subprocess.run, timeout: int = 30) -> subprocess.CompletedProcess: + return runner( + ["brew"] + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _split_lines(text: str) -> List[str]: + return [line.strip() for line in text.splitlines() if line.strip()] + + +def brew_installed() -> bool: + return shutil.which("brew") is not None + + +def collect_brew_status( + include_outdated: bool = False, + runner=subprocess.run, +) -> BrewStatus: + """Collect Homebrew status and cache sizes.""" + if not brew_installed(): + return BrewStatus(installed=False) + + status = BrewStatus(installed=True) + + try: + version = _run_brew(["--version"], runner=runner) + if version.returncode == 0: + status.version = version.stdout.splitlines()[0].strip() + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew --version failed: {exc}") + + for label, args, attr in [ + ("prefix", ["--prefix"], "prefix"), + ("cache", ["--cache"], "cache"), + ("cellar", ["--cellar"], "cellar"), + ]: + try: + result = _run_brew(args, runner=runner) + if result.returncode == 0: + path = Path(result.stdout.strip()) + setattr(status, attr, path) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew {label} lookup failed: {exc}") + + if status.cache and status.cache.exists(): + status.cache_size = size_of(status.cache) + if status.cellar and status.cellar.exists(): + status.cellar_size = size_of(status.cellar) + + try: + formulas = _run_brew(["list", "--formula"], runner=runner) + if formulas.returncode == 0: + status.formulae = len(_split_lines(formulas.stdout)) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew list --formula failed: {exc}") + + try: + casks = _run_brew(["list", "--cask"], runner=runner) + if casks.returncode == 0: + status.casks = len(_split_lines(casks.stdout)) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew list --cask failed: {exc}") + + if include_outdated: + try: + outdated_formula = _run_brew(["outdated", "--formula"], runner=runner) + if outdated_formula.returncode == 0: + status.outdated_formulae = _split_lines(outdated_formula.stdout) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew outdated --formula failed: {exc}") + + try: + outdated_casks = _run_brew(["outdated", "--cask"], runner=runner) + if outdated_casks.returncode == 0: + status.outdated_casks = _split_lines(outdated_casks.stdout) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew outdated --cask failed: {exc}") + + return status + + +def brew_cleanup(prune_all: bool = False, runner=subprocess.run) -> BrewActionResult: + """Run brew cleanup (optionally with --prune=all).""" + args = ["cleanup"] + if prune_all: + args.append("--prune=all") + try: + result = _run_brew(args, runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew cleanup failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew cleanup completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew cleanup failed", result.stdout, result.stderr) + + +def brew_autoremove(runner=subprocess.run) -> BrewActionResult: + """Run brew autoremove to remove unused dependencies.""" + try: + result = _run_brew(["autoremove"], runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew autoremove failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew autoremove completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew autoremove failed", result.stdout, result.stderr) + + +def brew_update(runner=subprocess.run) -> BrewActionResult: + """Run brew update.""" + try: + result = _run_brew(["update"], runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew update failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew update completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew update failed", result.stdout, result.stderr) + + +def brew_doctor(runner=subprocess.run) -> BrewActionResult: + """Run brew doctor.""" + try: + result = _run_brew(["doctor"], runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew doctor failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew doctor completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew doctor failed", result.stdout, result.stderr) diff --git a/src/core/cleaner.py b/src/core/cleaner.py index 8a22fdf..10bad25 100644 --- a/src/core/cleaner.py +++ b/src/core/cleaner.py @@ -1,10 +1,10 @@ """ -Mac Deep Cleaner v1.2.0 — Cleaner Module +Mac Deep Cleaner v1.5.0 — Cleaner Module ====================================== Handles deletion of orphan and junk files with safety checks, audit logging, and optional staged-deletion (undo) support. -Changes from v1.2.0 +Changes from v1.5.0 --------------- - do_cleanup() now accepts an optional `session` parameter. When provided, files are moved to the staging area (undo.stage_file) @@ -41,7 +41,7 @@ def write_deletion_log(entries: List[Tuple[str, int]]) -> None: try: with open(LOG_FILE, "a") as f: f.write(f"\n{'=' * 60}\n") - f.write(f"Mac Deep Cleaner v1.2.0 — Deletion Log\n") + f.write(f"Mac Deep Cleaner v1.5.0 — Deletion Log\n") f.write(f"Timestamp: {datetime.now().isoformat()}\n") f.write(f"Items deleted: {len(entries)}\n") f.write(f"Total freed: {bytes_human(sum(s for _, s in entries))}\n") diff --git a/src/core/completions.py b/src/core/completions.py new file mode 100644 index 0000000..3f01a96 --- /dev/null +++ b/src/core/completions.py @@ -0,0 +1,74 @@ +"""Shell completion helpers.""" + +from __future__ import annotations + +import os +from typing import Callable, cast + +SUPPORTED_SHELLS = ("bash", "zsh", "fish") + + +def detect_shell() -> str: + """Best-effort shell detection from $SHELL.""" + shell = os.environ.get("SHELL", "") + base = os.path.basename(shell) + return base if base in SUPPORTED_SHELLS else "bash" + + +def _complete_var(prog_name: str) -> str: + return f"_{prog_name.replace('-', '_').upper()}_COMPLETE" + + +def _fallback_line(shell: str, prog_name: str) -> str: + complete_var = _complete_var(prog_name) + if shell == "fish": + return f"env {complete_var}=fish_source {prog_name} | source" + return f"eval \"$({complete_var}={shell}_source {prog_name})\"" + + +def _fallback_script(shell: str, prog_name: str) -> str: + line = _fallback_line(shell, prog_name) + return f"# Fallback completion line\n{line}\n" + + +def completion_script(shell: str, prog_name: str, command=None) -> str: + """Return a shell completion script for Click-based CLIs.""" + shell = shell.lower() + if shell not in SUPPORTED_SHELLS: + raise ValueError(f"Unsupported shell: {shell}") + + try: + import click.shell_completion as shell_completion + except Exception: + return _fallback_script(shell, prog_name) + + get_completion_script = getattr(shell_completion, "get_completion_script", None) + if not callable(get_completion_script): + return _fallback_script(shell, prog_name) + + get_completion_script_typed = cast(Callable[..., str], get_completion_script) + + import inspect + + params = list(inspect.signature(get_completion_script).parameters) + has_command = "command" in params + has_complete_var = "complete_var" in params + + try: + if has_command and command is not None: + return get_completion_script_typed(prog_name, shell, command) + if has_complete_var: + return get_completion_script_typed(prog_name, shell, _complete_var(prog_name)) + return get_completion_script_typed(prog_name, shell) + except TypeError: + return _fallback_script(shell, prog_name) + + +def install_instructions(shell: str, prog_name: str) -> str: + """Return shell-specific install instructions.""" + line = _fallback_line(shell, prog_name) + if shell == "zsh": + return f"Add this line to ~/.zshrc:\n{line}" + if shell == "fish": + return f"Run this once, or add to ~/.config/fish/config.fish:\n{line}" + return f"Add this line to ~/.bashrc:\n{line}" diff --git a/src/core/dry_run.py b/src/core/dry_run.py new file mode 100644 index 0000000..e89dd5f --- /dev/null +++ b/src/core/dry_run.py @@ -0,0 +1,32 @@ +"""Global dry-run helpers.""" + +from __future__ import annotations + +from typing import Optional + +import click +from rich.console import Console + +DRY_RUN_KEY = "dry_run" + + +def set_dry_run(ctx: click.Context, value: bool) -> None: + """Store dry-run flag in the Click context.""" + ctx.ensure_object(dict) + ctx.obj[DRY_RUN_KEY] = bool(value) + + +def dry_run_enabled(ctx: Optional[click.Context]) -> bool: + """Return True when global dry-run is enabled.""" + if ctx is None: + return False + obj = getattr(ctx, "obj", None) or {} + return bool(obj.get(DRY_RUN_KEY, False)) + + +def skip_if_dry_run(ctx: Optional[click.Context], console: Console, action: str) -> bool: + """Print a warning and return True when dry-run blocks an action.""" + if dry_run_enabled(ctx): + console.print(f"[yellow]Dry-run enabled; {action} skipped.[/yellow]") + return True + return False diff --git a/src/core/memory_pressure.py b/src/core/memory_pressure.py new file mode 100644 index 0000000..5b1818a --- /dev/null +++ b/src/core/memory_pressure.py @@ -0,0 +1,246 @@ +"""Memory pressure inspection and cache purge helpers.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Tuple + + +@dataclass +class MemoryStats: + """Snapshot of system memory statistics.""" + captured_at: str + page_size: int + pages_free: int + pages_active: int + pages_inactive: int + pages_speculative: int + pages_wired: int + pages_compressed: int + free_percent: Optional[float] = None + pressure_level: Optional[str] = None + swap_total_bytes: Optional[int] = None + swap_used_bytes: Optional[int] = None + swap_free_bytes: Optional[int] = None + + @property + def total_pages(self) -> int: + return ( + self.pages_free + + self.pages_active + + self.pages_inactive + + self.pages_speculative + + self.pages_wired + + self.pages_compressed + ) + + @property + def free_pages(self) -> int: + return self.pages_free + self.pages_speculative + + @property + def total_bytes(self) -> int: + return self.total_pages * self.page_size + + @property + def free_bytes(self) -> int: + return self.free_pages * self.page_size + + @property + def used_bytes(self) -> int: + used_pages = max(self.total_pages - self.free_pages, 0) + return used_pages * self.page_size + + @property + def compressed_bytes(self) -> int: + return self.pages_compressed * self.page_size + + +@dataclass +class ReliefResult: + """Summary of a cache purge attempt.""" + success: bool + message: str + stdout: str = "" + stderr: str = "" + + +_VM_PAGE_RE = re.compile(r"page size of (\d+) bytes", re.IGNORECASE) +_VM_NUMBER_RE = re.compile(r"([0-9]+)\.") +_SWAP_RE = re.compile(r"(total|used|free) = ([0-9.]+)([KMGTP])", re.IGNORECASE) + + +def parse_vm_stat(output: str) -> Optional[MemoryStats]: + """Parse vm_stat output into MemoryStats.""" + page_size = None + pages = { + "free": 0, + "active": 0, + "inactive": 0, + "speculative": 0, + "wired": 0, + "compressed": 0, + } + + for line in output.splitlines(): + if page_size is None: + match = _VM_PAGE_RE.search(line) + if match: + page_size = int(match.group(1)) + continue + lower = line.strip().lower() + if not lower.startswith("pages "): + continue + number = _VM_NUMBER_RE.search(lower) + if not number: + continue + value = int(number.group(1)) + if lower.startswith("pages free"): + pages["free"] = value + elif lower.startswith("pages active"): + pages["active"] = value + elif lower.startswith("pages inactive"): + pages["inactive"] = value + elif lower.startswith("pages speculative"): + pages["speculative"] = value + elif lower.startswith("pages wired"): + pages["wired"] = value + elif "pages occupied by compressor" in lower or lower.startswith("pages compressed"): + pages["compressed"] = value + + if page_size is None: + return None + + return MemoryStats( + captured_at=datetime.now().isoformat(), + page_size=page_size, + pages_free=pages["free"], + pages_active=pages["active"], + pages_inactive=pages["inactive"], + pages_speculative=pages["speculative"], + pages_wired=pages["wired"], + pages_compressed=pages["compressed"], + ) + + +def parse_memory_pressure(output: str) -> Tuple[Optional[float], Optional[str]]: + """Parse memory_pressure output for free percentage and level.""" + free_percent = None + level = None + for line in output.splitlines(): + if "memory free percentage" in line.lower(): + parts = re.findall(r"(\d+(?:\.\d+)?)%", line) + if parts: + free_percent = float(parts[0]) + if "memory pressure" in line.lower(): + parts = line.split(":", 1) + if len(parts) == 2: + level = parts[1].strip().lower() + return free_percent, level + + +def _size_to_bytes(value: str, unit: str) -> int: + scale = { + "k": 1024, + "m": 1024 ** 2, + "g": 1024 ** 3, + "t": 1024 ** 4, + "p": 1024 ** 5, + }.get(unit.lower(), 1) + return int(float(value) * scale) + + +def parse_swapusage(output: str) -> Optional[Tuple[int, int, int]]: + """Parse sysctl vm.swapusage output into byte totals.""" + values = {} + for key, amount, unit in _SWAP_RE.findall(output): + values[key.lower()] = _size_to_bytes(amount, unit) + if not values: + return None + total = values.get("total") + used = values.get("used") + free = values.get("free") + if total is None or used is None or free is None: + return None + return total, used, free + + +def collect_memory_stats( + runner=subprocess.run, +) -> Optional[MemoryStats]: + """Collect memory stats using vm_stat and memory_pressure when available.""" + try: + result = runner( + ["vm_stat"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return None + + if result.returncode != 0: + return None + + stats = parse_vm_stat(result.stdout) + if stats is None: + return None + + mp_bin = shutil.which("memory_pressure") + if mp_bin: + try: + mp = runner( + [mp_bin, "-Q"], + capture_output=True, + text=True, + timeout=5, + ) + if mp.returncode == 0: + free_percent, level = parse_memory_pressure(mp.stdout) + stats.free_percent = free_percent + stats.pressure_level = level + except (OSError, subprocess.TimeoutExpired): + pass + + try: + swap = runner( + ["sysctl", "vm.swapusage"], + capture_output=True, + text=True, + timeout=5, + ) + if swap.returncode == 0: + parsed = parse_swapusage(swap.stdout) + if parsed: + stats.swap_total_bytes, stats.swap_used_bytes, stats.swap_free_bytes = parsed + except (OSError, subprocess.TimeoutExpired): + pass + + return stats + + +def relieve_memory_pressure(runner=subprocess.run) -> ReliefResult: + """Attempt to purge inactive memory caches using the system purge tool.""" + purge_bin = shutil.which("purge") + if not purge_bin: + return ReliefResult(False, "purge not available on this system") + + try: + result = runner( + [purge_bin], + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + return ReliefResult(False, "purge timed out after 120 seconds") + except OSError as exc: + return ReliefResult(False, f"purge failed: {exc}") + + if result.returncode == 0: + return ReliefResult(True, "purge completed", stdout=result.stdout) + return ReliefResult(False, "purge failed", stdout=result.stdout, stderr=result.stderr) diff --git a/src/core/menubar.py b/src/core/menubar.py new file mode 100644 index 0000000..0b586ff --- /dev/null +++ b/src/core/menubar.py @@ -0,0 +1,128 @@ +"""Menu bar companion helpers (SwiftBar/xbar plugin).""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional + +from constants import CONFIG_DIR, HOME +from utils import bytes_human + + +SWIFTBAR_DIR = HOME / "Library" / "Application Support" / "SwiftBar" / "Plugins" +XBAR_DIR = HOME / "Library" / "Application Support" / "xbar" / "plugins" + + +@dataclass +class MenubarStatus: + """Computed status for the menu bar plugin.""" + label: str + subtitle: str + orphan_bytes: int + junk_bytes: int + dev_junk_bytes: int + scanned_at: str + + +def detect_plugin_dirs() -> Dict[str, Path]: + """Return available plugin directories for menu bar tools.""" + dirs: Dict[str, Path] = {} + if SWIFTBAR_DIR.exists(): + dirs["swiftbar"] = SWIFTBAR_DIR + if XBAR_DIR.exists(): + dirs["xbar"] = XBAR_DIR + return dirs + + +def _command_path() -> str: + return shutil.which("mac-cleaner") or shutil.which("mdc") or "mac-cleaner" + + +def build_status_from_history() -> Optional[MenubarStatus]: + """Use the most recent scan record to build a status.""" + try: + from config.history import latest_scan + except Exception: + return None + + record = latest_scan() + if record is None: + return None + + orphan_bytes = record.orphan_bytes + junk_bytes = record.junk_bytes + dev_bytes = record.dev_junk_bytes + total = orphan_bytes + junk_bytes + dev_bytes + + label = f"Cleaner: {bytes_human(total)}" + subtitle = f"Last scan: {record.scanned_at:%Y-%m-%d %H:%M}" + + return MenubarStatus( + label=label, + subtitle=subtitle, + orphan_bytes=orphan_bytes, + junk_bytes=junk_bytes, + dev_junk_bytes=dev_bytes, + scanned_at=record.scanned_at.isoformat(), + ) + + +def format_swiftbar(status: MenubarStatus) -> str: + """Format status as a SwiftBar/xbar-compatible script output.""" + cmd = _command_path() + lines = [status.label, "---"] + lines.append(status.subtitle) + lines.append(f"Orphans: {bytes_human(status.orphan_bytes)}") + lines.append(f"Junk: {bytes_human(status.junk_bytes)}") + lines.append(f"Dev Junk: {bytes_human(status.dev_junk_bytes)}") + lines.append("---") + lines.append( + f"Run scan | bash={cmd} param1=scan terminal=true refresh=true" + ) + lines.append( + f"Open history | bash={cmd} param1=history terminal=true" + ) + return "\n".join(lines) + + +def install_plugin( + target_dir: Path, + interval_minutes: int = 15, +) -> Path: + """Install a SwiftBar/xbar plugin script.""" + target_dir.mkdir(parents=True, exist_ok=True) + filename = f"mac-cleaner.{interval_minutes}m.sh" + path = target_dir / filename + + script = "\n".join([ + "#!/bin/sh", + "# mac-cleaner menu bar status", + f"{_command_path()} menubar status --format swiftbar", + "", + ]) + path.write_text(script) + os.chmod(path, 0o755) + return path + + +def remove_plugin(target_dir: Path) -> int: + """Remove mac-cleaner plugins from a directory.""" + removed = 0 + if not target_dir.exists(): + return 0 + for child in target_dir.iterdir(): + if child.name.startswith("mac-cleaner.") and child.suffix == ".sh": + try: + child.unlink() + removed += 1 + except OSError: + continue + return removed + + +def ensure_config_dir() -> Path: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + return CONFIG_DIR diff --git a/src/core/permissions_auditor.py b/src/core/permissions_auditor.py new file mode 100644 index 0000000..708d362 --- /dev/null +++ b/src/core/permissions_auditor.py @@ -0,0 +1,165 @@ +"""Permissions auditor for macOS TCC database.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Sequence + +from constants import HOME + +USER_TCC_DB = HOME / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db" +SYSTEM_TCC_DB = Path("/Library/Application Support/com.apple.TCC/TCC.db") + + +SERVICE_LABELS: Dict[str, str] = { + "kTCCServiceSystemPolicyAllFiles": "Full Disk Access", + "kTCCServiceAccessibility": "Accessibility", + "kTCCServiceScreenCapture": "Screen Recording", + "kTCCServiceAppleEvents": "Apple Events", + "kTCCServiceDeveloperTool": "Developer Tools", + "kTCCServiceListenEvent": "Input Monitoring", + "kTCCServiceCamera": "Camera", + "kTCCServiceMicrophone": "Microphone", +} + +RISKY_SERVICES = { + "kTCCServiceSystemPolicyAllFiles", + "kTCCServiceAccessibility", + "kTCCServiceScreenCapture", + "kTCCServiceAppleEvents", + "kTCCServiceDeveloperTool", + "kTCCServiceListenEvent", +} + + +@dataclass +class PermissionEntry: + """One row from the TCC access table.""" + service: str + client: str + client_type: int + auth_value: int + auth_reason: int + auth_version: int + last_modified: int + + @property + def service_name(self) -> str: + return SERVICE_LABELS.get(self.service, self.service) + + @property + def allowed(self) -> bool: + return int(self.auth_value) == 1 + + +@dataclass +class PermissionsReport: + """Summary of permissions by service.""" + entries: List[PermissionEntry] + warnings: List[str] = field(default_factory=list) + + def by_service(self) -> Dict[str, List[PermissionEntry]]: + grouped: Dict[str, List[PermissionEntry]] = {} + for entry in self.entries: + grouped.setdefault(entry.service, []).append(entry) + return grouped + + +def _column_names(conn: sqlite3.Connection) -> Sequence[str]: + rows = conn.execute("PRAGMA table_info(access)").fetchall() + names: List[str] = [] + for row in rows: + try: + names.append(row["name"]) + except (KeyError, IndexError, TypeError): + try: + names.append(row[1]) + except (IndexError, TypeError): + continue + return names + + +def _row_value(row: sqlite3.Row, key: str, default: int = 0) -> int: + try: + value = row[key] + except (KeyError, IndexError, TypeError): + return default + if value is None: + return default + return int(value) + + +def _read_access_rows(db_path: Path) -> List[PermissionEntry]: + if not db_path.exists(): + return [] + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + rows: List[sqlite3.Row] + try: + names = set(_column_names(conn)) + auth_col = None + if "auth_value" in names: + auth_col = "auth_value" + elif "allowed" in names: + auth_col = "allowed" + + if auth_col is None: + return [] + + select_cols = ["service", "client", auth_col] + for optional in ["client_type", "auth_reason", "auth_version", "last_modified"]: + if optional in names: + select_cols.append(optional) + + rows = conn.execute( + f"SELECT {', '.join(select_cols)} FROM access" + ).fetchall() + finally: + conn.close() + + entries: List[PermissionEntry] = [] + for row in rows: + auth_value = _row_value(row, "auth_value", _row_value(row, "allowed", 0)) + entries.append(PermissionEntry( + service=str(row["service"]), + client=str(row["client"]), + client_type=_row_value(row, "client_type", 0), + auth_value=auth_value, + auth_reason=_row_value(row, "auth_reason", 0), + auth_version=_row_value(row, "auth_version", 0), + last_modified=_row_value(row, "last_modified", 0), + )) + return entries + + +def audit_permissions( + include_system: bool = False, + db_paths: Optional[List[Path]] = None, +) -> PermissionsReport: + """Audit TCC permissions for the current user.""" + paths = db_paths or [USER_TCC_DB] + if include_system: + paths = paths + [SYSTEM_TCC_DB] + + entries: List[PermissionEntry] = [] + errors: List[str] = [] + for path in paths: + try: + entries.extend(_read_access_rows(path)) + except sqlite3.Error as exc: + errors.append(f"{path}: {exc}") + continue + + warnings: List[str] = [] + risky = [e for e in entries if e.service in RISKY_SERVICES and e.allowed] + if risky: + warnings.append( + f"{len(risky)} app(s) have sensitive permissions (Full Disk Access, Accessibility, Screen Recording)" + ) + if errors and not entries: + warnings.append("Permissions database could not be read. Full Disk Access may be required.") + + return PermissionsReport(entries=entries, warnings=warnings) diff --git a/src/core/safety.py b/src/core/safety.py index 3a9f0e4..6e27f33 100644 --- a/src/core/safety.py +++ b/src/core/safety.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Safety Module +Mac Deep Cleaner v1.5.0 — Safety Module ===================================== All safety checks, safelist lookups, and system-file protection logic. Ensures that system-critical files are NEVER deleted. diff --git a/src/core/scanner.py b/src/core/scanner.py index 5ddef2d..b4cfb94 100644 --- a/src/core/scanner.py +++ b/src/core/scanner.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Scanner Module +Mac Deep Cleaner v1.5.0 — Scanner Module ====================================== Core scanning logic for orphan detection and general junk discovery. """ diff --git a/src/core/scheduler.py b/src/core/scheduler.py index 19f3f19..2dc9f72 100644 --- a/src/core/scheduler.py +++ b/src/core/scheduler.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Notifications & Scheduler +Mac Deep Cleaner v1.5.0 — Notifications & Scheduler ================================================= Notifications diff --git a/src/core/system_inspector.py b/src/core/system_inspector.py index 34b21f7..5375c0f 100644 --- a/src/core/system_inspector.py +++ b/src/core/system_inspector.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — System Inspector +Mac Deep Cleaner v1.5.0 — System Inspector ======================================== Three sub-features bundled together because they share macOS system queries: diff --git a/src/core/undo.py b/src/core/undo.py index ffd429c..25cd96a 100644 --- a/src/core/undo.py +++ b/src/core/undo.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Undo / Restore (Staged Deletion) +Mac Deep Cleaner v1.5.0 — Undo / Restore (Staged Deletion) ======================================================== Instead of permanently deleting files, mac-cleaner moves them to a staging area (~/.mac_cleaner_trash/) with a JSON manifest so they can be restored. diff --git a/src/core/uninstaller.py b/src/core/uninstaller.py new file mode 100644 index 0000000..42a035b --- /dev/null +++ b/src/core/uninstaller.py @@ -0,0 +1,339 @@ +"""Full app uninstaller helpers.""" + +from __future__ import annotations + +import plistlib +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +from config.models import AppInfo +from constants import SEARCH_ROOTS +from core.safety import validate_path_for_deletion +from core.scanner import classify_root +from scanners.matching import match_to_app +from utils import iterdir_safe, size_of + + +@dataclass +class UninstallItem: + """One deletable (or protected) app artifact.""" + path: Path + category: str + size: int + protected: bool = False + reason: str = "" + + +@dataclass +class UninstallPlan: + """Resolved uninstall plan for a single app.""" + app: AppInfo + items: List[UninstallItem] = field(default_factory=list) + protected_items: List[UninstallItem] = field(default_factory=list) + + @property + def deletable_items(self) -> List[UninstallItem]: + return [i for i in self.items if not i.protected] + + @property + def total_size(self) -> int: + return sum(i.size for i in self.deletable_items) + + +@dataclass +class UninstallResult: + """Summary of an uninstall operation.""" + deleted: int = 0 + staged: int = 0 + skipped: int = 0 + bytes_freed: int = 0 + errors: List[str] = field(default_factory=list) + + +def _read_app_info(app_path: Path) -> AppInfo: + info_plist = app_path / "Contents" / "Info.plist" + if not info_plist.exists(): + info_plist = app_path / "Info.plist" + + name = app_path.stem + bundle_id = app_path.stem + + if info_plist.exists(): + try: + with open(info_plist, "rb") as f: + pl = plistlib.load(f) + bundle_id = pl.get("CFBundleIdentifier", bundle_id) + name = pl.get("CFBundleDisplayName") or pl.get("CFBundleName") or name + except (OSError, plistlib.InvalidFileException, ValueError): + pass + + return AppInfo(name=str(name), bundle_id=str(bundle_id), path=app_path) + + +def find_app_candidates(query: str, apps: Dict[str, AppInfo]) -> List[AppInfo]: + """Return matching installed apps for a query string or .app path.""" + q = query.strip() + candidates: List[AppInfo] = [] + + path = Path(q).expanduser() + if path.exists() and path.suffix == ".app": + app_info = _read_app_info(path) + if app_info.bundle_id in apps: + return [apps[app_info.bundle_id]] + return [app_info] + + q_lower = q.lower() + + for app in apps.values(): + if q_lower in (app.bundle_id, app.name_lower): + candidates.append(app) + continue + if q_lower in app.bundle_id or q_lower in app.name_lower: + candidates.append(app) + + if not candidates: + matched = match_to_app(q_lower, apps) + if matched: + candidates.append(matched) + + seen: Set[str] = set() + unique: List[AppInfo] = [] + for app in candidates: + if app.bundle_id in seen: + continue + seen.add(app.bundle_id) + unique.append(app) + + return sorted(unique, key=lambda a: a.name_lower) + + +def _identifier_set(app: AppInfo) -> Tuple[str, str, Set[str], str]: + bundle_id = app.bundle_id.lower().strip() + parts = [p for p in bundle_id.split(".") if p] + vendor = parts[1] if len(parts) >= 2 else "" + product = parts[-1] if parts else bundle_id + + tokens: Set[str] = set() + if len(product) >= 4: + tokens.add(product) + for word in re.split(r"[\s\-_]+", app.name_lower): + if len(word) >= 4: + tokens.add(word) + + return bundle_id, vendor, tokens, app.name_lower + + +def _matches_bundle_prefix(name: str, bundle_id: str) -> bool: + if not bundle_id: + return False + return name == bundle_id or name.startswith(bundle_id + ".") or name.startswith(bundle_id + "-") + + +def _matches_bundle_suffix(name: str, bundle_id: str) -> bool: + if not bundle_id: + return False + return name == bundle_id or name.endswith("." + bundle_id) or name.endswith(bundle_id) + + +def _matches_direct_name(name: str, bundle_id: str, tokens: Set[str], app_name: str) -> bool: + if _matches_bundle_prefix(name, bundle_id): + return True + if app_name and name == app_name: + return True + if name in tokens: + return True + return False + + +def _matches_vendor_child(name: str, bundle_id: str, tokens: Set[str]) -> bool: + if _matches_bundle_prefix(name, bundle_id): + return True + for token in tokens: + if name == token or name.startswith(token): + return True + return False + + +def _root_kind(root: Path) -> str: + s = str(root).lower().rstrip("/") + if "group containers" in s: + return "Group Containers" + if s.endswith("/containers"): + return "Containers" + if s.endswith("/saved application state"): + return "Saved State" + if s.endswith("/preferences"): + return "Preferences" + if s.endswith("/launchagents"): + return "Launch Agent" + if s.endswith("/launchdaemons"): + return "Launch Daemon" + if s.endswith("/privilegedhelpertools"): + return "Helper Tool" + if s.endswith("/httpstorages"): + return "HTTP Storage" + if s.endswith("/cookies"): + return "Cookies" + if s.endswith("/syncedpreferences"): + return "Synced Prefs" + if s.endswith("/webkit"): + return "WebKit Data" + return classify_root(root) + + +def _matching_paths( + root: Path, + bundle_id: str, + vendor: str, + tokens: Set[str], + app_name: str, +) -> List[Path]: + matches: List[Path] = [] + kind = _root_kind(root) + + for item in iterdir_safe(root): + name = item.name.lower() + + if kind == "Preferences": + if name.startswith(bundle_id) and item.suffix in {".plist", ".lockfile", ".plist.lockfile", ".plist.disabled"}: + matches.append(item) + continue + + if kind == "Saved State": + if name.startswith(bundle_id) and name.endswith(".savedstate"): + matches.append(item) + continue + + if kind == "Containers": + if name == bundle_id: + matches.append(item) + continue + + if kind == "Group Containers": + if _matches_bundle_suffix(name, bundle_id) or f".{bundle_id}" in name: + matches.append(item) + continue + + if kind in {"Launch Agent", "Launch Daemon", "Helper Tool"}: + if _matches_bundle_prefix(name, bundle_id) or f".{bundle_id}" in name: + matches.append(item) + continue + + if kind in {"HTTP Storage", "Cookies", "Synced Prefs", "WebKit Data"}: + if _matches_bundle_prefix(name, bundle_id) or _matches_bundle_suffix(name, bundle_id): + matches.append(item) + continue + + if vendor and name == vendor and item.is_dir(): + for child in iterdir_safe(item): + if _matches_vendor_child(child.name.lower(), bundle_id, tokens): + matches.append(child) + continue + + if _matches_direct_name(name, bundle_id, tokens, app_name): + matches.append(item) + + return matches + + +def _label_root(root: Path, default: str) -> str: + root_str = str(root).lower() + if "saved application state" in root_str: + return "Saved State" + return default + + +def build_uninstall_plan( + app: AppInfo, + whitelist_set: Optional[Set[Path]] = None, + roots: Optional[Iterable[Path]] = None, + keep_preferences: bool = False, +) -> UninstallPlan: + """Build an uninstall plan for the given app.""" + whitelist = whitelist_set or set() + plan = UninstallPlan(app=app) + seen: Set[Path] = set() + + def _add_item(path: Path, category: str) -> None: + if path in seen: + return + seen.add(path) + if not path.exists(): + return + if path in whitelist or any(wl in path.parents for wl in whitelist): + return + size = size_of(path) + if size <= 0: + return + safe, reason = validate_path_for_deletion(path) + item = UninstallItem( + path=path, + category=category, + size=size, + protected=not safe, + reason=reason, + ) + if item.protected: + plan.protected_items.append(item) + else: + plan.items.append(item) + + _add_item(app.path, "App Bundle") + + bundle_id, vendor, tokens, app_name = _identifier_set(app) + for root in roots or SEARCH_ROOTS: + if not root.exists(): + continue + category = _label_root(root, _root_kind(root)) + if keep_preferences and category in {"Preferences", "Saved State"}: + continue + for match in _matching_paths(root, bundle_id, vendor, tokens, app_name): + _add_item(match, category) + + plan.items.sort(key=lambda i: i.size, reverse=True) + plan.protected_items.sort(key=lambda i: i.size, reverse=True) + return plan + + +def execute_uninstall(plan: UninstallPlan, session=None) -> UninstallResult: + """Execute an uninstall plan (delete or stage).""" + from core.cleaner import write_deletion_log + from utils import safe_remove + + result = UninstallResult() + deleted_entries: List[tuple[str, int]] = [] + + if session is not None: + from core.undo import stage_file + + for item in plan.deletable_items: + safe, reason = validate_path_for_deletion(item.path) + if not safe: + result.skipped += 1 + result.errors.append(f"{item.path}: {reason}") + continue + + if session is not None: + ok, sz = stage_file(item.path, session, category=item.category) + if ok: + result.staged += 1 + result.bytes_freed += sz + deleted_entries.append((str(item.path), sz)) + else: + result.skipped += 1 + continue + + ok, sz = safe_remove(item.path) + if ok: + result.deleted += 1 + result.bytes_freed += sz + deleted_entries.append((str(item.path), sz)) + else: + result.skipped += 1 + + if deleted_entries: + write_deletion_log(deleted_entries) + + return result diff --git a/src/core/updater.py b/src/core/updater.py index eaccb26..091d3b9 100644 --- a/src/core/updater.py +++ b/src/core/updater.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Self-Update +Mac Deep Cleaner v1.5.0 — Self-Update =================================== Checks PyPI for a newer version and upgrades the package in-place using pip. diff --git a/src/reporting/exporter.py b/src/reporting/exporter.py index e8bca86..6fa7d15 100644 --- a/src/reporting/exporter.py +++ b/src/reporting/exporter.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Export Module +Mac Deep Cleaner v1.5.0 — Export Module ===================================== Exports scan results to JSON or YAML format. """ @@ -27,7 +27,7 @@ def export_json( ) -> None: """Export full scan results to JSON.""" data = { - "tool": "Mac Deep Cleaner v1.2.0", + "tool": "Mac Deep Cleaner v1.5.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: { @@ -84,7 +84,7 @@ def export_yaml( return data = { - "tool": "Mac Deep Cleaner v1.2.0", + "tool": "Mac Deep Cleaner v1.5.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: [e.to_dict() for e in entries] diff --git a/src/reporting/html_report.py b/src/reporting/html_report.py index cf71022..3bede8b 100644 --- a/src/reporting/html_report.py +++ b/src/reporting/html_report.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — HTML Report Exporter +Mac Deep Cleaner v1.5.0 — HTML Report Exporter ============================================ Generates a self-contained HTML report with: - Collapsible sections per category @@ -94,7 +94,7 @@

◆ Mac Deep Cleaner — Scan Report

-
Generated {generated_at}  ·  v1.2.0
+
Generated {generated_at}  ·  v1.5.0
@@ -144,7 +144,7 @@ - +