diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ diff --git a/CHANGELOG.md b/CHANGELOG.md index b71b04c..bc4b1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,33 +2,61 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. -## Unreleased +## v2.0.0 (2026-05-15) -## v1.5.0 (2026-05-12) ### Added -#### 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) +## Cross-cutting Work +- Add new CLI subcommands and options in src/cli.py +- Extend config schema in src/config/config.py for new features +- Add logging and safe-path validation in src/core/safety.py where needed +- Expand reporting exports in src/reporting for new outputs +- Add tests for parsers and non-destructive scanners in tests/ + +## External Dependencies (tentative) +- textual or prompt_toolkit for interactive TUI picker +- rumps for menu bar companion +- requests or urllib for HIBP API (prefer urllib to avoid new deps) +- pandas/pyarrow NOT planned (keep lightweight) + +## Safety Gates +- All destructive operations honor --dry-run and undo staging. +- Time Machine guard before bulk deletes. +- APFS snapshot support behind explicit flags. +- Restore checksum verification for staged files. -#### 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) +## Open Decisions (needs confirmation) +- Preferred TUI library (textual vs prompt_toolkit) +- Whether to add optional dependencies vs strict core only +- Handling sudo-required operations (auto prompt vs printed instructions) +- HIBP API key provisioning and storage +- Minimum supported macOS version for system commands +- CI mode via `mdc uninstall-cli` ti uninstall this package -#### 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 feature commands +- Added reporting utilities +- Updated Readme for new features +- Version bump to v2.0.0 across docs and UI + +## v1.5.0 (2026-05-12) +### Added +- Global --dry-run flag (src/core/dry_run.py) +- Shell completion command (src/core/completions.py) +- Full app uninstaller (src/core/uninstaller.py) +- Browser data cleaner (src/scanners/browser_data.py) +- Visual disk space map (src/scanners/space_map.py) +- Photo library analyzer (src/scanners/photos_analyzer.py) +- iOS simulator deep cleaner (src/scanners/simulators.py) +- Memory pressure reliever (src/core/memory_pressure.py) +- Homebrew deep manager (src/core/brew_manager.py) +- Storage trend tracker (src/reporting/storage_trend.py) +- Recent files and activity cleaner (src/scanners/recent_activity.py) +- Permissions auditor (src/core/permissions_auditor.py) +- APFS snapshot guard (src/core/apfs_snapshots.py) +- Menu bar companion (src/core/menubar.py) +- Data breach monitor (src/core/breach_monitor.py) +- Cloud storage junk scanner (src/scanners/cloud_junk.py) ### Changed - CLI wiring for new P0/P1 commands and dry-run behavior @@ -47,7 +75,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.5.0 across docs and UI +- Version bump to v1.2.0 across docs and UI ## v1.0.0 (2026-05-10) ### Added diff --git a/README.md b/README.md index 4476eee..7ac0190 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Mac Deep Cleaner v1.5.0 +# Mac Deep Cleaner **Professional macOS cleanup tool — Smart App Orphan Detector** @@ -42,12 +42,27 @@ mdc scan - **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) +- **Purgeable space reclaimer** — snapshot thinning and purgeable status +- **Installer & PKG hunter** — find old installers and disk images +- **DNS cache flush** — refresh name resolution caches +- **Font cache rebuild** — rebuild ATS caches safely +- **Spotlight re-index** — rebuild metadata index on demand +- **Sleep & power optimizer** — apply recommended pmset settings +- **App update checker** — system, Homebrew, and App Store updates +- **PKG receipt manager** — list and forget pkgutil receipts +- **Xcode derived data cleaner** — DerivedData, Archives, caches - **Permissions auditor** — TCC privacy access audit (read-only) +- **Time Machine backup guard** — status, age checks, local snapshots - **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 +- **Weekly digest report** — aggregated weekly scan summary +- **Cleaning impact score** — score reclaimable impact per scan +- **Interactive TUI app picker** — choose apps with keyboard search +- **Multi-Mac config sync** — export/import config across devices +- **Restore checksum verification** — verify restores via `undo --verify` - **macOS notifications** — via `osascript`, no dependencies - **CI mode** — JSON-only scan summary with threshold-based exit code - **Live TUI dashboard** — Rich Live/Layout summary while a scan is running @@ -171,6 +186,14 @@ mac-cleaner duplicates --path ~/Movies --min-size 500 mac-cleaner large-files mac-cleaner large-files --min-mb 50 --export large.json +# Installer and PKG hunter +mac-cleaner installer-hunter --min-age-days 30 +mac-cleaner installer-hunter --delete --yes + +# Xcode derived data cleaner +mac-cleaner xcode-cleaner +mac-cleaner xcode-cleaner --delete --yes + # Find broken symlinks mac-cleaner symlinks mac-cleaner symlinks --delete @@ -204,6 +227,15 @@ mac-cleaner diff mac-cleaner diff abc12345 def67890 ``` +### Reports +```bash +# Weekly digest report +mac-cleaner weekly-digest --days 7 + +# Cleaning impact score +mac-cleaner impact-score +``` + ### Undo ```bash # List staged deletion sessions @@ -215,6 +247,9 @@ mac-cleaner undo # Restore a specific session mac-cleaner undo --session abc12345 +# Restore with checksum verification +mac-cleaner undo --verify + # Purge old staged files mac-cleaner undo --purge ``` @@ -227,12 +262,34 @@ mac-cleaner system --login-items mac-cleaner system --health ``` +### TUI app picker +```bash +mac-cleaner tui-picker +mac-cleaner tui-picker --uninstall --yes +``` + ### P2/P3 system utilities ```bash # Memory pressure mac-cleaner memory-pressure mac-cleaner memory-pressure --relieve +# Purgeable space +mac-cleaner purgeable +mac-cleaner purgeable --thin-gb 10 --yes + +# DNS cache +mac-cleaner dns-cache --flush + +# Font cache +mac-cleaner font-cache --rebuild --clear-user + +# Spotlight +mac-cleaner spotlight --reindex + +# Power optimizer +mac-cleaner power-optimizer --apply + # Homebrew manager mac-cleaner brew --outdated mac-cleaner brew --cleanup --yes @@ -249,10 +306,19 @@ mac-cleaner recent-activity --clear mac-cleaner permissions mac-cleaner permissions --system --export tcc.json +# App updates +mac-cleaner app-updates --all + +# PKG receipts +mac-cleaner pkg-receipts --search com.apple --details + # APFS snapshots mac-cleaner snapshots mac-cleaner snapshots --delete-older-than 14 --yes +# Time Machine guard +mac-cleaner time-machine + # Menu bar companion mac-cleaner menubar install --interval 15 mac-cleaner menubar status --format swiftbar @@ -287,6 +353,13 @@ mac-cleaner config --init # create default config file mac-cleaner config --show # print resolved settings ``` +### Config sync +```bash +mac-cleaner config-sync export +mac-cleaner config-sync import +mac-cleaner config-sync status +``` + --- ## Configuration (`~/.config/mac-cleaner/config.yaml`) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9386be4..d5e0258 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,4 +1,4 @@ -# Mac Deep Cleaner — Features (v1.5.0) +# Mac Deep Cleaner — Features (v2.0.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 5c3bd45..6aa4c92 100644 --- a/docs/PYPI_PUBLISHING.md +++ b/docs/PYPI_PUBLISHING.md @@ -1,4 +1,4 @@ -# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v1.5.0) +# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v2.0.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.5.0 +## Quick checklist for v2.0.0 - `pyproject.toml` → `project.version = "1.2.0"` -- `README.md` / docs reflect v1.5.0 +- `README.md` / docs reflect v2.0.0 - `python3 -m build` produces valid wheel + sdist - `twine upload dist/*` succeeds diff --git a/docs/test.md b/docs/test.md index 3e7541b..a26a303 100644 --- a/docs/test.md +++ b/docs/test.md @@ -345,3 +345,4 @@ Default behavior: - Use `scan` first to preview what would be removed. - Use `clean` with undo enabled for safer cleanup. - Use `undo --list` to view staging sessions and `undo --session ` to restore. +- rg -0 -l "v1\.2\.0" -g '!build/**' -g '!.venv/**' | xargs -0 perl -pi -e 's/v1\.2\.0/v2.0.0/g' ( to change the v directly with finding patterns) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 22bce5d..30dcf1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mac-deep-cleaner" -version = "1.5.0" +version = "2.0.0" description = "Professional Mac cleanup tool — Smart App Orphan Detector" readme = "README.md" license = "Apache-2.0" diff --git a/roadmap.md b/roadmap.md index b6da033..d25a750 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,37 +1,9 @@ # Mac Deep Cleaner v2.x Roadmap -Date: 2026-05-11 +Date: 2026-05-15 ## Goals - Ship a full v2.x feature set with professional-grade safety, logging, and undo support. - Keep destructive actions opt-in and gated by explicit flags and confirmations. - Keep new dependencies minimal and justified; document when optional. - Add a feature module per roadmap item, grouped by domain. - -## Proposed Feature Modules (one per feature) - -## Cross-cutting Work -- Add new CLI subcommands and options in src/cli.py -- Extend config schema in src/config/config.py for new features -- Add logging and safe-path validation in src/core/safety.py where needed -- Expand reporting exports in src/reporting for new outputs -- Add tests for parsers and non-destructive scanners in tests/ - -## External Dependencies (tentative) -- textual or prompt_toolkit for interactive TUI picker -- rumps for menu bar companion -- requests or urllib for HIBP API (prefer urllib to avoid new deps) -- pandas/pyarrow NOT planned (keep lightweight) - -## Safety Gates -- All destructive operations honor --dry-run and undo staging. -- Time Machine guard before bulk deletes. -- APFS snapshot support behind explicit flags. -- Restore checksum verification for staged files. - -## Open Decisions (needs confirmation) -- Preferred TUI library (textual vs prompt_toolkit) -- Whether to add optional dependencies vs strict core only -- Handling sudo-required operations (auto prompt vs printed instructions) -- HIBP API key provisioning and storage -- Minimum supported macOS version for system commands diff --git a/scripts/build.sh b/scripts/build.sh index 809aef3..3027cce 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# Mac Deep Cleaner v1.5.0 — Build & Install Script +# Mac Deep Cleaner v2.0.0 — Build & Install Script # ============================================================================= # Usage: # bash build.sh → default: build wheel + sdist diff --git a/src/__init__.py b/src/__init__.py index c049eed..af1e5c1 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,7 +1,6 @@ """ -Mac Deep Cleaner v1.5.0 — Professional Edition Smart App Orphan Detector & System Cleanup Tool for macOS """ -__version__ = "1.5.0" +__version__ = "2.0.0" __author__ = "NK2552003" diff --git a/src/cli.py b/src/cli.py index 8b3c0b5..1fbfe70 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """ -Mac Deep Cleaner v1.5.0 — CLI Entry Point -======================================= All subcommands, new and updated. Subcommands @@ -11,6 +9,7 @@ info Safety guarantees completions Generate shell completion scripts uninstall Full app uninstaller + uninstall-cli Uninstall mac-cleaner CLI and data browser-data Clean browser caches/history/cookies space-map Visual disk space map photos Photo library analyzer @@ -28,6 +27,7 @@ brew Homebrew manager (cache + cleanup) storage-trend Storage usage trend tracker recent-activity Recent files/activity scanner + developer Developer junk scanner / cleanup permissions Audit macOS privacy permissions (TCC) snapshots APFS local snapshot guard menubar Menu bar companion (SwiftBar/xbar) @@ -36,6 +36,20 @@ schedule Install / remove / status of weekly scan update Check for and apply upgrades config Show / init config file + purgeable Purgeable space reclaimer + installer-hunter Find old installers and PKG files + dns-cache Flush DNS cache + font-cache Rebuild font cache + spotlight Re-index Spotlight + power-optimizer Sleep and power optimizer + app-updates App update checker + pkg-receipts PKG receipt manager + xcode-cleaner Xcode derived data cleaner + weekly-digest Weekly digest report + impact-score Cleaning impact score + tui-picker Interactive TUI app picker + config-sync Multi-Mac config sync + time-machine Time Machine backup guard """ from __future__ import annotations @@ -44,7 +58,7 @@ import sys from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Tuple import click from rich.console import Console @@ -126,8 +140,11 @@ def _ensure_first_run_profile(profile: Optional[str], ci: bool) -> Optional[str] table = Table(show_header=True, header_style="bold cyan", border_style="dim") table.add_column("Profile", style="bold") table.add_column("Focus") + table.add_column("Recommended", justify="center", width=12) + recommended = "beginner" for name in choices: - table.add_row(name, descriptions.get(name, "")) + rec = "yes" if name == recommended else "" + table.add_row(name, descriptions.get(name, ""), rec) console.print() console.print(Panel( @@ -165,7 +182,7 @@ def main( log_file: Optional[str], dry_run: bool, ) -> None: - """Mac Deep Cleaner v1.5.0 — Professional macOS cleanup tool.""" + """Mac Deep Cleaner v2.0.0 — Professional macOS cleanup tool.""" from core.dry_run import set_dry_run configure_logging( verbose=verbose, @@ -513,6 +530,138 @@ def clean( ) +# ══════════════════════════════════════════════════════════════════════════════ +# DEVELOPER JUNK +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("developer") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Roots to scan for developer junk (default: config + common project folders).") +@click.option("--max-depth", default=None, type=int, + help="Max depth for dev junk scanning (default: config value).") +@click.option("--global", "include_global", is_flag=True, default=False, + help="Include global caches (~/.npm, ~/.gradle, etc).") +@click.option("--limit", default=0, show_default=True, + help="Limit number of items returned (0 = no limit).") +@click.option("--delete", is_flag=True, default=False, + help="Delete detected developer junk.") +@click.option("--no-undo", is_flag=True, default=False, + help="Permanently delete instead of staging for undo.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export report to JSON.") +@click.option("--profile", default=None, help="Config profile to use.") +@click.pass_context +def cmd_developer( + ctx: click.Context, + roots: Tuple[str, ...], + max_depth: Optional[int], + include_global: bool, + limit: int, + delete: bool, + no_undo: bool, + yes: bool, + export_path: Optional[str], + profile: Optional[str], +) -> None: + """Scan and optionally clean developer junk (node_modules, venv, build dirs).""" + from core.dry_run import skip_if_dry_run + from core.safety import validate_path_for_deletion + from scanners.dev_junk import find_dev_junk + + cfg = load_config(profile=profile) + wl = cfg.whitelist_set + roots_list = [Path(p).expanduser().resolve() for p in roots] or (cfg.dev_junk_roots or None) + depth = max_depth if max_depth is not None else cfg.dev_junk_max_depth + include_global = include_global or bool(getattr(cfg, "scan_dev_junk_global", False)) + limit_val = None if limit <= 0 else limit + + console.print() + console.print(Panel("[bold cyan]Developer Junk[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + with _progress() as prog: + task = prog.add_task("Scanning developer junk...", total=None) + entries = find_dev_junk( + roots=roots_list, + max_depth=depth, + limit=limit_val, + include_global=include_global, + ) + prog.update(task, completed=100, total=100) + + if wl: + entries = [ + e for e in entries + if e.path not in wl and not any(w in e.path.parents for w in wl) + ] + + total = print_dev_junk_report(entries) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "total_bytes": total, + "total_human": bytes_human(total), + "entries": [e.to_dict() for e in entries], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2, default=str) + console.print(f"\n [green]Exported to {export_path}[/green]") + + if not delete or not entries: + return + + if skip_if_dry_run(ctx, console, "developer junk cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask( + f"Delete developer junk ({bytes_human(total)})?", + default=False, + ) + if not do_it: + return + + if cfg.undo_mode and not no_undo: + session = new_session() + freed = 0 + for e in entries: + safe, _ = validate_path_for_deletion(e.path) + if safe: + ok, sz = stage_file(e.path, session, category="Dev Junk") + if ok: + freed += sz + session.save() + console.print( + f"\n [green]Staged {bytes_human(freed)} for undo[/green]" + ) + console.print( + f" [dim]Restore with: mac-cleaner undo --session {session.session_id[:8]}[/dim]" + ) + return + + from core.cleaner import write_deletion_log + from utils import safe_remove + + freed = 0 + deleted = [] + for e in entries: + safe, _ = validate_path_for_deletion(e.path) + if safe: + ok, sz = safe_remove(e.path) + if ok: + freed += sz + deleted.append((str(e.path), sz)) + if deleted: + write_deletion_log(deleted) + console.print(f"\n [green]Removed {bytes_human(freed)}[/green]") + + # ══════════════════════════════════════════════════════════════════════════════ # INFO # ══════════════════════════════════════════════════════════════════════════════ @@ -825,12 +974,18 @@ def cmd_space_map( @main.command("photos") @click.option("--root", "roots", multiple=True, type=click.Path(exists=True), help="Search roots for Photos libraries (default: ~/Pictures).") +@click.option("--anywhere", is_flag=True, default=False, + help="Search recursively under roots (or your home folder when no roots are provided).") +@click.option("--depth", default=6, show_default=True, + help="Max recursion depth for --anywhere searches.") @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, ...], + anywhere: bool, + depth: int, details: bool, export_path: Optional[str], ) -> None: @@ -838,8 +993,24 @@ def cmd_photos( 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 roots: + search_roots = [Path(p).expanduser().resolve() for p in roots] + else: + search_roots = [HOME] if anywhere else [HOME / "Pictures"] + + libs = find_photo_libraries( + search_roots=search_roots, + recursive=anywhere, + max_depth=depth, + ) + if not libs and not anywhere and not roots: + libs = find_photo_libraries( + search_roots=[HOME], + recursive=True, + max_depth=depth, + ) + if libs: + console.print("[dim]No libraries in ~/Pictures; searched your home folder instead.[/dim]") if not libs: console.print("[yellow]No Photos libraries found in the selected roots.[/yellow]") return @@ -1458,6 +1629,8 @@ 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.") +@click.option("--verify", is_flag=True, default=False, + help="Verify checksums after restore.") @click.pass_context def cmd_undo( ctx: click.Context, @@ -1465,6 +1638,7 @@ def cmd_undo( session_id: Optional[str], purge: bool, purge_all: bool, + verify: bool, ) -> None: """Restore files from the staging area (undo a clean operation). @@ -1552,15 +1726,29 @@ def cmd_undo( f"({len(target.files)} files, {target.total_size_human})?", default=False, ): - result = restore_session(target) - console.print( - f"\n [green]✓ Restored {result.restored} file(s) " - f"({bytes_human(result.bytes_restored)})[/green]" - ) - if result.failed: - console.print(f" [yellow]⚠ {result.failed} file(s) could not be restored[/yellow]") - for err in result.errors: - console.print(f" [dim]{err}[/dim]") + if verify: + from core.restore_checksums import restore_with_verification + vresult = restore_with_verification(target) + console.print( + f"\n [green]✓ Restored {vresult.restored} file(s) " + f"({bytes_human(vresult.bytes_restored)})[/green]" + ) + if vresult.mismatched: + console.print(f" [yellow]⚠ {vresult.mismatched} checksum mismatch(es)[/yellow]") + if vresult.failed: + console.print(f" [yellow]⚠ {vresult.failed} file(s) failed to restore[/yellow]") + for err in vresult.errors: + console.print(f" [dim]{err}[/dim]") + else: + result = restore_session(target) + console.print( + f"\n [green]✓ Restored {result.restored} file(s) " + f"({bytes_human(result.bytes_restored)})[/green]" + ) + if result.failed: + console.print(f" [yellow]⚠ {result.failed} file(s) could not be restored[/yellow]") + for err in result.errors: + console.print(f" [dim]{err}[/dim]") # ══════════════════════════════════════════════════════════════════════════════ @@ -2071,6 +2259,979 @@ def cmd_recent_activity(ctx: click.Context, clear: bool, yes: bool) -> None: console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") +# ══════════════════════════════════════════════════════════════════════════════ +# PURGEABLE SPACE +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("purgeable") +@click.option("--volume", default="/", show_default=True, + help="Volume path to inspect.") +@click.option("--thin-gb", default=None, type=int, + help="Reclaim at least this many GB using tmutil thinning.") +@click.option("--thin-mb", default=None, type=int, + help="Reclaim at least this many MB using tmutil thinning.") +@click.option("--delete-older-than", default=None, type=int, + help="Delete local 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 prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export summary to JSON.") +@click.pass_context +def cmd_purgeable( + ctx: click.Context, + volume: str, + thin_gb: Optional[int], + thin_mb: Optional[int], + delete_older_than: Optional[int], + keep: Optional[int], + yes: bool, + export_path: Optional[str], +) -> None: + """Inspect purgeable space and reclaim via snapshot thinning.""" + from core.dry_run import skip_if_dry_run + from scanners.purgeable import ( + collect_purgeable_sources, + delete_snapshots_by_policy, + summarize_sources, + thin_local_snapshots, + ) + + sources = collect_purgeable_sources(volume) + + console.print() + console.print(Panel("[bold cyan]Purgeable Space[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Source", min_width=24) + table.add_column("Detail", min_width=24) + table.add_column("Size", justify="right", style="yellow", width=12) + for s in sources: + size_label = s.size_human if s.bytes > 0 else "unknown" + table.add_row(s.name, s.detail, size_label) + console.print(table) + + if export_path: + import json + with open(export_path, "w") as f: + json.dump(summarize_sources(sources), f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + do_thin = thin_gb is not None or thin_mb is not None + do_delete = delete_older_than is not None or keep is not None + if not (do_thin or do_delete): + return + + if skip_if_dry_run(ctx, console, "purgeable cleanup"): + return + + if not yes: + from rich.prompt import Confirm + if not Confirm.ask("Proceed with purgeable cleanup?", default=False): + return + + if do_thin: + target_bytes = 0 + if thin_gb is not None: + target_bytes = thin_gb * 1024 * 1024 * 1024 + elif thin_mb is not None: + target_bytes = thin_mb * 1024 * 1024 + result = thin_local_snapshots(volume, target_bytes) + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if do_delete: + deleted, total = delete_snapshots_by_policy( + volume, + keep=keep, + older_than_days=delete_older_than, + ) + console.print(f" Deleted {deleted}/{total} snapshot(s)") + + +# ══════════════════════════════════════════════════════════════════════════════ +# INSTALLER HUNTER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("installer-hunter") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Roots to scan (default: Downloads/Desktop/Documents).") +@click.option("--min-age-days", default=None, type=int, + help="Only show installers older than N days.") +@click.option("--min-mb", default=0, show_default=True, + help="Minimum size in MB.") +@click.option("--include-archives", is_flag=True, default=False, + help="Include .zip/.tar archives.") +@click.option("--limit", default=200, show_default=True, + help="Maximum results to show.") +@click.option("--delete", is_flag=True, default=False, + help="Delete installers under allowed roots.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +@click.pass_context +def cmd_installer_hunter( + ctx: click.Context, + roots: Tuple[str, ...], + min_age_days: Optional[int], + min_mb: int, + include_archives: bool, + limit: int, + delete: bool, + yes: bool, + export_path: Optional[str], +) -> None: + """Find old installers and PKG files.""" + from core.dry_run import skip_if_dry_run + from scanners.installer_hunter import delete_installers, find_installers + + root_paths = [Path(p).expanduser().resolve() for p in roots] or None + items = find_installers( + roots=root_paths, + min_age_days=min_age_days, + min_size_mb=min_mb, + include_archives=include_archives, + limit=limit, + ) + + console.print() + console.print(Panel("[bold cyan]Installer Hunter[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not items: + console.print("[green]No installers found.[/green]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Kind", width=12) + table.add_column("Age (days)", justify="right", width=9) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + for item in items: + table.add_row(item.kind, str(item.age_days), item.size_human, str(item.path)) + console.print(table) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "count": len(items), + "items": [ + { + "path": str(i.path), + "size": i.size, + "kind": i.kind, + "modified_at": i.modified_at, + "age_days": i.age_days, + } + for i in items + ], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + if not delete: + return + + if skip_if_dry_run(ctx, console, "installer cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Delete installer files?", default=False) + if not do_it: + return + + result = delete_installers(items, allowed_roots=root_paths) + console.print( + f"\n [green]Deleted {result.deleted} file(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# XCODE CLEANER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("xcode-cleaner") +@click.option("--category", "categories", multiple=True, + help="Limit cleanup to matching categories.") +@click.option("--delete", is_flag=True, default=False, + help="Delete selected Xcode data.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +@click.pass_context +def cmd_xcode_cleaner( + ctx: click.Context, + categories: Tuple[str, ...], + delete: bool, + yes: bool, + export_path: Optional[str], +) -> None: + """Inspect and clean Xcode derived data and caches.""" + from core.dry_run import skip_if_dry_run + from scanners.xcode_cleaner import collect_xcode_junk, delete_xcode_junk + + items = collect_xcode_junk() + console.print() + console.print(Panel("[bold cyan]Xcode Cleaner[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not items: + console.print("[green]No Xcode caches found.[/green]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Category", min_width=20) + table.add_column("Size", justify="right", style="yellow", width=12) + table.add_column("Path", style="dim") + for item in items: + table.add_row(item.category, bytes_human(item.size), str(item.path)) + console.print(table) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "items": [ + { + "category": i.category, + "path": str(i.path), + "size": i.size, + } + for i in items + ], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + if not delete: + return + + if skip_if_dry_run(ctx, console, "Xcode cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Delete Xcode caches?", default=False) + if not do_it: + return + + result = delete_xcode_junk(items, categories=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[/dim]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# DNS CACHE +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("dns-cache") +@click.option("--flush", is_flag=True, default=False, + help="Flush DNS caches.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_dns_cache(ctx: click.Context, flush: bool, yes: bool) -> None: + """Flush DNS caches.""" + from core.dns_cache import flush_dns_cache + from core.dry_run import skip_if_dry_run + + if not flush: + console.print("Use --flush to clear DNS caches.") + return + + if skip_if_dry_run(ctx, console, "DNS cache flush"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Flush DNS cache now?", default=False) + if not do_it: + return + + result = flush_dns_cache() + if result.success: + console.print("[green]DNS cache flushed.[/green]") + else: + console.print("[yellow]DNS cache flush may be incomplete.[/yellow]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# FONT CACHE +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("font-cache") +@click.option("--rebuild", is_flag=True, default=False, + help="Rebuild font caches using atsutil.") +@click.option("--clear-user", is_flag=True, default=False, + help="Delete user font cache folders before rebuild.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_font_cache(ctx: click.Context, rebuild: bool, clear_user: bool, yes: bool) -> None: + """Rebuild font caches.""" + from core.dry_run import skip_if_dry_run + from core.font_cache import rebuild_font_cache + + if not rebuild: + console.print("Use --rebuild to rebuild font caches.") + return + + if skip_if_dry_run(ctx, console, "font cache rebuild"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Rebuild font caches now?", default=False) + if not do_it: + return + + result = rebuild_font_cache(clear_user=clear_user) + color = "green" if result.success else "red" + console.print(f"[{color}]{'Font cache rebuilt' if result.success else 'Font cache rebuild failed'}[/{color}]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# SPOTLIGHT +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("spotlight") +@click.option("--volume", default="/", show_default=True, + help="Volume path to inspect.") +@click.option("--reindex", is_flag=True, default=False, + help="Rebuild Spotlight index.") +@click.option("--enable", is_flag=True, default=False, + help="Enable Spotlight indexing.") +@click.option("--disable", is_flag=True, default=False, + help="Disable Spotlight indexing.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_spotlight( + ctx: click.Context, + volume: str, + reindex: bool, + enable: bool, + disable: bool, + yes: bool, +) -> None: + """Inspect or rebuild Spotlight index.""" + from core.dry_run import skip_if_dry_run + from core.spotlight import get_spotlight_status, reindex_spotlight, set_spotlight_indexing + + status = get_spotlight_status(volume) + console.print() + console.print(Panel("[bold cyan]Spotlight[/bold cyan]", + border_style="cyan", padding=(0, 2))) + enabled_label = "enabled" if status.enabled else "disabled" + console.print(f" Status: {enabled_label} ({status.raw})") + + if not (reindex or enable or disable): + return + + if skip_if_dry_run(ctx, console, "Spotlight update"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Proceed with Spotlight changes?", default=False) + if not do_it: + return + + if enable: + ok = set_spotlight_indexing(volume, True) + console.print(" Enabled" if ok else " Enable failed") + if disable: + ok = set_spotlight_indexing(volume, False) + console.print(" Disabled" if ok else " Disable failed") + if reindex: + ok = reindex_spotlight(volume) + console.print(" Reindex started" if ok else " Reindex failed") + + +# ══════════════════════════════════════════════════════════════════════════════ +# POWER OPTIMIZER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("power-optimizer") +@click.option("--apply", "apply_changes", is_flag=True, default=False, + help="Apply recommended power settings.") +@click.option("--restore", is_flag=True, default=False, + help="Restore last saved power profile.") +@click.option("--scope", default="all", + type=click.Choice(["all", "battery", "ac"], case_sensitive=False), + show_default=True) +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_power_optimizer( + ctx: click.Context, + apply_changes: bool, + restore: bool, + scope: str, + yes: bool, +) -> None: + """Show or apply power optimization settings.""" + from core.dry_run import skip_if_dry_run + from core.power_optimizer import ( + apply_recommended, + diff_recommendations, + get_power_profile, + restore_profile, + ) + + profile = get_power_profile() + if profile is None: + console.print("[yellow]Unable to read power settings.[/yellow]") + return + + console.print() + console.print(Panel("[bold cyan]Power Optimizer[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Setting", min_width=18) + table.add_column("Battery", justify="right", width=10) + table.add_column("AC", justify="right", width=10) + keys = sorted(set(profile.battery.keys()) | set(profile.ac.keys())) + for key in keys: + table.add_row(key, profile.battery.get(key, "-"), profile.ac.get(key, "-")) + console.print(table) + + changes = diff_recommendations(profile, scope=scope) + if changes: + console.print(f"\n Recommended changes: {len(changes)}") + for change in changes[:10]: + console.print(f" {change.key}: {change.current} -> {change.recommended}") + if len(changes) > 10: + console.print(f" ... {len(changes) - 10} more") + else: + console.print("\n No recommended changes needed.") + + if not (apply_changes or restore): + return + + if skip_if_dry_run(ctx, console, "power settings update"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + action_label = "restore" if restore else "apply" + do_it = Confirm.ask(f"Proceed to {action_label} power settings?", default=False) + if not do_it: + return + + result = restore_profile(scope=scope) if restore else apply_recommended(scope=scope) + color = "green" if result.success else "red" + console.print(f"[{color}]{result.message}[/{color}]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# APP UPDATES +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("app-updates") +@click.option("--system", is_flag=True, default=False, + help="Check macOS software updates.") +@click.option("--brew", is_flag=True, default=False, + help="Check Homebrew updates.") +@click.option("--mas", is_flag=True, default=False, + help="Check Mac App Store updates (requires mas).") +@click.option("--all", "all_checks", is_flag=True, default=False, + help="Run all checks (default).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +def cmd_app_updates( + system: bool, + brew: bool, + mas: bool, + all_checks: bool, + export_path: Optional[str], +) -> None: + """Check for app updates across system, brew, and App Store.""" + from core.update_checker import collect_update_report + + if not (system or brew or mas or all_checks): + all_checks = True + + report = collect_update_report() + + console.print() + console.print(Panel("[bold cyan]App Updates[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if all_checks or system: + console.print(f"\n macOS updates: {len(report.system_updates)}") + for item in report.system_updates[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.system_updates) > 10: + console.print(f" [dim]... {len(report.system_updates) - 10} more[/dim]") + + if all_checks or brew: + console.print(f"\n Homebrew formulae: {len(report.brew_formulae)}") + for item in report.brew_formulae[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.brew_formulae) > 10: + console.print(f" [dim]... {len(report.brew_formulae) - 10} more[/dim]") + console.print(f"\n Homebrew casks: {len(report.brew_casks)}") + for item in report.brew_casks[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.brew_casks) > 10: + console.print(f" [dim]... {len(report.brew_casks) - 10} more[/dim]") + + if all_checks or mas: + console.print(f"\n Mac App Store updates: {len(report.mas_updates)}") + for item in report.mas_updates[:10]: + console.print(f" [dim]- {item}[/dim]") + if len(report.mas_updates) > 10: + console.print(f" [dim]... {len(report.mas_updates) - 10} more[/dim]") + + if report.errors: + console.print("\n [yellow]Warnings:[/yellow]") + for err in report.errors: + console.print(f" [dim]{err}[/dim]") + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "system_updates": report.system_updates, + "brew_formulae": report.brew_formulae, + "brew_casks": report.brew_casks, + "mas_updates": report.mas_updates, + "errors": report.errors, + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# PKG RECEIPTS +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("pkg-receipts") +@click.option("--search", default=None, + help="Filter receipts by substring.") +@click.option("--limit", default=30, show_default=True, + help="Limit results.") +@click.option("--details", is_flag=True, default=False, + help="Show detailed receipt info.") +@click.option("--forget", "forget_id", default=None, + help="Forget a pkg receipt by identifier.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export receipts to JSON.") +@click.pass_context +def cmd_pkg_receipts( + ctx: click.Context, + search: Optional[str], + limit: int, + details: bool, + forget_id: Optional[str], + yes: bool, + export_path: Optional[str], +) -> None: + """Inspect and manage pkg receipts.""" + from core.dry_run import skip_if_dry_run + from core.pkg_receipts import forget_receipt, get_receipt_info, list_receipts + + if forget_id: + if skip_if_dry_run(ctx, console, "pkgutil forget"): + return + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask(f"Forget receipt {forget_id}?", default=False) + if not do_it: + return + ok, msg = forget_receipt(forget_id) + color = "green" if ok else "red" + console.print(f"[{color}]{msg}[/{color}]") + return + + receipts = list_receipts(search=search, limit=limit) + console.print() + console.print(Panel("[bold cyan]PKG Receipts[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not receipts: + console.print("[yellow]No receipts found.[/yellow]") + return + + if details: + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Identifier", min_width=36) + table.add_column("Version", width=12) + table.add_column("Installed", width=20) + for receipt_id in receipts: + info = get_receipt_info(receipt_id) + if info: + table.add_row( + info.identifier, + info.version or "-", + (info.install_time or "-")[:19], + ) + console.print(table) + else: + for receipt_id in receipts: + console.print(f" {receipt_id}") + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "receipts": receipts, + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# TIME MACHINE GUARD +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("time-machine") +@click.option("--enable", is_flag=True, default=False, + help="Enable Time Machine backups.") +@click.option("--disable", is_flag=True, default=False, + help="Disable Time Machine backups.") +@click.option("--warn-days", default=7, show_default=True, + help="Warn if last backup is older than N days.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export status to JSON.") +@click.pass_context +def cmd_time_machine( + ctx: click.Context, + enable: bool, + disable: bool, + warn_days: int, + export_path: Optional[str], +) -> None: + """Inspect and guard Time Machine status.""" + from core.dry_run import skip_if_dry_run + from core.time_machine_guard import disable_time_machine, enable_time_machine, get_time_machine_status + + console.print() + console.print(Panel("[bold cyan]Time Machine Guard[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if enable or disable: + if skip_if_dry_run(ctx, console, "Time Machine toggle"): + return + ok, msg = (enable_time_machine() if enable else disable_time_machine()) + color = "green" if ok else "red" + console.print(f"[{color}]{msg}[/{color}]") + + status = get_time_machine_status() + console.print(f"\n Destinations: {len(status.destinations)}") + for dest in status.destinations: + console.print(f" [dim]- {dest}[/dim]") + console.print(f" Local snapshots: {status.local_snapshot_count}") + if status.last_backup: + age = status.last_backup_age_days + console.print(f" Latest backup: {status.last_backup}") + if age is not None and age > warn_days: + console.print(f" [yellow]Warning: last backup is {age} days old[/yellow]") + else: + console.print(" [yellow]No recent backups detected[/yellow]") + + if export_path: + import json + payload = status.to_dict() + payload["generated_at"] = __import__("datetime").datetime.now().isoformat() + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# WEEKLY DIGEST +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("weekly-digest") +@click.option("--days", default=7, show_default=True, + help="Number of days to include.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export digest to JSON.") +def cmd_weekly_digest(days: int, export_path: Optional[str]) -> None: + """Generate a weekly scan digest report.""" + from config.history import list_history + from reporting.weekly_digest import generate_weekly_digest + + records = list_history(limit=200) + digest = generate_weekly_digest(records, days=days) + + console.print() + console.print(Panel("[bold cyan]Weekly Digest[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not digest: + console.print("[yellow]No scans found in the selected time range.[/yellow]") + return + + console.print(f" Range: {digest.start} -> {digest.end}") + console.print(f" Scans: {digest.scan_count}") + console.print(f" Total reclaimable: {digest.total_reclaimable_human}") + console.print(f" Avg per scan: {digest.avg_reclaimable_human}") + + if digest.top_orphan_apps: + console.print("\n Top orphaned apps:") + for name, size in digest.top_orphan_apps: + console.print(f" [dim]- {name} ({bytes_human(size)})[/dim]") + + if digest.top_junk_categories: + console.print("\n Top junk categories:") + for name, size in digest.top_junk_categories: + console.print(f" [dim]- {name} ({bytes_human(size)})[/dim]") + + if digest.top_dev_categories: + console.print("\n Top dev junk:") + for name, size in digest.top_dev_categories: + console.print(f" [dim]- {name} ({bytes_human(size)})[/dim]") + + if export_path: + import json + payload = digest.to_dict() + payload["generated_at"] = __import__("datetime").datetime.now().isoformat() + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# IMPACT SCORE +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("impact-score") +@click.option("--scan-id", default=None, + help="Scan ID prefix to score (default: latest).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export impact score to JSON.") +def cmd_impact_score(scan_id: Optional[str], export_path: Optional[str]) -> None: + """Compute a cleaning impact score from scan history.""" + from config.history import list_history + from reporting.impact_score import compute_impact_from_summary + + records = list_history(limit=50) + if not records: + console.print("[yellow]No scan history found.[/yellow]") + return + + target = None + if scan_id: + for r in records: + if r.scan_id.startswith(scan_id): + target = r + break + else: + target = records[0] + + if not target: + console.print("[yellow]Scan not found.[/yellow]") + return + + summary = target.summary + score = compute_impact_from_summary( + orphan_bytes=target.orphan_bytes, + junk_bytes=target.junk_bytes, + dev_bytes=target.dev_junk_bytes, + orphan_count=int(summary.get("orphan_count", 0)), + junk_count=int(summary.get("junk_count", 0)), + dev_count=int(summary.get("dev_junk_count", 0)), + ) + + console.print() + console.print(Panel("[bold cyan]Impact Score[/bold cyan]", + border_style="cyan", padding=(0, 2))) + console.print(f" Score: [bold]{score.score}[/bold] ({score.label})") + console.print(f" Total reclaimable: {score.total_human}") + console.print(f" Total items: {score.total_items}") + + if export_path: + import json + payload = score.to_dict() + payload["scan_id"] = target.scan_id + payload["generated_at"] = __import__("datetime").datetime.now().isoformat() + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# TUI PICKER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("tui-picker") +@click.option("--uninstall", is_flag=True, default=False, + help="Uninstall the selected app.") +@click.option("--no-undo", is_flag=True, default=False, + help="Permanently delete instead of staging for undo.") +@click.option("--reveal", is_flag=True, default=False, + help="Reveal the app in Finder.") +@click.option("--open", "open_app", is_flag=True, default=False, + help="Open the app after selection.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_tui_picker( + ctx: click.Context, + uninstall: bool, + no_undo: bool, + reveal: bool, + open_app: bool, + yes: bool, +) -> None: + """Interactive app picker.""" + import subprocess + from core.dry_run import dry_run_enabled + from core.tui_picker import pick_app + from core.uninstaller import build_uninstall_plan, execute_uninstall + + apps = discover_installed_apps() + result = pick_app(list(apps.values()), prompt="Pick an app") + app = result.selected + if app is None: + console.print("[yellow]No app selected.[/yellow]") + return + + console.print(f"\nSelected: [bold]{app.name}[/bold] ({app.bundle_id})") + + if reveal: + subprocess.run(["open", "-R", str(app.path)]) + if open_app: + subprocess.run(["open", str(app.path)]) + + if not uninstall: + return + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; uninstall skipped.[/yellow]") + return + + cfg = load_config() + plan = build_uninstall_plan(app=app, whitelist_set=cfg.whitelist_set) + if not plan.deletable_items: + console.print("[yellow]No removable data found for this app.[/yellow]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Category", width=16) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + for item in plan.deletable_items[:40]: + table.add_row(item.category, bytes_human(item.size), str(item.path)) + console.print(table) + if len(plan.deletable_items) > 40: + console.print(f" [dim]... {len(plan.deletable_items) - 40} more items omitted[/dim]") + + 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]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# CONFIG SYNC +# ══════════════════════════════════════════════════════════════════════════════ + +@main.group("config-sync") +def cmd_config_sync() -> None: + """Sync configuration across multiple Macs.""" + + +@cmd_config_sync.command("export") +@click.option("--dest", "dest_dir", default=None, type=click.Path(), + help="Destination sync directory.") +@click.option("--include-history", is_flag=True, default=False, + help="Include scan history in sync bundle.") +@click.option("--no-icloud", is_flag=True, default=False, + help="Do not use iCloud Drive as default sync location.") +def config_sync_export(dest_dir: Optional[str], include_history: bool, no_icloud: bool) -> None: + """Export config to sync directory.""" + from core.config_sync import export_config + + dest = Path(dest_dir).expanduser().resolve() if dest_dir else None + result = export_config(dest_dir=dest, include_history=include_history, prefer_icloud=not no_icloud) + color = "green" if result.success else "red" + console.print(f"[{color}]{result.message}[/{color}]") + if result.path: + console.print(f" [dim]{result.path}[/dim]") + + +@cmd_config_sync.command("import") +@click.option("--src", "src_dir", default=None, type=click.Path(), + help="Source sync directory.") +@click.option("--no-icloud", is_flag=True, default=False, + help="Do not use iCloud Drive as default sync location.") +@click.option("--no-backup", is_flag=True, default=False, + help="Do not backup existing config.") +def config_sync_import(src_dir: Optional[str], no_icloud: bool, no_backup: bool) -> None: + """Import config from sync directory.""" + from core.config_sync import import_config + + src = Path(src_dir).expanduser().resolve() if src_dir else None + result = import_config(src_dir=src, prefer_icloud=not no_icloud, backup=not no_backup) + color = "green" if result.success else "red" + console.print(f"[{color}]{result.message}[/{color}]") + if result.path: + console.print(f" [dim]{result.path}[/dim]") + + +@cmd_config_sync.command("status") +@click.option("--dir", "dest_dir", default=None, type=click.Path(), + help="Sync directory to inspect.") +@click.option("--no-icloud", is_flag=True, default=False, + help="Do not use iCloud Drive as default sync location.") +def config_sync_status(dest_dir: Optional[str], no_icloud: bool) -> None: + """Show sync metadata.""" + from core.config_sync import sync_status + + dest = Path(dest_dir).expanduser().resolve() if dest_dir else None + result = sync_status(dest_dir=dest, prefer_icloud=not no_icloud) + color = "green" if result.success else "yellow" + console.print(f"[{color}]{result.message}[/{color}]") + if result.path: + console.print(f" [dim]{result.path}[/dim]") + + # ══════════════════════════════════════════════════════════════════════════════ # PERMISSIONS AUDITOR # ══════════════════════════════════════════════════════════════════════════════ diff --git a/src/config/config.py b/src/config/config.py index b9fddf7..df63237 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,52 +1,16 @@ -""" -Mac Deep Cleaner v1.5.0 — Configuration Manager -============================================= -Reads and writes a YAML config file at ~/.config/mac-cleaner/config.yaml. - -Config schema (all keys optional) ----------------------------------- -whitelist: - - ~/Library/Application Support/Slack - - ~/Library/Caches/MyApp - -skip_categories: - - "System Cache" - - "Log File" - -dev_junk_roots: - - ~/Projects - - ~/Code - -scan_dev_junk: false -scan_dev_junk_global: false -dev_junk_max_depth: 6 - -custom_scan_roots: - - ~/Projects/tools - - /opt/company - -profile: developer # name of an active profile (merged on top of base config) - -profiles: - minimal: - skip_categories: ["Xcode Junk", "npm Cache", "Cargo Cache"] - developer: - custom_scan_roots: [~/Projects] - skip_categories: [] - -undo_mode: true # stage deletions in ~/.mac_cleaner_trash instead of permanent delete -retention_days: 30 # how long staged files are kept -large_file_threshold_mb: 100 -duplicate_min_size_kb: 4 -notify_after_scan: false - -Usage ------ - from mac_cleaner.config import load_config, Config - - cfg = load_config() # reads file or returns defaults - cfg = load_config(profile="developer") - cfg.save() # writes back to disk + +"""Manage on-disk configuration for Mac Deep Cleaner. + +Configuration is stored as YAML at ~/.config/mac-cleaner/config.yaml. + +Example: + whitelist: + - ~/Library/Application Support/Slack + - ~/Library/Caches/MyApp + + scan_dev_junk: false + scan_dev_junk_global: false + dev_junk_max_depth: 6 """ from __future__ import annotations @@ -137,7 +101,25 @@ @dataclass class Config: - """Resolved configuration, ready for use by the CLI.""" + """Resolved configuration, ready for use by the CLI. + + Attributes: + whitelist: Explicit paths to exclude from deletion. + custom_scan_roots: Extra root folders to scan. + skip_categories: Categories of junk to skip. + scan_orphans: Whether to scan for app leftovers. + scan_junk: Whether to scan for general junk. + scan_dev_junk: Whether to scan for developer junk. + scan_dev_junk_global: Whether to include global caches. + undo_mode: Stage deletions for undo instead of removing permanently. + retention_days: Days to keep staged deletions. + notify_after_scan: Post a notification after scans. + profile: Active profile name if any. + dev_junk_roots: Roots to scan for developer junk. + dev_junk_max_depth: Max depth for dev junk scanning. + large_file_threshold_mb: Minimum size for large-file scans. + duplicate_min_size_kb: Minimum size for duplicate detection. + """ # Paths whitelist: List[Path] = field(default_factory=list) @@ -187,7 +169,12 @@ def whitelist_set(self) -> Set[Path]: # ── Persistence ──────────────────────────────────────────────────────── def save(self) -> None: - """Write the current config to disk as YAML.""" + """Write the current config to disk as YAML. + + Raises: + RuntimeError: If PyYAML is not installed. + OSError: If the file cannot be written. + """ if not _YAML_OK: raise RuntimeError("pyyaml is required to save config. pip install pyyaml") @@ -213,10 +200,11 @@ def save(self) -> None: if self.profile: data["profile"] = self.profile - with open(_CONFIG_FILE, "w") as f: + with open(_CONFIG_FILE, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False) def to_dict(self) -> Dict[str, Any]: + """Serialize the resolved config to a JSON-safe dictionary.""" return { "whitelist": [str(p) for p in self.whitelist], "skip_categories": sorted(self.skip_categories), @@ -238,7 +226,14 @@ def to_dict(self) -> Dict[str, Any]: # ── Loader ───────────────────────────────────────────────────────────────────── def _expand(paths: List[Any]) -> List[Path]: - """Expand a list of path strings into Path objects.""" + """Expand a list of path-like values into resolved Path objects. + + Args: + paths: List of values that can be converted to Path. + + Returns: + List of resolved Path objects. + """ result = [] for p in paths: try: @@ -249,22 +244,47 @@ def _expand(paths: List[Any]) -> List[Path]: def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: - """Shallow merge: override keys win.""" + """Shallow merge: override keys win. + + Args: + base: Base dictionary. + override: Overrides applied on top of base. + + Returns: + Merged dictionary. + """ merged = copy.copy(base) merged.update(override) return merged +def _coerce_int(value: Any, default: int, min_value: int = 0) -> int: + """Coerce a value to int with a lower bound. + + Args: + value: Value to coerce. + default: Fallback when conversion fails. + min_value: Minimum allowed value. + + Returns: + Coerced integer value. + """ + try: + coerced = int(value) + except (TypeError, ValueError): + return default + return max(min_value, coerced) + + def load_config( path: Optional[Path] = None, profile: Optional[str] = None, ) -> Config: - """ - Load configuration from disk (YAML) and apply profile overrides. + """Load configuration from disk and apply profile overrides. Args: - path: Path to the config file. Defaults to ~/.config/mac-cleaner/config.yaml. - profile: Profile name to activate (overrides file's 'profile' key). + path: Path to the config file. Defaults to ~/.config/mac-cleaner/config.yaml. + profile: Profile name to activate (overrides file's "profile" key). Returns: Config object with all settings resolved. @@ -274,11 +294,11 @@ def load_config( if config_path.exists() and _YAML_OK: try: - with open(config_path) as f: + with open(config_path, encoding="utf-8") as f: loaded = yaml.safe_load(f) or {} if isinstance(loaded, dict): raw = loaded - except Exception as exc: + except (OSError, ValueError, TypeError, yaml.YAMLError) as exc: logger.debug("Failed to load config %s: %s", config_path, exc) # Resolve active profile @@ -307,13 +327,13 @@ def load_config( scan_dev_junk=bool(effective.get("scan_dev_junk", False)), scan_dev_junk_global=bool(effective.get("scan_dev_junk_global", False)), undo_mode=bool(effective.get("undo_mode", True)), - retention_days=int(effective.get("retention_days", 30)), + retention_days=_coerce_int(effective.get("retention_days", 30), 30, min_value=0), notify_after_scan=bool(effective.get("notify_after_scan", False)), - large_file_threshold_mb=int(effective.get("large_file_threshold_mb", 100)), - duplicate_min_size_kb=int(effective.get("duplicate_min_size_kb", 4)), + large_file_threshold_mb=_coerce_int(effective.get("large_file_threshold_mb", 100), 100, min_value=0), + duplicate_min_size_kb=_coerce_int(effective.get("duplicate_min_size_kb", 4), 4, min_value=1), profile=active_profile, dev_junk_roots=_expand(effective.get("dev_junk_roots", [])), - dev_junk_max_depth=int(effective.get("dev_junk_max_depth", 6)), + dev_junk_max_depth=_coerce_int(effective.get("dev_junk_max_depth", 6), 6, min_value=1), ) cfg._raw_profiles = all_profiles return cfg @@ -336,8 +356,13 @@ def ensure_config_dir() -> Path: def init_default_config(profile: Optional[str] = None) -> Config: - """ - Write a default config file if none exists, then load and return it. + """Write a default config file if none exists, then load and return it. + + Args: + profile: Optional profile name to set on first run. + + Returns: + Loaded configuration. """ if not _CONFIG_FILE.exists(): cfg = default_config() diff --git a/src/config/history.py b/src/config/history.py index b8c50f7..3e97ffe 100644 --- a/src/config/history.py +++ b/src/config/history.py @@ -1,39 +1,8 @@ -""" -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. - -Storage format --------------- -Each scan is stored as _.json with this schema: - -{ - "schema_version": 2, - "scan_id": "abc12345", - "scanned_at": "2024-01-15T10:30:00", - "profile": "developer", // optional - "orphans": { - "Slack": { - "total_size": 123456, - "items": [{"path": "...", "reason": "...", "size": 123}] - } - }, - "junk": [ - {"path": "...", "category": "User Cache", "size": 123} - ], - "dev_junk": [ - {"path": "...", "category": "Node Modules", "size": 123} - ], - "summary": { - "orphan_count": 3, - "orphan_bytes": 456789, - "junk_count": 12, - "junk_bytes": 987654, - "dev_junk_count": 5, - "dev_junk_bytes": 55555 - } -} + +"""Persist and diff scan history on disk. + +Scan history is stored as JSON files in ~/.config/mac-cleaner/history/ and can +be diffed to show what changed between runs. """ from __future__ import annotations @@ -43,10 +12,13 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple from utils import bytes_human +if TYPE_CHECKING: + from config.models import DevJunkEntry, JunkEntry, OrphanEntry + # ── Paths ────────────────────────────────────────────────────────────────────── HISTORY_DIR = Path.home() / ".config" / "mac-cleaner" / "history" @@ -55,6 +27,7 @@ def _ensure_history_dir() -> None: + """Ensure the history directory exists.""" HISTORY_DIR.mkdir(parents=True, exist_ok=True) @@ -62,7 +35,17 @@ def _ensure_history_dir() -> None: @dataclass class ScanRecord: - """A single historical scan result.""" + """A single historical scan result. + + Attributes: + scan_id: Unique scan identifier. + scanned_at: Timestamp of the scan. + profile: Optional profile name. + orphans: Orphaned app data grouped by name. + junk: List of junk items. + dev_junk: List of developer junk items. + summary: Aggregated counts and totals. + """ scan_id: str scanned_at: datetime profile: Optional[str] @@ -92,7 +75,8 @@ def dev_junk_bytes(self) -> int: def total_bytes(self) -> int: return self.orphan_bytes + self.junk_bytes + self.dev_junk_bytes - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the record to a JSON-serializable dictionary.""" return { "schema_version": SCHEMA_VERSION, "scan_id": self.scan_id, @@ -105,13 +89,15 @@ def to_dict(self) -> dict: } def save(self) -> None: + """Persist the scan record to disk.""" _ensure_history_dir() - with open(self.file_path, "w") as f: + with open(self.file_path, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, indent=2, default=str) _prune_old_entries() @classmethod - def from_dict(cls, d: dict) -> "ScanRecord": + def from_dict(cls, d: Dict[str, Any]) -> "ScanRecord": + """Create a ScanRecord from a dictionary payload.""" return cls( scan_id=d["scan_id"], scanned_at=datetime.fromisoformat(d["scanned_at"]), @@ -124,8 +110,16 @@ def from_dict(cls, d: dict) -> "ScanRecord": @classmethod def load(cls, path: Path) -> Optional["ScanRecord"]: + """Load a scan record from disk. + + Args: + path: Path to a JSON history file. + + Returns: + ScanRecord if the file is valid; otherwise None. + """ try: - with open(path) as f: + with open(path, encoding="utf-8") as f: data = json.load(f) return cls.from_dict(data) except (json.JSONDecodeError, KeyError, OSError, ValueError): @@ -142,17 +136,17 @@ def __repr__(self) -> str: # ── Builder ──────────────────────────────────────────────────────────────────── def build_scan_record( - orphans: Dict, - junk: List, - dev_junk: Optional[List] = None, + orphans: Dict[str, List["OrphanEntry"]], + junk: List["JunkEntry"], + dev_junk: Optional[List["DevJunkEntry"]] = None, profile: Optional[str] = None, ) -> ScanRecord: - """ - Create a ScanRecord from live scan results. + """Create a ScanRecord from live scan results. Args: - orphans: Dict[str, List[OrphanEntry]] from scanner.scan_orphans() - junk: List[JunkEntry] from scanner.scan_junk() + orphans: Mapping of app name to orphan entries. + junk: List of junk entries. + dev_junk: List of developer junk entries. profile: Active profile name (optional). """ orphan_data: Dict[str, Any] = {} @@ -199,7 +193,14 @@ def build_scan_record( # ── History listing ──────────────────────────────────────────────────────────── def list_history(limit: int = 20) -> List[ScanRecord]: - """Return past scan records, newest first.""" + """Return past scan records, newest first. + + Args: + limit: Maximum records to return. + + Returns: + List of ScanRecord instances. + """ _ensure_history_dir() records: List[ScanRecord] = [] for p in sorted(HISTORY_DIR.glob("*.json"), reverse=True): @@ -232,7 +233,17 @@ def _prune_old_entries() -> None: @dataclass class ScanDiff: - """Comparison between two scan records.""" + """Comparison between two scan records. + + Attributes: + older: Older scan record. + newer: Newer scan record. + new_orphans: App names appearing only in the newer scan. + resolved_orphans: App names missing from the newer scan. + persistent_orphans: App names present in both scans. + junk_delta_bytes: Net change in junk bytes. + dev_junk_delta_bytes: Net change in dev junk bytes. + """ older: ScanRecord newer: ScanRecord @@ -261,6 +272,7 @@ def size_delta_bytes(self) -> int: @property def summary(self) -> Dict[str, Any]: + """Return a JSON-ready summary of the diff.""" return { "older_scan": self.older.scan_id[:8], "older_date": self.older.scanned_at.isoformat(), @@ -277,14 +289,30 @@ def summary(self) -> Dict[str, Any]: def diff_scans(older: ScanRecord, newer: ScanRecord) -> ScanDiff: - """Compare two ScanRecords and return a ScanDiff.""" - return ScanDiff(older=older, newer=newer) + """Compare two ScanRecords and return a ScanDiff. + Args: + older: Older scan record. + newer: Newer scan record. -def diff_with_latest(current_orphans: Dict, current_junk: List) -> Optional[ScanDiff]: + Returns: + ScanDiff instance. """ - Compare the current (live) scan results with the most recent stored scan. - Returns None if there is no history yet. + return ScanDiff(older=older, newer=newer) + + +def diff_with_latest( + current_orphans: Dict[str, List["OrphanEntry"]], + current_junk: List["JunkEntry"], +) -> Optional[ScanDiff]: + """Compare live scan results with the most recent stored scan. + + Args: + current_orphans: Mapping of app name to orphan entries. + current_junk: List of junk entries. + + Returns: + ScanDiff if history exists; otherwise None. """ last = latest_scan() if last is None: diff --git a/src/config/models.py b/src/config/models.py index 631c461..ed1ad23 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -1,15 +1,12 @@ -""" -Mac Deep Cleaner v1.5.0 — Data Models -================================== -Immutable data classes for apps, orphan entries, and junk entries. -""" + +"""Core data models for scan results and metadata.""" from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path -from typing import Set +from typing import Any, Dict, Set from utils import size_of @@ -24,7 +21,13 @@ @dataclass class AppInfo: - """Represents a single installed application.""" + """Represents a single installed application. + + Attributes: + name: Display name of the app. + bundle_id: App bundle identifier. + path: Filesystem path to the app bundle. + """ name: str bundle_id: str path: Path @@ -61,7 +64,17 @@ def __repr__(self) -> str: @dataclass class OrphanEntry: - """A leftover file/directory from an uninstalled app.""" + """A leftover file or directory from an uninstalled app. + + Attributes: + path: Filesystem path to the orphaned item. + app_name: Display name of the app, if known. + reason: Reason category for the orphan entry. + size: Size in bytes. + category: Normalized category label. + bundle_id: Associated bundle identifier, if available. + vendor: Vendor name, if known. + """ path: Path app_name: str = "" reason: str = "Other" # e.g. "App Support", "Cache", "Container" @@ -80,7 +93,8 @@ def __post_init__(self) -> None: if self.size <= 0: self.size = size_of(self.path) - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the entry to a JSON-serializable dictionary.""" return { "path": str(self.path), "app_name": self.app_name, @@ -94,7 +108,15 @@ def to_dict(self) -> dict: @dataclass class JunkEntry: - """A general junk file/directory (cache, log, crash report, etc.).""" + """A general junk file or directory. + + Attributes: + path: Filesystem path to the junk item. + category: Category label (e.g., "User Cache"). + is_system: Whether the item is system-owned (never auto-delete). + size: Size in bytes. + bundle_id: Associated bundle identifier, if available. + """ path: Path category: str = "Other" # e.g. "User Cache", "Log File", "Trash" is_system: bool = False # If True, never auto-delete @@ -105,7 +127,8 @@ def __post_init__(self) -> None: if self.size <= 0: self.size = size_of(self.path) - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the entry to a JSON-serializable dictionary.""" return { "path": str(self.path), "category": self.category, @@ -117,7 +140,13 @@ def to_dict(self) -> dict: @dataclass class DevJunkEntry: - """Developer junk directory (build output, venv, node_modules, etc.).""" + """Developer junk directory (build output, venv, node_modules, etc.). + + Attributes: + path: Filesystem path to the dev junk directory. + category: Category label. + size: Size in bytes. + """ path: Path category: str = "Other" size: int = 0 @@ -126,7 +155,8 @@ def __post_init__(self) -> None: if self.size <= 0: self.size = size_of(self.path) - def to_dict(self) -> dict: + def to_dict(self) -> Dict[str, Any]: + """Convert the entry to a JSON-serializable dictionary.""" return { "path": str(self.path), "category": self.category, diff --git a/src/constants.py b/src/constants.py index 72b5215..181496c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,16 +1,12 @@ -""" -Mac Deep Cleaner v1.5.0 — Constants & Configuration -================================================= -All safelists, alias tables, search roots, and configuration constants. -""" +"""Constants and configuration data for scans and safety rules.""" from pathlib import Path from typing import Dict, List, Set HOME = Path.home() LOG_FILE = HOME / ".mac_cleaner_deleted.log" -CONFIG_DIR = HOME / ".config" / "mac-cleaner" # NEW in v1.5.0 +CONFIG_DIR = HOME / ".config" / "mac-cleaner" # ── Scan roots ──────────────────────────────────────────────────────────────── @@ -68,7 +64,7 @@ "microsoft teams": "com.microsoft.teams", "microsoft teams (work or school)": "com.microsoft.teams2", "microsoft teams classic": "com.microsoft.teams", - # v1.5.0: short-form aliases for Microsoft apps + # short-form aliases for Microsoft apps "excel": "com.microsoft.excel", "word": "com.microsoft.word", "powerpoint": "com.microsoft.powerpoint", @@ -86,7 +82,7 @@ "google chrome canary": "com.google.chrome.canary", "google drive": "com.google.drivefs", "google earth pro": "com.google.googleearthpro", - # v1.5.0 + # v2.0.0 "googledrive": "com.google.drivefs", # ── JetBrains ───────────────────────────────────────────────────────── @@ -105,7 +101,6 @@ "appcode": "com.jetbrains.appcode", "fleet": "com.jetbrains.fleet", "jetbrains toolbox": "com.jetbrains.toolbox", - # v1.5.0 "jetbrains toolbox app": "com.jetbrains.toolbox", # ── Browsers ────────────────────────────────────────────────────────── @@ -123,7 +118,7 @@ "tor browser": "org.torproject.torbrowser", "waterfox": "net.nickolaj.nickelodeon", "sidekick": "com.nicklodeon.nickelodeon", - # v1.5.0: extra short-form browser aliases + # extra short-form browser aliases "brave": "com.brave.browser", "edge": "com.microsoft.edgemac", @@ -173,7 +168,6 @@ "ia writer": "pro.writer.mac", "devonthink 3": "com.devon-technologies.think3", "devonthink": "com.devon-technologies.think3", - # v1.5.0 "linear": "com.linear.linear", "superhuman": "com.superhuman.desktop", @@ -193,7 +187,6 @@ "gimp": "org.gimp.gimp-2.10", "canva": "com.canva.canva", "principle": "com.principleformac.principle", - # v1.5.0 "pixelmator": "com.pixelmatorteam.pixelmator", # ── Dev tools ───────────────────────────────────────────────────────── @@ -225,7 +218,6 @@ "coderunner": "com.krill.coderunner", "coteditor": "com.coteditor.coteditor", "textedit": "com.apple.textedit", - # v1.5.0 "simulator": "com.apple.iphonesimulator", "xcode": "com.apple.dt.xcode", "textmate": "com.macromates.textmate", @@ -342,7 +334,7 @@ # ── Apple Developer Team ID → owner name ───────────────────────────────────── TEAM_ID_MAP: Dict[str, str] = { - # ── v1.5.0 original entries (verbatim) ──────────────────────────────────── + # ── original entries (verbatim) ──────────────────────────────────── "ubf8t346g9": "Microsoft Office", "2bua8c4s2c": "1Password", "7pkpll4vld": "Dropbox", @@ -382,7 +374,7 @@ "t9um3f5r6t": "Spark / Readdle", "w5364u7y5r": "Canva", - # ── v1.5.0 additions ────────────────────────────────────────────────────── + # ── additions ────────────────────────────────────────────────────── "ug75gva3v9": "Microsoft (general)", "jq525l2msd": "Adobe", "g7hh3359t7": "Dropbox", @@ -489,7 +481,7 @@ # ── Exact-stem safelist — the stem (lowercased) matches exactly ─────────────── SYSTEM_EXACT_SAFELIST: Set[str] = { - # ── v1.5.0 original entries (verbatim) ──────────────────────────────────── + # ── original entries (verbatim) ──────────────────────────────────── # Networking / directory "systemconfiguration", "opendirectory", "directoryservice", @@ -587,7 +579,7 @@ "storedownloadd", "commerced", - # ── v1.5.0 additions ────────────────────────────────────────────────────── + # ── additions ────────────────────────────────────────────────────── "apsd", "appleid", "airplay", diff --git a/src/core/cleaner.py b/src/core/cleaner.py index 10bad25..a54e480 100644 --- a/src/core/cleaner.py +++ b/src/core/cleaner.py @@ -1,16 +1,7 @@ """ -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.5.0 ---------------- -- do_cleanup() now accepts an optional `session` parameter. - When provided, files are moved to the staging area (undo.stage_file) - instead of being permanently deleted. -- write_deletion_log() is unchanged — it logs staged moves too. -- All safety gates remain in place. """ from __future__ import annotations @@ -41,7 +32,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.5.0 — Deletion Log\n") + f.write(f"Mac Deep Cleaner v2.0.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/config_sync.py b/src/core/config_sync.py new file mode 100644 index 0000000..3e84cd5 --- /dev/null +++ b/src/core/config_sync.py @@ -0,0 +1,96 @@ +"""Multi-Mac config sync helpers.""" + +from __future__ import annotations + +import json +import platform +import shutil +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + +from constants import CONFIG_DIR + + +@dataclass +class SyncResult: + """Summary of a sync operation.""" + success: bool + message: str + path: Optional[Path] = None + details: list[str] = field(default_factory=list) + + +def default_sync_dir(prefer_icloud: bool = True) -> Path: + """Return the default sync directory.""" + if prefer_icloud: + icloud_root = Path.home() / "Library" / "Mobile Documents" / "com~apple~CloudDocs" + if icloud_root.exists(): + return icloud_root / "MacCleaner" + return CONFIG_DIR / "sync" + + +def _write_meta(dest: Path) -> None: + meta = { + "host": platform.node(), + "platform": platform.platform(), + "updated_at": datetime.now().isoformat(), + } + dest.mkdir(parents=True, exist_ok=True) + (dest / "sync_meta.json").write_text(json.dumps(meta, indent=2)) + + +def export_config( + dest_dir: Optional[Path] = None, + include_history: bool = False, + prefer_icloud: bool = True, +) -> SyncResult: + """Export config to a sync directory.""" + dest = dest_dir or default_sync_dir(prefer_icloud=prefer_icloud) + config_path = CONFIG_DIR / "config.yaml" + + if not config_path.exists(): + return SyncResult(False, "No config.yaml found to export", dest) + + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(config_path, dest / "config.yaml") + + if include_history: + history = CONFIG_DIR / "history" + if history.exists(): + shutil.copytree(history, dest / "history", dirs_exist_ok=True) + + _write_meta(dest) + return SyncResult(True, "Config exported", dest) + + +def import_config( + src_dir: Optional[Path] = None, + prefer_icloud: bool = True, + backup: bool = True, +) -> SyncResult: + """Import config from a sync directory.""" + src = src_dir or default_sync_dir(prefer_icloud=prefer_icloud) + src_cfg = src / "config.yaml" + if not src_cfg.exists(): + return SyncResult(False, "No config.yaml found in sync directory", src) + + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + dest_cfg = CONFIG_DIR / "config.yaml" + if backup and dest_cfg.exists(): + backup_path = CONFIG_DIR / f"config.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}.yaml" + shutil.copy2(dest_cfg, backup_path) + + shutil.copy2(src_cfg, dest_cfg) + return SyncResult(True, "Config imported", src) + + +def sync_status(dest_dir: Optional[Path] = None, prefer_icloud: bool = True) -> SyncResult: + """Return sync metadata if present.""" + dest = dest_dir or default_sync_dir(prefer_icloud=prefer_icloud) + meta = dest / "sync_meta.json" + if not meta.exists(): + return SyncResult(False, "No sync metadata found", dest) + + return SyncResult(True, meta.read_text().strip(), dest) diff --git a/src/core/dns_cache.py b/src/core/dns_cache.py new file mode 100644 index 0000000..d67dcf8 --- /dev/null +++ b/src/core/dns_cache.py @@ -0,0 +1,78 @@ +"""DNS cache flush helpers.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass, field +from typing import List + +logger = logging.getLogger(__name__) + + +@dataclass +class DNSFlushStep: + """One DNS flush command.""" + command: List[str] + success: bool + stdout: str = "" + stderr: str = "" + + +@dataclass +class DNSFlushResult: + """Summary of DNS flush.""" + success: bool + steps: List[DNSFlushStep] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +def _run_cmd(args: List[str], runner=subprocess.run) -> DNSFlushStep: + try: + result = runner( + args, + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("DNS command failed: %s", exc) + return DNSFlushStep(command=args, success=False, stderr=str(exc)) + + ok = result.returncode == 0 + return DNSFlushStep( + command=args, + success=ok, + stdout=result.stdout.strip(), + stderr=result.stderr.strip(), + ) + + +def flush_dns_cache(runner=subprocess.run) -> DNSFlushResult: + """Flush DNS cache using standard macOS commands.""" + commands = [ + ["dscacheutil", "-flushcache"], + ["killall", "-HUP", "mDNSResponder"], + ["killall", "-HUP", "mDNSResponderHelper"], + ] + + steps: List[DNSFlushStep] = [] + errors: List[str] = [] + success_count = 0 + + for cmd in commands: + step = _run_cmd(cmd, runner=runner) + if not step.success: + if step.stderr and "no matching processes" in step.stderr.lower(): + step.success = True + if step.success: + success_count += 1 + else: + errors.append(" ".join(cmd)) + steps.append(step) + + return DNSFlushResult( + success=success_count > 0, + steps=steps, + errors=errors, + ) diff --git a/src/core/font_cache.py b/src/core/font_cache.py new file mode 100644 index 0000000..5813475 --- /dev/null +++ b/src/core/font_cache.py @@ -0,0 +1,99 @@ +"""Font cache rebuild helpers.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import List + +from constants import HOME +from utils import safe_remove + +logger = logging.getLogger(__name__) + + +@dataclass +class FontCacheStep: + """One font cache action.""" + command: List[str] + success: bool + stdout: str = "" + stderr: str = "" + + +@dataclass +class FontCacheResult: + """Summary of font cache rebuild.""" + success: bool + steps: List[FontCacheStep] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +_USER_CACHE_DIRS = [ + HOME / "Library" / "FontCaches", + HOME / "Library" / "Caches" / "com.apple.ATS", +] + + +def _run_cmd(args: List[str], runner=subprocess.run) -> FontCacheStep: + try: + result = runner( + args, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("Font cache command failed: %s", exc) + return FontCacheStep(command=args, success=False, stderr=str(exc)) + + ok = result.returncode == 0 + return FontCacheStep( + command=args, + success=ok, + stdout=result.stdout.strip(), + stderr=result.stderr.strip(), + ) + + +def rebuild_font_cache( + clear_user: bool = False, + runner=subprocess.run, +) -> FontCacheResult: + """Rebuild system font caches using atsutil.""" + steps: List[FontCacheStep] = [] + errors: List[str] = [] + + if clear_user: + for cache_dir in _USER_CACHE_DIRS: + if cache_dir.exists(): + ok, _freed = safe_remove(cache_dir) + if not ok: + errors.append(f"Failed to remove {cache_dir}") + + atsutil = shutil.which("atsutil") + if not atsutil: + return FontCacheResult( + success=False, + steps=steps, + errors=["atsutil not found"], + ) + + for cmd in [ + [atsutil, "server", "-shutdown"], + [atsutil, "databases", "-remove"], + [atsutil, "server", "-ping"], + ]: + step = _run_cmd(cmd, runner=runner) + steps.append(step) + if not step.success: + errors.append(" ".join(cmd)) + + return FontCacheResult( + success=all(s.success for s in steps) and not errors, + steps=steps, + errors=errors, + ) diff --git a/src/core/pkg_receipts.py b/src/core/pkg_receipts.py new file mode 100644 index 0000000..c2909bb --- /dev/null +++ b/src/core/pkg_receipts.py @@ -0,0 +1,100 @@ +"""PKG receipt manager.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class PkgReceipt: + """A single pkg receipt entry.""" + identifier: str + version: Optional[str] = None + volume: Optional[str] = None + location: Optional[str] = None + install_time: Optional[str] = None + + def to_dict(self) -> dict: + return { + "identifier": self.identifier, + "version": self.version, + "volume": self.volume, + "location": self.location, + "install_time": self.install_time, + } + + +def _run(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def list_receipts(search: Optional[str] = None, limit: Optional[int] = None) -> List[str]: + """List pkg receipt identifiers.""" + try: + result = _run(["pkgutil", "--pkgs"], timeout=60) + except (OSError, subprocess.TimeoutExpired): + return [] + if result.returncode != 0: + return [] + + receipts = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if search: + needle = search.lower() + receipts = [r for r in receipts if needle in r.lower()] + if limit is not None and limit > 0: + receipts = receipts[:limit] + return receipts + + +def get_receipt_info(identifier: str) -> Optional[PkgReceipt]: + """Return detailed receipt info for a package id.""" + try: + result = _run(["pkgutil", "--pkg-info", identifier], timeout=30) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + + data: Dict[str, str] = {} + for line in result.stdout.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + data[key.strip().lower()] = value.strip() + + install_time = None + if "install-time" in data: + try: + install_time = datetime.fromtimestamp(int(data["install-time"])).isoformat() + except (ValueError, OSError): + install_time = data.get("install-time") + + return PkgReceipt( + identifier=identifier, + version=data.get("version"), + volume=data.get("volume"), + location=data.get("location"), + install_time=install_time, + ) + + +def forget_receipt(identifier: str) -> tuple[bool, str]: + """Forget a pkg receipt.""" + try: + result = _run(["pkgutil", "--forget", identifier], timeout=30) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) + if result.returncode == 0: + return True, result.stdout.strip() or "receipt forgotten" + return False, result.stderr.strip() or "pkgutil --forget failed" diff --git a/src/core/power_optimizer.py b/src/core/power_optimizer.py new file mode 100644 index 0000000..f61c0e8 --- /dev/null +++ b/src/core/power_optimizer.py @@ -0,0 +1,198 @@ +"""Sleep and power optimizer helpers.""" + +from __future__ import annotations + +import json +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +from constants import CONFIG_DIR + +logger = logging.getLogger(__name__) + + +@dataclass +class PowerProfile: + """Parsed pmset custom settings.""" + battery: Dict[str, str] + ac: Dict[str, str] + + +@dataclass +class PowerChange: + """One recommended change.""" + key: str + current: Optional[str] + recommended: str + scope: str # "battery", "ac", or "all" + + +@dataclass +class ApplyResult: + """Summary of apply/restore run.""" + success: bool + message: str + changes: List[PowerChange] + + +_RECOMMENDED: Dict[str, str] = { + "displaysleep": "10", + "sleep": "30", + "powernap": "0", + "tcpkeepalive": "1", + "standby": "1", + "standbydelayhigh": "86400", + "standbydelaylow": "3600", + "autopoweroff": "1", + "autopoweroffdelay": "28800", +} + +_PROFILE_PATH = CONFIG_DIR / "power_profile.json" + + +def _run(args: List[str], timeout: int = 30) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def parse_pmset_custom(output: str) -> PowerProfile: + battery: Dict[str, str] = {} + ac: Dict[str, str] = {} + current: Optional[Dict[str, str]] = None + + for raw in output.splitlines(): + line = raw.strip() + if not line: + continue + lower = line.lower() + if lower.startswith("battery power"): + current = battery + continue + if lower.startswith("ac power"): + current = ac + continue + if current is None: + continue + parts = line.split() + if len(parts) >= 2: + key = parts[0] + value = parts[-1] + current[key] = value + + return PowerProfile(battery=battery, ac=ac) + + +def get_power_profile() -> Optional[PowerProfile]: + try: + result = _run(["pmset", "-g", "custom"], timeout=10) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("pmset failed: %s", exc) + return None + if result.returncode != 0: + return None + return parse_pmset_custom(result.stdout) + + +def save_profile(profile: PowerProfile, path: Path = _PROFILE_PATH) -> None: + payload = { + "battery": profile.battery, + "ac": profile.ac, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2)) + + +def load_profile(path: Path = _PROFILE_PATH) -> Optional[PowerProfile]: + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + return PowerProfile(battery=data.get("battery", {}), ac=data.get("ac", {})) + except (json.JSONDecodeError, OSError): + return None + + +def diff_recommendations(profile: PowerProfile, scope: str = "all") -> List[PowerChange]: + changes: List[PowerChange] = [] + + def _maybe_add(current_map: Dict[str, str], scope_label: str) -> None: + for key, recommended in _RECOMMENDED.items(): + if key not in current_map: + continue + current = current_map.get(key) + if current != recommended: + changes.append(PowerChange( + key=key, + current=current, + recommended=recommended, + scope=scope_label, + )) + + if scope in ("battery", "all"): + _maybe_add(profile.battery, "battery" if scope == "battery" else "all") + if scope in ("ac", "all"): + _maybe_add(profile.ac, "ac" if scope == "ac" else "all") + + return changes + + +def apply_changes(changes: List[PowerChange]) -> ApplyResult: + if not changes: + return ApplyResult(True, "No changes required", changes) + + ok = True + for change in changes: + flag = "-a" + if change.scope == "battery": + flag = "-b" + elif change.scope == "ac": + flag = "-c" + try: + result = _run(["pmset", flag, change.key, change.recommended], timeout=10) + if result.returncode != 0: + ok = False + except (OSError, subprocess.TimeoutExpired): + ok = False + + return ApplyResult(ok, "Applied power settings" if ok else "Some settings failed", changes) + + +def apply_recommended(scope: str = "all") -> ApplyResult: + profile = get_power_profile() + if profile is None: + return ApplyResult(False, "Unable to read current power settings", []) + + save_profile(profile) + changes = diff_recommendations(profile, scope=scope) + return apply_changes(changes) + + +def restore_profile(scope: str = "all") -> ApplyResult: + saved = load_profile() + if saved is None: + return ApplyResult(False, "No saved power profile found", []) + + changes: List[PowerChange] = [] + + def _build(map_data: Dict[str, str], scope_label: str) -> None: + for key, value in map_data.items(): + changes.append(PowerChange( + key=key, + current=None, + recommended=str(value), + scope=scope_label, + )) + + if scope in ("battery", "all"): + _build(saved.battery, "battery" if scope == "battery" else "all") + if scope in ("ac", "all"): + _build(saved.ac, "ac" if scope == "ac" else "all") + + return apply_changes(changes) diff --git a/src/core/restore_checksums.py b/src/core/restore_checksums.py new file mode 100644 index 0000000..73390ed --- /dev/null +++ b/src/core/restore_checksums.py @@ -0,0 +1,118 @@ +"""Restore checksum verification helpers.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import shutil +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from core.undo import DeletionSession, TRASH_ROOT + +logger = logging.getLogger(__name__) + +_CHECKSUM_DIR = TRASH_ROOT / "checksums" + + +@dataclass +class VerificationEntry: + """Checksum verification for one file.""" + original_path: str + restored_path: str + checksum_before: str + checksum_after: str + matched: bool + + +@dataclass +class VerificationResult: + """Summary of restore verification.""" + restored: int = 0 + verified: int = 0 + mismatched: int = 0 + failed: int = 0 + bytes_restored: int = 0 + errors: List[str] = field(default_factory=list) + entries: List[VerificationEntry] = field(default_factory=list) + + +def _sha256(path: Path, chunk_size: int = 1024 * 1024) -> Optional[str]: + if not path.exists(): + return None + h = hashlib.sha256() + try: + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(chunk_size), b""): + h.update(chunk) + return h.hexdigest() + except OSError as exc: + logger.debug("checksum failed for %s: %s", path, exc) + return None + + +def _write_manifest(session: DeletionSession, entries: List[VerificationEntry]) -> None: + _CHECKSUM_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "session_id": session.session_id, + "created_at": datetime.now().isoformat(), + "entries": [e.__dict__ for e in entries], + } + (_CHECKSUM_DIR / f"{session.session_id}.json").write_text(json.dumps(payload, indent=2)) + + +def restore_with_verification(session: DeletionSession) -> VerificationResult: + """Restore a session and verify checksums before/after move.""" + result = VerificationResult() + + for f in session.files: + staging = Path(f.staging_path) + original = Path(f.original_path) + + if not staging.exists(): + result.failed += 1 + result.errors.append(f"Missing staged file: {f.original_path}") + continue + + checksum_before = _sha256(staging) + if checksum_before is None: + result.failed += 1 + result.errors.append(f"Checksum failed: {f.original_path}") + continue + + original.parent.mkdir(parents=True, exist_ok=True) + restored_path = original + if original.exists(): + restored_path = original.with_name(original.name + ".restored") + + try: + shutil.move(str(staging), str(restored_path)) + except (shutil.Error, OSError) as exc: + result.failed += 1 + result.errors.append(f"Restore failed: {f.original_path} ({exc})") + continue + + checksum_after = _sha256(restored_path) + if checksum_after is None: + result.failed += 1 + result.errors.append(f"Checksum failed after restore: {restored_path}") + continue + + matched = checksum_before == checksum_after + result.restored += 1 + result.verified += 1 if matched else 0 + result.mismatched += 0 if matched else 1 + result.bytes_restored += f.size + result.entries.append(VerificationEntry( + original_path=f.original_path, + restored_path=str(restored_path), + checksum_before=checksum_before, + checksum_after=checksum_after, + matched=matched, + )) + + _write_manifest(session, result.entries) + return result diff --git a/src/core/safety.py b/src/core/safety.py index 6e27f33..6f56e70 100644 --- a/src/core/safety.py +++ b/src/core/safety.py @@ -1,6 +1,4 @@ """ -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 b4cfb94..aa27e86 100644 --- a/src/core/scanner.py +++ b/src/core/scanner.py @@ -1,6 +1,4 @@ """ -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 2dc9f72..824a38e 100644 --- a/src/core/scheduler.py +++ b/src/core/scheduler.py @@ -1,7 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 — Notifications & Scheduler -================================================= - Notifications ------------- Posts a native macOS notification after a scan completes using diff --git a/src/core/spotlight.py b/src/core/spotlight.py new file mode 100644 index 0000000..d6f9358 --- /dev/null +++ b/src/core/spotlight.py @@ -0,0 +1,80 @@ +"""Spotlight indexing helpers.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class SpotlightStatus: + """Spotlight status for a volume.""" + volume: str + enabled: Optional[bool] + raw: str + + +def get_spotlight_status(volume: str = "/", runner=subprocess.run) -> SpotlightStatus: + """Return Spotlight indexing status for a volume.""" + try: + result = runner( + ["mdutil", "-s", volume], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("mdutil status failed: %s", exc) + return SpotlightStatus(volume=volume, enabled=None, raw=str(exc)) + + text = result.stdout.strip() or result.stderr.strip() + enabled: Optional[bool] + if "indexing enabled" in text.lower(): + enabled = True + elif "indexing and searching disabled" in text.lower(): + enabled = False + else: + enabled = None + + return SpotlightStatus(volume=volume, enabled=enabled, raw=text) + + +def set_spotlight_indexing( + volume: str, + enabled: bool, + runner=subprocess.run, +) -> bool: + """Enable or disable Spotlight indexing.""" + flag = "on" if enabled else "off" + try: + result = runner( + ["mdutil", "-i", flag, volume], + capture_output=True, + text=True, + timeout=30, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("mdutil set failed: %s", exc) + return False + + +def reindex_spotlight(volume: str = "/", runner=subprocess.run) -> bool: + """Rebuild Spotlight index for the given volume.""" + if not set_spotlight_indexing(volume, True, runner=runner): + return False + try: + result = runner( + ["mdutil", "-E", volume], + capture_output=True, + text=True, + timeout=120, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired) as exc: + logger.debug("mdutil reindex failed: %s", exc) + return False diff --git a/src/core/system_inspector.py b/src/core/system_inspector.py index 5375c0f..5de4b13 100644 --- a/src/core/system_inspector.py +++ b/src/core/system_inspector.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 — System Inspector -======================================== Three sub-features bundled together because they share macOS system queries: 1. LaunchAgent / LaunchDaemon Manager diff --git a/src/core/time_machine_guard.py b/src/core/time_machine_guard.py new file mode 100644 index 0000000..6205d07 --- /dev/null +++ b/src/core/time_machine_guard.py @@ -0,0 +1,138 @@ +"""Time Machine backup guard helpers.""" + +from __future__ import annotations + +import logging +import plistlib +import re +import subprocess +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional + +from core.apfs_snapshots import list_snapshots + +logger = logging.getLogger(__name__) + +_BACKUP_RE = re.compile(r"(\d{4}-\d{2}-\d{2}-\d{6})") + + +@dataclass +class TimeMachineStatus: + """Summary of Time Machine status.""" + destinations: List[str] = field(default_factory=list) + last_backup: Optional[str] = None + last_backup_age_days: Optional[int] = None + is_running: Optional[bool] = None + local_snapshot_count: int = 0 + errors: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "destinations": self.destinations, + "last_backup": self.last_backup, + "last_backup_age_days": self.last_backup_age_days, + "is_running": self.is_running, + "local_snapshot_count": self.local_snapshot_count, + "errors": self.errors, + } + + +def _run(args: List[str], timeout: int = 20) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=False, + timeout=timeout, + ) + + +def _parse_latest_backup(path: str) -> Optional[int]: + match = _BACKUP_RE.search(path) + if not match: + return None + token = match.group(1) + try: + stamp = datetime.strptime(token, "%Y-%m-%d-%H%M%S") + except ValueError: + return None + return (datetime.now() - stamp).days + + +def get_time_machine_status() -> TimeMachineStatus: + status = TimeMachineStatus() + + # Destinations + try: + result = _run(["tmutil", "destinationinfo", "-plist"], timeout=20) + if result.returncode == 0: + pl = plistlib.loads(result.stdout) + dests = pl.get("Destinations", []) + for d in dests: + name = d.get("DestinationName") or d.get("MountPoint") or d.get("ID") + if name: + status.destinations.append(str(name)) + else: + status.errors.append("tmutil destinationinfo failed") + except Exception as exc: + logger.debug("destinationinfo failed: %s", exc) + status.errors.append("tmutil destinationinfo failed") + + # Running status + try: + result = _run(["tmutil", "status", "-plist"], timeout=10) + if result.returncode == 0: + pl = plistlib.loads(result.stdout) + status.is_running = bool(pl.get("Running", False)) + except Exception as exc: + logger.debug("tmutil status failed: %s", exc) + + # Latest backup + try: + latest = subprocess.run( + ["tmutil", "latestbackup"], + capture_output=True, + text=True, + timeout=10, + ) + if latest.returncode == 0: + path = latest.stdout.strip() + status.last_backup = path + status.last_backup_age_days = _parse_latest_backup(path) + except (OSError, subprocess.TimeoutExpired): + pass + + # Local snapshots + status.local_snapshot_count = len(list_snapshots("/")) + + return status + + +def enable_time_machine() -> tuple[bool, str]: + try: + result = subprocess.run( + ["tmutil", "enable"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return True, "Time Machine enabled" + return False, result.stderr.strip() or "tmutil enable failed" + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) + + +def disable_time_machine() -> tuple[bool, str]: + try: + result = subprocess.run( + ["tmutil", "disable"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return True, "Time Machine disabled" + return False, result.stderr.strip() or "tmutil disable failed" + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) diff --git a/src/core/tui_picker.py b/src/core/tui_picker.py new file mode 100644 index 0000000..175cec2 --- /dev/null +++ b/src/core/tui_picker.py @@ -0,0 +1,101 @@ +"""Interactive TUI app picker.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +import click + +from config.models import AppInfo + + +@dataclass +class PickerResult: + """Result of an app picker.""" + selected: Optional[AppInfo] + filter_text: str + + +def pick_app(apps: List[AppInfo], prompt: str = "Select an app") -> PickerResult: + """Pick an app from the list using a TUI or fallback prompt.""" + if not apps: + return PickerResult(selected=None, filter_text="") + + try: + import curses + except Exception: + return _pick_simple(apps, prompt) + + try: + selected = curses.wrapper(lambda stdscr: _pick_curses(stdscr, apps, prompt)) + return PickerResult(selected=selected, filter_text="") + except Exception: + return _pick_simple(apps, prompt) + + +def _pick_simple(apps: List[AppInfo], prompt: str) -> PickerResult: + click.echo("\n" + prompt) + sorted_apps = sorted(apps, key=lambda a: a.name.lower()) + for i, app in enumerate(sorted_apps, 1): + click.echo(f" {i:>3} {app.name} ({app.bundle_id})") + choice = click.prompt("App number", type=click.IntRange(1, len(sorted_apps))) + return PickerResult(selected=sorted_apps[choice - 1], filter_text="") + + +def _pick_curses(stdscr, apps: List[AppInfo], prompt: str) -> Optional[AppInfo]: + import curses + + curses.curs_set(0) + stdscr.nodelay(False) + stdscr.keypad(True) + + filter_text = "" + index = 0 + + def filtered() -> List[AppInfo]: + if not filter_text: + return apps + needle = filter_text.lower() + return [a for a in apps if needle in a.name.lower() or needle in a.bundle_id.lower()] + + while True: + stdscr.clear() + height, width = stdscr.getmaxyx() + header = f"{prompt} (type to filter, ENTER to select, q to quit)" + stdscr.addnstr(0, 0, header, width - 1) + stdscr.addnstr(1, 0, f"Filter: {filter_text}", width - 1) + + items = filtered() + if not items: + stdscr.addnstr(3, 0, "No matches", width - 1) + else: + index = max(0, min(index, len(items) - 1)) + start = max(0, index - (height - 6)) + view = items[start:start + height - 5] + for i, app in enumerate(view): + row = 3 + i + prefix = ">" if (start + i) == index else " " + label = f"{prefix} {app.name} ({app.bundle_id})" + stdscr.addnstr(row, 0, label, width - 1) + + stdscr.refresh() + ch = stdscr.getch() + if ch in (ord("q"), 27): + return None + if ch in (curses.KEY_UP, ord("k")): + index = max(0, index - 1) + elif ch in (curses.KEY_DOWN, ord("j")): + index = min(len(filtered()) - 1, index + 1) + elif ch in (curses.KEY_BACKSPACE, 127, 8): + filter_text = filter_text[:-1] + index = 0 + elif ch in (curses.KEY_ENTER, 10, 13): + items = filtered() + if items: + return items[index] + elif 32 <= ch <= 126: + filter_text += chr(ch) + index = 0 + + diff --git a/src/core/undo.py b/src/core/undo.py index 25cd96a..4c6c800 100644 --- a/src/core/undo.py +++ b/src/core/undo.py @@ -1,6 +1,4 @@ """ -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/update_checker.py b/src/core/update_checker.py new file mode 100644 index 0000000..96d1f19 --- /dev/null +++ b/src/core/update_checker.py @@ -0,0 +1,125 @@ +"""App update checker helpers.""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from dataclasses import dataclass, field +from typing import List + +logger = logging.getLogger(__name__) + + +@dataclass +class UpdateReport: + """Summary of available updates.""" + system_updates: List[str] = field(default_factory=list) + brew_formulae: List[str] = field(default_factory=list) + brew_casks: List[str] = field(default_factory=list) + mas_updates: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +def _run(args: List[str], timeout: int = 60) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def check_system_updates() -> List[str]: + """Return available macOS software updates.""" + try: + result = _run(["softwareupdate", "-l"], timeout=120) + except (OSError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + updates: List[str] = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("*"): + updates.append(stripped.lstrip("* ")) + elif stripped.lower().startswith("label:"): + updates.append(stripped.split(":", 1)[-1].strip()) + + return updates + + +def check_brew_updates() -> tuple[list[str], list[str]]: + """Return Homebrew outdated formulae and casks.""" + if not shutil.which("brew"): + return [], [] + + formulae: List[str] = [] + casks: List[str] = [] + + try: + out_formula = _run(["brew", "outdated", "--formula"], timeout=60) + if out_formula.returncode == 0: + formulae = [l.strip() for l in out_formula.stdout.splitlines() if l.strip()] + except (OSError, subprocess.TimeoutExpired): + pass + + try: + out_cask = _run(["brew", "outdated", "--cask"], timeout=60) + if out_cask.returncode == 0: + casks = [l.strip() for l in out_cask.stdout.splitlines() if l.strip()] + except (OSError, subprocess.TimeoutExpired): + pass + + return formulae, casks + + +def check_mas_updates() -> List[str]: + """Return Mac App Store updates via mas (if installed).""" + if not shutil.which("mas"): + return [] + + try: + result = _run(["mas", "outdated"], timeout=60) + except (OSError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + updates: List[str] = [] + for line in result.stdout.splitlines(): + stripped = line.strip() + if not stripped: + continue + updates.append(stripped) + return updates + + +def collect_update_report() -> UpdateReport: + """Collect updates from system, Homebrew, and Mac App Store.""" + report = UpdateReport() + + try: + report.system_updates = check_system_updates() + except Exception as exc: + logger.debug("system update check failed: %s", exc) + report.errors.append("system update check failed") + + try: + formulae, casks = check_brew_updates() + report.brew_formulae = formulae + report.brew_casks = casks + except Exception as exc: + logger.debug("brew update check failed: %s", exc) + report.errors.append("brew update check failed") + + try: + report.mas_updates = check_mas_updates() + except Exception as exc: + logger.debug("mas update check failed: %s", exc) + report.errors.append("mas update check failed") + + return report diff --git a/src/core/updater.py b/src/core/updater.py index 091d3b9..14792e0 100644 --- a/src/core/updater.py +++ b/src/core/updater.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 — Self-Update -=================================== Checks PyPI for a newer version and upgrades the package in-place using pip. Usage (CLI): diff --git a/src/reporting/exporter.py b/src/reporting/exporter.py index 6fa7d15..9728f4e 100644 --- a/src/reporting/exporter.py +++ b/src/reporting/exporter.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 — Export Module -===================================== Exports scan results to JSON or YAML format. """ @@ -27,7 +25,7 @@ def export_json( ) -> None: """Export full scan results to JSON.""" data = { - "tool": "Mac Deep Cleaner v1.5.0", + "tool": "Mac Deep Cleaner v2.0.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: { @@ -84,7 +82,7 @@ def export_yaml( return data = { - "tool": "Mac Deep Cleaner v1.5.0", + "tool": "Mac Deep Cleaner v2.0.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 3bede8b..86fe6e0 100644 --- a/src/reporting/html_report.py +++ b/src/reporting/html_report.py @@ -1,6 +1,4 @@ """ -Mac Deep Cleaner v1.5.0 — HTML Report Exporter -============================================ Generates a self-contained HTML report with: - Collapsible sections per category - Doughnut chart (Chart.js via CDN) for space breakdown @@ -94,7 +92,7 @@

◆ Mac Deep Cleaner — Scan Report

-
Generated {generated_at}  ·  v1.5.0
+
Generated {generated_at}  ·  v2.0.0
@@ -144,7 +142,7 @@ -
Mac Deep Cleaner v1.5.0  ·  Report generated {generated_at}
+
Mac Deep Cleaner v2.0.0  ·  Report generated {generated_at}