From 64e636665a5e27993277e86f071a2607d9604053 Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Tue, 12 May 2026 09:22:36 +0530 Subject: [PATCH 1/8] new features --- CHANGELOG.md | 14 + README.md | 29 ++ checklist.md | 62 ++-- roadmap.md | 76 ++--- src/cli.py | 524 +++++++++++++++++++++++++++++++- src/core/completions.py | 66 ++++ src/core/dry_run.py | 32 ++ src/core/uninstaller.py | 339 +++++++++++++++++++++ src/scanners/browser_data.py | 274 +++++++++++++++++ src/scanners/photos_analyzer.py | 127 ++++++++ src/scanners/simulators.py | 127 ++++++++ src/scanners/space_map.py | 113 +++++++ tests/test_features_p0_p1.py | 93 ++++++ 13 files changed, 1798 insertions(+), 78 deletions(-) create mode 100644 src/core/completions.py create mode 100644 src/core/dry_run.py create mode 100644 src/core/uninstaller.py create mode 100644 src/scanners/browser_data.py create mode 100644 src/scanners/photos_analyzer.py create mode 100644 src/scanners/simulators.py create mode 100644 src/scanners/space_map.py create mode 100644 tests/test_features_p0_p1.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 663d984..6e3b307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,25 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. ## Unreleased + +## v1.3.0 (2026-05-12) ### Added +- Global dry-run flag that blocks destructive actions (`--dry-run`) +- Shell completions command for bash/zsh/fish +- Full app uninstaller command with undo staging support +- Browser data cleaner command (cache, cookies, history, sessions) +- Disk space map command for folder usage summaries +- Photos library analyzer command for Photos bundles +- iOS simulator cleaner command (devices, caches, logs) +- P0/P1 modules in core/scanners with CLI wiring +- Tests for the new P0/P1 features - Debug logging flags (`--verbose`, `--log-file`) with file rotation - Basic test coverage for utilities and matching ### Changed +- CLI wiring for new P0/P1 commands and dry-run behavior +- Module layout aligned to core/scanners (removed features package) +- README, checklist, and roadmap paths updated for the new layout - Improved error handling with debug logs across filesystem and subprocess paths - Dev junk scanner traversal now uses a deque for better performance diff --git a/README.md b/README.md index da3933f..8c18ccc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ mdc scan - **Duplicate finder** — SHA-256 content hashing, two-phase (head + full), sorted by wasted space - **Large file scanner** — finds files ≥100 MB (configurable), categorised by type - **Broken symlink detector** — walks `/usr/local`, `/opt/homebrew`, `~/bin`, etc. +- **Browser data cleaner** — cache, cookies, history, sessions (opt-in delete) +- **Space map** — disk usage overview by folder tree +- **Photos analyzer** — summaries of Photos libraries and originals +- **iOS simulator cleaner** — shows simulator sizes and can purge +- **Full app uninstaller** — remove app bundle plus known data +- **Shell completions** — bash, zsh, fish - **iOS backup finder** — parses `MobileSync/Backup` manifests, shows device/age/size - **Language pack stripper** — detects removable `.lproj` dirs in every installed app - **Universal binary thinner** — uses `ditto --arch` safely; creates `.fat_backup` by default @@ -84,6 +90,9 @@ mac-cleaner clean # Auto-delete everything detected mac-cleaner clean --auto +# Force preview mode (no deletes anywhere) +mac-cleaner --dry-run clean + # Permanently delete (skip undo staging) mac-cleaner clean --no-undo @@ -125,6 +134,26 @@ mac-cleaner scan --log-file ~/mac-cleaner.log ### New scanners ```bash +# Shell completions +mac-cleaner completions --shell zsh --instructions + +# Full app uninstall +mac-cleaner uninstall "Slack" + +# Browser data cleanup +mac-cleaner browser-data +mac-cleaner browser-data --browser chrome --category cache --clean + +# Disk usage map +mac-cleaner space-map --depth 2 --limit 12 + +# Photos library analyzer +mac-cleaner photos --details + +# iOS simulator cleaner +mac-cleaner simulators +mac-cleaner simulators --purge-unavailable --yes + # Find duplicate files (default: ~/Downloads, ~/Documents, ~/Desktop, ~/Pictures) mac-cleaner duplicates mac-cleaner duplicates --path ~/Movies --min-size 500 diff --git a/checklist.md b/checklist.md index d1a170b..d059814 100644 --- a/checklist.md +++ b/checklist.md @@ -5,42 +5,42 @@ Date: 2026-05-11 Use this file as the execution order. Check items only after the feature is fully implemented, wired to CLI, and covered by tests when feasible. ## P0 (baseline UX and safety) -- [ ] Global --dry-run flag (src/features/ux/dry_run.py) -- [ ] Shell completion command (src/features/ux/completions.py) -- [ ] Full app uninstaller (src/features/apps/uninstaller.py) +- [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) -- [ ] Browser data cleaner (src/features/privacy/browser_data.py) -- [ ] Visual disk space map (src/features/storage/space_map.py) -- [ ] Photo library analyzer (src/features/storage/photos_analyzer.py) -- [ ] iOS simulator deep cleaner (src/features/dev/simulators.py) +- [x] Browser data cleaner (src/scanners/browser_data.py) +- [x] Visual disk space map (src/scanners/space_map.py) +- [x] Photo library analyzer (src/scanners/photos_analyzer.py) +- [x] iOS simulator deep cleaner (src/scanners/simulators.py) ## P2 (system utilities and maintenance) -- [ ] Memory pressure reliever (src/features/system/memory_pressure.py) -- [ ] Homebrew deep manager (src/features/apps/brew_manager.py) -- [ ] Storage trend tracker (src/features/reporting/storage_trend.py) -- [ ] Recent files and activity cleaner (src/features/privacy/recent_activity.py) +- [ ] 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) ## P3 (advanced and higher risk features) -- [ ] Permissions auditor (src/features/security/permissions_auditor.py) -- [ ] APFS snapshot guard (src/features/safety/apfs_snapshots.py) -- [ ] Menu bar companion (src/features/ux/menubar.py) -- [ ] Data breach monitor (src/features/security/breach_monitor.py) -- [ ] Cloud storage junk scanner (src/features/storage/cloud_junk.py) +- [ ] 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) ## Additional (not yet scheduled) -- [ ] Purgeable space reclaimer (src/features/storage/purgeable.py) -- [ ] Installer and PKG file hunter (src/features/storage/installer_hunter.py) -- [ ] DNS cache flush (src/features/system/dns_cache.py) -- [ ] Font cache rebuild (src/features/system/font_cache.py) -- [ ] Spotlight re-index (src/features/system/spotlight.py) -- [ ] Sleep and power optimizer (src/features/system/power_optimizer.py) -- [ ] App update checker (src/features/apps/update_checker.py) -- [ ] PKG receipt manager (src/features/apps/pkg_receipts.py) -- [ ] Xcode derived data cleaner (src/features/dev/xcode_cleaner.py) -- [ ] Weekly digest report (src/features/reporting/weekly_digest.py) -- [ ] Cleaning impact score (src/features/reporting/impact_score.py) -- [ ] Interactive TUI app picker (src/features/ux/tui_picker.py) -- [ ] Multi-Mac config sync (src/features/ux/config_sync.py) -- [ ] Time Machine backup guard (src/features/safety/time_machine_guard.py) -- [ ] Restore checksum verification (src/features/safety/restore_checksums.py) +- [ ] Purgeable space reclaimer (src/scanners/purgeable.py) +- [ ] Installer and PKG file hunter (src/scanners/installer_hunter.py) +- [ ] DNS cache flush (src/core/dns_cache.py) +- [ ] Font cache rebuild (src/core/font_cache.py) +- [ ] Spotlight re-index (src/core/spotlight.py) +- [ ] Sleep and power optimizer (src/core/power_optimizer.py) +- [ ] App update checker (src/core/update_checker.py) +- [ ] PKG receipt manager (src/core/pkg_receipts.py) +- [ ] Xcode derived data cleaner (src/scanners/xcode_cleaner.py) +- [ ] Weekly digest report (src/reporting/weekly_digest.py) +- [ ] Cleaning impact score (src/reporting/impact_score.py) +- [ ] Interactive TUI app picker (src/core/tui_picker.py) +- [ ] Multi-Mac config sync (src/core/config_sync.py) +- [ ] Time Machine backup guard (src/core/time_machine_guard.py) +- [ ] Restore checksum verification (src/core/restore_checksums.py) diff --git a/roadmap.md b/roadmap.md index 1b834e4..e92d5dd 100644 --- a/roadmap.md +++ b/roadmap.md @@ -11,64 +11,64 @@ Date: 2026-05-11 ## Proposed Feature Modules (one per feature) Privacy and security -- src/features/privacy/browser_data.py -- src/features/privacy/recent_activity.py -- src/features/security/breach_monitor.py -- src/features/security/permissions_auditor.py +- src/scanners/browser_data.py +- src/scanners/recent_activity.py +- src/core/breach_monitor.py +- src/core/permissions_auditor.py Storage intelligence -- src/features/storage/space_map.py -- src/features/storage/purgeable.py -- src/features/storage/cloud_junk.py -- src/features/storage/photos_analyzer.py -- src/features/storage/installer_hunter.py +- src/scanners/space_map.py +- src/scanners/purgeable.py +- src/scanners/cloud_junk.py +- src/scanners/photos_analyzer.py +- src/scanners/installer_hunter.py Performance and system -- src/features/system/memory_pressure.py -- src/features/system/dns_cache.py -- src/features/system/font_cache.py -- src/features/system/spotlight.py -- src/features/system/power_optimizer.py +- src/core/memory_pressure.py +- src/core/dns_cache.py +- src/core/font_cache.py +- src/core/spotlight.py +- src/core/power_optimizer.py Application management -- src/features/apps/uninstaller.py -- src/features/apps/update_checker.py -- src/features/apps/brew_manager.py -- src/features/apps/pkg_receipts.py +- src/core/uninstaller.py +- src/core/update_checker.py +- src/core/brew_manager.py +- src/core/pkg_receipts.py Simulation and development -- src/features/dev/simulators.py -- src/features/dev/xcode_cleaner.py +- src/scanners/simulators.py +- src/scanners/xcode_cleaner.py Reporting and insights -- src/features/reporting/weekly_digest.py -- src/features/reporting/storage_trend.py -- src/features/reporting/impact_score.py +- src/reporting/weekly_digest.py +- src/reporting/storage_trend.py +- src/reporting/impact_score.py UX and workflow -- src/features/ux/completions.py -- src/features/ux/tui_picker.py -- src/features/ux/dry_run.py -- src/features/ux/config_sync.py -- src/features/ux/menubar.py +- src/core/completions.py +- src/core/tui_picker.py +- src/core/dry_run.py +- src/core/config_sync.py +- src/core/menubar.py Safety enhancements -- src/features/safety/time_machine_guard.py -- src/features/safety/apfs_snapshots.py -- src/features/safety/restore_checksums.py +- src/core/time_machine_guard.py +- src/core/apfs_snapshots.py +- src/core/restore_checksums.py ## Phases and Order P0 (baseline UX and safety) -- Global --dry-run flag (ux/dry_run) -- Shell completion command (ux/completions) -- Full app uninstaller (apps/uninstaller) +- Global --dry-run flag (core/dry_run) +- Shell completion command (core/completions) +- Full app uninstaller (core/uninstaller) P1 (highest demand data and visibility) -- Browser data cleaner (privacy/browser_data) -- Visual disk space map (storage/space_map) -- Photo library analyzer (storage/photos_analyzer) -- iOS simulator deep cleaner (dev/simulators) +- Browser data cleaner (scanners/browser_data) +- Visual disk space map (scanners/space_map) +- Photo library analyzer (scanners/photos_analyzer) +- iOS simulator deep cleaner (scanners/simulators) P2 (system utilities and maintenance) - Memory pressure reliever (system/memory_pressure) diff --git a/src/cli.py b/src/cli.py index 3b5d6c8..17b08e2 100644 --- a/src/cli.py +++ b/src/cli.py @@ -9,6 +9,12 @@ scan Preview scan (orphans + junk) — safe clean Interactive or auto cleanup info Safety guarantees + completions Generate shell completion scripts + uninstall Full app uninstaller + browser-data Clean browser caches/history/cookies + space-map Visual disk space map + photos Photo library analyzer + simulators iOS simulator deep cleaner duplicates Find duplicate files by hash large-files Find files over a size threshold symlinks Find broken symbolic links @@ -141,13 +147,22 @@ def _ensure_first_run_profile(profile: Optional[str], ci: bool) -> Optional[str] help="Enable debug logging to file.") @click.option("--log-file", type=click.Path(), default=None, help="Write logs to a file (default: ~/.config/mac-cleaner/mac-cleaner.log).") +@click.option("--dry-run", is_flag=True, default=False, + help="Do not modify anything (disables deletions and writes).") @click.pass_context -def main(ctx: click.Context, verbose: bool, log_file: Optional[str]) -> None: +def main( + ctx: click.Context, + verbose: bool, + log_file: Optional[str], + dry_run: bool, +) -> None: """Mac Deep Cleaner v1.2.0 — Professional macOS cleanup tool.""" + from core.dry_run import set_dry_run configure_logging( verbose=verbose, log_file=Path(log_file) if log_file else None, ) + set_dry_run(ctx, dry_run) if ctx.invoked_subcommand is None: ctx.invoke(scan) @@ -437,7 +452,9 @@ def render() -> Layout: @click.option("--notify", is_flag=True, default=False) @click.option("--no-undo", is_flag=True, default=False, help="Permanently delete instead of staging for undo.") +@click.pass_context def clean( + ctx: click.Context, auto: bool, skip_junk: bool, whitelist: Tuple[str, ...], @@ -457,6 +474,7 @@ def clean( and can be restored with: mac-cleaner undo Pass --no-undo to permanently delete (faster, no recovery). """ + from core.dry_run import dry_run_enabled profile = _ensure_first_run_profile(profile=profile, ci=False) cfg = load_config(profile=profile) wl = cfg.whitelist_set | { @@ -465,9 +483,12 @@ def clean( cfg.custom_scan_roots.extend(Path(p).expanduser().resolve() for p in custom_roots) cfg.dev_junk_roots.extend(Path(p).expanduser().resolve() for p in dev_roots) undo_mode = cfg.undo_mode and not no_undo + dry_run = dry_run_enabled(ctx) + if dry_run: + console.print("[yellow]Dry-run enabled; clean will run in preview mode.[/yellow]") _run( - delete=True, + delete=not dry_run, auto=auto, skip_junk=skip_junk, export_path=export_path, @@ -511,6 +532,441 @@ def info() -> None: console.print() +# ══════════════════════════════════════════════════════════════════════════════ +# COMPLETIONS +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("completions") +@click.option("--shell", default=None, + type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False), + help="Shell type (bash, zsh, fish).") +@click.option("--instructions", is_flag=True, default=False, + help="Show install instructions for your shell.") +def cmd_completions(shell: Optional[str], instructions: bool) -> None: + """Generate shell completion scripts.""" + from core.completions import completion_script, detect_shell, install_instructions + + resolved_shell = (shell or detect_shell()).lower() + script = completion_script(resolved_shell, "mac-cleaner", main) + console.print(script) + if instructions: + console.print() + console.print(install_instructions(resolved_shell, "mac-cleaner")) + + +# ══════════════════════════════════════════════════════════════════════════════ +# UNINSTALL +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("uninstall") +@click.argument("app_query") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation and uninstall immediately.") +@click.option("--no-undo", is_flag=True, default=False, + help="Permanently delete instead of staging for undo.") +@click.option("--keep-preferences", is_flag=True, default=False, + help="Keep Preferences and Saved State data.") +@click.option("--force", is_flag=True, default=False, + help="Allow uninstall even if the app appears to be running.") +@click.pass_context +def cmd_uninstall( + ctx: click.Context, + app_query: str, + yes: bool, + no_undo: bool, + keep_preferences: bool, + force: bool, +) -> None: + """Remove an app and its data (full uninstall).""" + from core.uninstaller import build_uninstall_plan, execute_uninstall, find_app_candidates + from core.dry_run import dry_run_enabled + + apps = discover_installed_apps() + matches = find_app_candidates(app_query, apps) + + if not matches: + console.print(f"[yellow]No installed app matched '{app_query}'.[/yellow]") + return + + app = matches[0] + if len(matches) > 1: + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("#", style="dim", width=4, justify="right") + table.add_column("App") + table.add_column("Bundle ID", style="dim") + table.add_column("Path", style="dim") + for i, a in enumerate(matches, 1): + table.add_row(str(i), a.name, a.bundle_id, str(a.path)) + console.print(table) + choice = click.prompt("Select app", type=click.IntRange(1, len(matches))) + app = matches[choice - 1] + + running = running_bundle_ids() + if app.bundle_id.lower() in running and not force: + console.print( + "[yellow]App appears to be running. Quit it or pass --force to continue.[/yellow]" + ) + return + + cfg = load_config() + plan = build_uninstall_plan( + app=app, + whitelist_set=cfg.whitelist_set, + keep_preferences=keep_preferences, + ) + + if not plan.deletable_items and not plan.protected_items: + console.print("[yellow]No removable data found for this app.[/yellow]") + return + + console.print() + console.print(Panel( + f"[bold cyan]Uninstall Plan[/bold cyan] [dim]{app.name}[/dim]", + border_style="cyan", padding=(0, 2), + )) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("#", style="dim", width=4, justify="right") + table.add_column("Category", width=16) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + + for i, item in enumerate(plan.deletable_items[:50], 1): + table.add_row(str(i), item.category, bytes_human(item.size), str(item.path)) + + console.print(table) + if len(plan.deletable_items) > 50: + console.print(f" [dim]... {len(plan.deletable_items) - 50} more items omitted[/dim]") + if plan.protected_items: + console.print( + f" [dim]{len(plan.protected_items)} item(s) protected by safety checks[/dim]" + ) + + console.print( + f"\n Total removable: [yellow]{bytes_human(plan.total_size)}[/yellow]" + ) + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; uninstall skipped.[/yellow]") + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Proceed with uninstall?", default=False) + if not do_it: + return + + session = None + if cfg.undo_mode and not no_undo: + session = new_session() + + result = execute_uninstall(plan, session=session) + + if session and result.staged > 0: + session.save() + console.print( + f"\n [green]Staged {bytes_human(result.bytes_freed)} for undo[/green]" + ) + console.print( + f" [dim]Restore with: mac-cleaner undo --session {session.session_id[:8]}[/dim]" + ) + else: + console.print( + f"\n [green]Removed {bytes_human(result.bytes_freed)}[/green]" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# BROWSER DATA CLEANER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("browser-data") +@click.option("--browser", "browsers", multiple=True, + type=click.Choice(["safari", "chrome", "firefox", "edge", "brave"], + case_sensitive=False), + help="Limit to specific browsers.") +@click.option("--category", "categories", multiple=True, + type=click.Choice(["cache", "cookies", "history", "downloads", "site-data", "sessions"], + case_sensitive=False), + help="Limit to specific data categories.") +@click.option("--clean", is_flag=True, default=False, + help="Delete selected data (requires --category or --all).") +@click.option("--all", "clean_all", is_flag=True, default=False, + help="Delete all supported categories for selected browsers.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for deletions.") +@click.pass_context +def cmd_browser_data( + ctx: click.Context, + browsers: Tuple[str, ...], + categories: Tuple[str, ...], + clean: bool, + clean_all: bool, + yes: bool, +) -> None: + """Analyze and optionally clean browser data.""" + from scanners.browser_data import ( + collect_browser_data, + delete_browser_data, + summarize_browser_data, + ) + from core.dry_run import dry_run_enabled + + items = collect_browser_data(browsers=list(browsers) or None) + if not items: + console.print("[green]No browser data found.[/green]") + return + + summary = summarize_browser_data(items) + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Browser", width=14) + table.add_column("Category", width=14) + table.add_column("Items", justify="right", width=7) + table.add_column("Size", justify="right", style="yellow", width=12) + + for row in summary: + table.add_row(row[0], row[1], str(row[2]), bytes_human(row[3])) + + console.print() + console.print(Panel("[bold cyan]Browser Data Summary[/bold cyan]", + border_style="cyan", padding=(0, 2))) + console.print(table) + + if not (clean or clean_all): + return + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; browser data cleanup skipped.[/yellow]") + return + + target_categories = [c.lower() for c in categories] + if not clean_all and not target_categories: + console.print("[yellow]Specify --category or use --all to clean.[/yellow]") + return + + if clean_all: + target_categories = [] # empty means all in delete_browser_data + + if not yes: + from rich.prompt import Confirm + if not Confirm.ask("Proceed with browser data deletion?", default=False): + return + + result = delete_browser_data(items, categories=target_categories) + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped by safety checks[/dim]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# SPACE MAP +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("space-map") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Root directories to map (default: HOME).") +@click.option("--depth", default=2, show_default=True, + help="Folder depth to include.") +@click.option("--limit", default=12, show_default=True, + help="Maximum child entries shown per directory.") +@click.option("--min-mb", default=1, show_default=True, + help="Minimum size per entry (MB).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export map to JSON.") +def cmd_space_map( + roots: Tuple[str, ...], + depth: int, + limit: int, + min_mb: int, + export_path: Optional[str], +) -> None: + """Visual disk space map.""" + from scanners.space_map import build_usage_tree, render_usage_tree + from constants import HOME + + min_bytes = min_mb * 1024 * 1024 + root_paths = [Path(p).expanduser().resolve() for p in roots] or [HOME] + + trees = [] + for root in root_paths: + node = build_usage_tree(root, max_depth=depth, min_size=min_bytes) + trees.append(node) + console.print() + console.print(Panel( + f"[bold cyan]Space Map[/bold cyan] [dim]{root}[/dim]", + border_style="cyan", padding=(0, 2), + )) + console.print(render_usage_tree(node, limit=limit)) + + if export_path: + import json + data = [t.to_dict() for t in trees] + with open(export_path, "w") as f: + json.dump(data, f, indent=2, default=str) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# PHOTOS ANALYZER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("photos") +@click.option("--root", "roots", multiple=True, type=click.Path(exists=True), + help="Search roots for Photos libraries (default: ~/Pictures).") +@click.option("--details", is_flag=True, default=False, + help="Show file type breakdown for originals.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export analysis to JSON.") +def cmd_photos( + roots: Tuple[str, ...], + details: bool, + export_path: Optional[str], +) -> None: + """Analyze Photos libraries and storage usage.""" + from scanners.photos_analyzer import analyze_photo_library, find_photo_libraries + from constants import HOME + + search_roots = [Path(p).expanduser().resolve() for p in roots] or [HOME / "Pictures"] + libs = find_photo_libraries(search_roots=search_roots) + if not libs: + console.print("[yellow]No Photos libraries found in the selected roots.[/yellow]") + return + + reports = [analyze_photo_library(p) for p in libs] + + console.print() + console.print(Panel("[bold cyan]Photos Library Analysis[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Library", min_width=22) + table.add_column("Total", justify="right", style="yellow", width=10) + table.add_column("Originals", justify="right", width=10) + table.add_column("Previews", justify="right", width=10) + table.add_column("Database", justify="right", width=10) + table.add_column("Originals Count", justify="right", width=16) + + for r in reports: + table.add_row( + r.name, + bytes_human(r.size), + bytes_human(r.originals_size), + bytes_human(r.previews_size), + bytes_human(r.database_size), + str(r.originals_count), + ) + + console.print(table) + + if details: + for r in reports: + console.print(f"\n [bold]{r.name}[/bold]") + for ext, count, size in r.top_extensions(8): + console.print(f" {ext:>6} {count:>6} files {bytes_human(size)}") + + if export_path: + import json + with open(export_path, "w") as f: + json.dump([r.to_dict() for r in reports], f, indent=2, default=str) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# SIMULATOR CLEANER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("simulators") +@click.option("--purge-unavailable", is_flag=True, default=False, + help="Delete data for unavailable simulators only.") +@click.option("--purge-all", is_flag=True, default=False, + help="Delete data for all simulators (destructive).") +@click.option("--purge-caches", is_flag=True, default=False, + help="Delete CoreSimulator caches and logs.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation prompts.") +@click.pass_context +def cmd_simulators( + ctx: click.Context, + purge_unavailable: bool, + purge_all: bool, + purge_caches: bool, + yes: bool, +) -> None: + """Inspect and clean iOS Simulator data.""" + from scanners.simulators import ( + find_simulator_caches, + find_simulator_devices, + purge_simulator_caches, + purge_simulator_devices, + ) + from core.dry_run import dry_run_enabled + + devices = find_simulator_devices() + caches = find_simulator_caches() + + console.print() + console.print(Panel("[bold cyan]iOS Simulator Data[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if devices: + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Name", min_width=20) + table.add_column("Runtime", width=18) + table.add_column("State", width=12) + table.add_column("Available", width=10) + table.add_column("Size", justify="right", style="yellow", width=10) + for d in devices: + table.add_row(d.name, d.runtime, d.state, "yes" if d.is_available else "no", bytes_human(d.size)) + console.print(table) + else: + console.print(" [dim]No simulator devices found.[/dim]") + + if caches: + cache_total = sum(c.size for c in caches) + console.print(f"\n Caches: {bytes_human(cache_total)}") + for c in caches: + console.print(f" {c.category}: {bytes_human(c.size)}") + + if not (purge_unavailable or purge_all or purge_caches): + return + + if dry_run_enabled(ctx): + console.print("[yellow]Dry-run enabled; simulator cleanup skipped.[/yellow]") + return + + if purge_unavailable or purge_all: + targets = devices if purge_all else [d for d in devices if not d.is_available] + if targets: + if not yes: + from rich.prompt import Confirm + if not Confirm.ask( + f"Delete simulator data for {len(targets)} device(s)?", + default=False, + ): + targets = [] + if targets: + result = purge_simulator_devices(targets) + console.print( + f"\n [green]Deleted {result.deleted} device(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + else: + console.print(" [dim]No devices matched purge criteria.[/dim]") + + if purge_caches and caches: + if yes: + proceed = True + else: + from rich.prompt import Confirm + proceed = Confirm.ask("Delete CoreSimulator caches?", default=False) + if proceed: + result = purge_simulator_caches(caches) + console.print( + f" [green]Deleted {result.deleted} cache item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + # ══════════════════════════════════════════════════════════════════════════════ # DUPLICATES # ══════════════════════════════════════════════════════════════════════════════ @@ -524,7 +980,9 @@ def info() -> None: help="Export results to JSON.") @click.option("--delete", is_flag=True, default=False, help="Interactively delete duplicates (keeps the first copy).") +@click.pass_context def cmd_duplicates( + ctx: click.Context, paths: Tuple[str, ...], min_size: int, export_path: Optional[str], @@ -601,6 +1059,9 @@ def _cb(n: int) -> None: console.print(f"\n [green]✓ Exported to {export_path}[/green]") if delete: + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "duplicate deletions"): + return from rich.prompt import Confirm console.print() for g in groups: @@ -698,7 +1159,8 @@ def cmd_large_files( @click.option("--path", "paths", multiple=True, type=click.Path()) @click.option("--delete", is_flag=True, default=False, help="Delete broken symlinks after confirmation.") -def cmd_symlinks(paths: Tuple[str, ...], delete: bool) -> None: +@click.pass_context +def cmd_symlinks(ctx: click.Context, paths: Tuple[str, ...], delete: bool) -> None: """Find broken (dangling) symbolic links in developer directories.""" from scanners.symlinks import find_broken_symlinks, DEFAULT_ROOTS @@ -734,6 +1196,9 @@ def cmd_symlinks(paths: Tuple[str, ...], delete: bool) -> None: console.print(table) if delete: + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "symlink deletions"): + return from rich.prompt import Confirm if Confirm.ask(f"\n Delete all {len(broken)} broken symlinks?", default=False): deleted = 0 @@ -761,7 +1226,9 @@ def cmd_symlinks(paths: Tuple[str, ...], delete: bool) -> None: help="Interactively delete old iOS backups.") @click.option("--strip-languages", is_flag=True, default=False, help="Interactively strip unused language packs.") +@click.pass_context def cmd_extras( + ctx: click.Context, ios_backups: bool, language_packs: bool, all_extras: bool, @@ -776,8 +1243,13 @@ def cmd_extras( mac-cleaner extras --ios-backups --delete-backups mac-cleaner extras --language-packs --strip-languages """ + from core.dry_run import dry_run_enabled from scanners.extras import find_ios_backups, find_language_packs + dry_run = dry_run_enabled(ctx) + if dry_run and (delete_backups or strip_languages): + console.print("[yellow]Dry-run enabled; delete actions are skipped.[/yellow]") + do_ios = ios_backups or all_extras do_lang = language_packs or all_extras @@ -815,7 +1287,7 @@ def cmd_extras( table.add_row(b.device_name, b.ios_version, age, b.size_human, str(b.path)) console.print(table) - if delete_backups: + if delete_backups and not dry_run: from rich.prompt import Confirm console.print() for b in backups: @@ -854,7 +1326,7 @@ def cmd_extras( table.add_row(e.app_name, str(len(e.removable_lprojs)), e.removable_size_human) console.print(table) - if strip_languages: + if strip_languages and not dry_run: from rich.prompt import Confirm console.print() for e in lang_entries: @@ -884,7 +1356,9 @@ def cmd_extras( help="Interactively thin fat binaries.") @click.option("--no-backup", is_flag=True, default=False, help="Skip .fat_backup copy (irreversible!).") +@click.pass_context def cmd_binary( + ctx: click.Context, paths: Tuple[str, ...], arch: Optional[str], thin: bool, @@ -936,6 +1410,9 @@ def cmd_binary( console.print(table) if thin: + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "binary thinning"): + return from rich.prompt import Confirm keep_backup = not no_backup freed = 0 @@ -972,15 +1449,30 @@ def cmd_binary( help="Permanently purge old staged files beyond retention period.") @click.option("--purge-all", "purge_all", is_flag=True, default=False, help="Permanently purge ALL staged sessions regardless of age.") -def cmd_undo(list_only: bool, session_id: Optional[str], purge: bool, purge_all: bool) -> None: +@click.pass_context +def cmd_undo( + ctx: click.Context, + list_only: bool, + session_id: Optional[str], + purge: bool, + purge_all: bool, +) -> None: """Restore files from the staging area (undo a clean operation). \b Files are staged in ~/.mac_cleaner_trash/ during clean. Sessions older than 30 days are purged automatically. """ + from core.dry_run import dry_run_enabled from core.undo import list_sessions, restore_session, purge_old_sessions, purge_all_sessions + dry_run = dry_run_enabled(ctx) + if dry_run and (purge or purge_all or not list_only): + console.print("[yellow]Dry-run enabled; restore and purge actions are skipped.[/yellow]") + purge = False + purge_all = False + list_only = True + sessions = list_sessions() if purge_all: @@ -1274,8 +1766,12 @@ def cmd_schedule() -> None: @cmd_schedule.command("install") @click.option("--no-notify", is_flag=True, default=False) -def schedule_install(no_notify: bool) -> None: +@click.pass_context +def schedule_install(ctx: click.Context, no_notify: bool) -> None: """Install a weekly LaunchAgent to run scans automatically.""" + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "schedule install"): + return from core.scheduler import install_schedule ok, msg = install_schedule(notify=not no_notify) color = "green" if ok else "red" @@ -1283,8 +1779,12 @@ def schedule_install(no_notify: bool) -> None: @cmd_schedule.command("remove") -def schedule_remove() -> None: +@click.pass_context +def schedule_remove(ctx: click.Context) -> None: """Remove the weekly scan LaunchAgent.""" + from core.dry_run import skip_if_dry_run + if skip_if_dry_run(ctx, console, "schedule removal"): + return from core.scheduler import remove_schedule ok, msg = remove_schedule() color = "green" if ok else "yellow" @@ -1315,9 +1815,11 @@ def schedule_status() -> None: help="Check only — do not upgrade.") @click.option("--yes", "-y", is_flag=True, default=False, help="Upgrade without prompting.") -def cmd_update(check: bool, yes: bool) -> None: +@click.pass_context +def cmd_update(ctx: click.Context, check: bool, yes: bool) -> None: """Check for a newer version on PyPI and optionally upgrade.""" from core.updater import check_for_update, perform_upgrade + from core.dry_run import dry_run_enabled console.print() console.print(f" Current version: [bold cyan]{__version__}[/bold cyan]") @@ -1340,6 +1842,10 @@ def cmd_update(check: bool, yes: bool) -> None: if check: return + if dry_run_enabled(ctx): + console.print(" [yellow]Dry-run enabled; upgrade skipped.[/yellow]") + return + do_it = yes if not do_it: from rich.prompt import Confirm diff --git a/src/core/completions.py b/src/core/completions.py new file mode 100644 index 0000000..32a0be1 --- /dev/null +++ b/src/core/completions.py @@ -0,0 +1,66 @@ +"""Shell completion helpers.""" + +from __future__ import annotations + +import os + +SUPPORTED_SHELLS = ("bash", "zsh", "fish") + + +def detect_shell() -> str: + """Best-effort shell detection from $SHELL.""" + shell = os.environ.get("SHELL", "") + base = os.path.basename(shell) + return base if base in SUPPORTED_SHELLS else "bash" + + +def _complete_var(prog_name: str) -> str: + return f"_{prog_name.replace('-', '_').upper()}_COMPLETE" + + +def _fallback_line(shell: str, prog_name: str) -> str: + complete_var = _complete_var(prog_name) + if shell == "fish": + return f"env {complete_var}=fish_source {prog_name} | source" + return f"eval \"$({complete_var}={shell}_source {prog_name})\"" + + +def _fallback_script(shell: str, prog_name: str) -> str: + line = _fallback_line(shell, prog_name) + return f"# Fallback completion line\n{line}\n" + + +def completion_script(shell: str, prog_name: str, command=None) -> str: + """Return a shell completion script for Click-based CLIs.""" + shell = shell.lower() + if shell not in SUPPORTED_SHELLS: + raise ValueError(f"Unsupported shell: {shell}") + + try: + from click.shell_completion import get_completion_script + except Exception: + return _fallback_script(shell, prog_name) + import inspect + + params = list(inspect.signature(get_completion_script).parameters) + has_command = "command" in params + has_complete_var = "complete_var" in params + + try: + if has_command and command is not None: + return get_completion_script(prog_name, shell, command) + if has_complete_var: + return get_completion_script(prog_name, shell, _complete_var(prog_name)) + return get_completion_script(prog_name, shell) + except TypeError: + return _fallback_script(shell, prog_name) + + +def install_instructions(shell: str, prog_name: str) -> str: + """Return shell-specific install instructions.""" + line = _fallback_line(shell, prog_name) + if shell == "zsh": + return f"Add this line to ~/.zshrc:\n{line}" + if shell == "fish": + return f"Run this once, or add to ~/.config/fish/config.fish:\n{line}" + return f"Add this line to ~/.bashrc:\n{line}" diff --git a/src/core/dry_run.py b/src/core/dry_run.py new file mode 100644 index 0000000..e89dd5f --- /dev/null +++ b/src/core/dry_run.py @@ -0,0 +1,32 @@ +"""Global dry-run helpers.""" + +from __future__ import annotations + +from typing import Optional + +import click +from rich.console import Console + +DRY_RUN_KEY = "dry_run" + + +def set_dry_run(ctx: click.Context, value: bool) -> None: + """Store dry-run flag in the Click context.""" + ctx.ensure_object(dict) + ctx.obj[DRY_RUN_KEY] = bool(value) + + +def dry_run_enabled(ctx: Optional[click.Context]) -> bool: + """Return True when global dry-run is enabled.""" + if ctx is None: + return False + obj = getattr(ctx, "obj", None) or {} + return bool(obj.get(DRY_RUN_KEY, False)) + + +def skip_if_dry_run(ctx: Optional[click.Context], console: Console, action: str) -> bool: + """Print a warning and return True when dry-run blocks an action.""" + if dry_run_enabled(ctx): + console.print(f"[yellow]Dry-run enabled; {action} skipped.[/yellow]") + return True + return False diff --git a/src/core/uninstaller.py b/src/core/uninstaller.py new file mode 100644 index 0000000..42a035b --- /dev/null +++ b/src/core/uninstaller.py @@ -0,0 +1,339 @@ +"""Full app uninstaller helpers.""" + +from __future__ import annotations + +import plistlib +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +from config.models import AppInfo +from constants import SEARCH_ROOTS +from core.safety import validate_path_for_deletion +from core.scanner import classify_root +from scanners.matching import match_to_app +from utils import iterdir_safe, size_of + + +@dataclass +class UninstallItem: + """One deletable (or protected) app artifact.""" + path: Path + category: str + size: int + protected: bool = False + reason: str = "" + + +@dataclass +class UninstallPlan: + """Resolved uninstall plan for a single app.""" + app: AppInfo + items: List[UninstallItem] = field(default_factory=list) + protected_items: List[UninstallItem] = field(default_factory=list) + + @property + def deletable_items(self) -> List[UninstallItem]: + return [i for i in self.items if not i.protected] + + @property + def total_size(self) -> int: + return sum(i.size for i in self.deletable_items) + + +@dataclass +class UninstallResult: + """Summary of an uninstall operation.""" + deleted: int = 0 + staged: int = 0 + skipped: int = 0 + bytes_freed: int = 0 + errors: List[str] = field(default_factory=list) + + +def _read_app_info(app_path: Path) -> AppInfo: + info_plist = app_path / "Contents" / "Info.plist" + if not info_plist.exists(): + info_plist = app_path / "Info.plist" + + name = app_path.stem + bundle_id = app_path.stem + + if info_plist.exists(): + try: + with open(info_plist, "rb") as f: + pl = plistlib.load(f) + bundle_id = pl.get("CFBundleIdentifier", bundle_id) + name = pl.get("CFBundleDisplayName") or pl.get("CFBundleName") or name + except (OSError, plistlib.InvalidFileException, ValueError): + pass + + return AppInfo(name=str(name), bundle_id=str(bundle_id), path=app_path) + + +def find_app_candidates(query: str, apps: Dict[str, AppInfo]) -> List[AppInfo]: + """Return matching installed apps for a query string or .app path.""" + q = query.strip() + candidates: List[AppInfo] = [] + + path = Path(q).expanduser() + if path.exists() and path.suffix == ".app": + app_info = _read_app_info(path) + if app_info.bundle_id in apps: + return [apps[app_info.bundle_id]] + return [app_info] + + q_lower = q.lower() + + for app in apps.values(): + if q_lower in (app.bundle_id, app.name_lower): + candidates.append(app) + continue + if q_lower in app.bundle_id or q_lower in app.name_lower: + candidates.append(app) + + if not candidates: + matched = match_to_app(q_lower, apps) + if matched: + candidates.append(matched) + + seen: Set[str] = set() + unique: List[AppInfo] = [] + for app in candidates: + if app.bundle_id in seen: + continue + seen.add(app.bundle_id) + unique.append(app) + + return sorted(unique, key=lambda a: a.name_lower) + + +def _identifier_set(app: AppInfo) -> Tuple[str, str, Set[str], str]: + bundle_id = app.bundle_id.lower().strip() + parts = [p for p in bundle_id.split(".") if p] + vendor = parts[1] if len(parts) >= 2 else "" + product = parts[-1] if parts else bundle_id + + tokens: Set[str] = set() + if len(product) >= 4: + tokens.add(product) + for word in re.split(r"[\s\-_]+", app.name_lower): + if len(word) >= 4: + tokens.add(word) + + return bundle_id, vendor, tokens, app.name_lower + + +def _matches_bundle_prefix(name: str, bundle_id: str) -> bool: + if not bundle_id: + return False + return name == bundle_id or name.startswith(bundle_id + ".") or name.startswith(bundle_id + "-") + + +def _matches_bundle_suffix(name: str, bundle_id: str) -> bool: + if not bundle_id: + return False + return name == bundle_id or name.endswith("." + bundle_id) or name.endswith(bundle_id) + + +def _matches_direct_name(name: str, bundle_id: str, tokens: Set[str], app_name: str) -> bool: + if _matches_bundle_prefix(name, bundle_id): + return True + if app_name and name == app_name: + return True + if name in tokens: + return True + return False + + +def _matches_vendor_child(name: str, bundle_id: str, tokens: Set[str]) -> bool: + if _matches_bundle_prefix(name, bundle_id): + return True + for token in tokens: + if name == token or name.startswith(token): + return True + return False + + +def _root_kind(root: Path) -> str: + s = str(root).lower().rstrip("/") + if "group containers" in s: + return "Group Containers" + if s.endswith("/containers"): + return "Containers" + if s.endswith("/saved application state"): + return "Saved State" + if s.endswith("/preferences"): + return "Preferences" + if s.endswith("/launchagents"): + return "Launch Agent" + if s.endswith("/launchdaemons"): + return "Launch Daemon" + if s.endswith("/privilegedhelpertools"): + return "Helper Tool" + if s.endswith("/httpstorages"): + return "HTTP Storage" + if s.endswith("/cookies"): + return "Cookies" + if s.endswith("/syncedpreferences"): + return "Synced Prefs" + if s.endswith("/webkit"): + return "WebKit Data" + return classify_root(root) + + +def _matching_paths( + root: Path, + bundle_id: str, + vendor: str, + tokens: Set[str], + app_name: str, +) -> List[Path]: + matches: List[Path] = [] + kind = _root_kind(root) + + for item in iterdir_safe(root): + name = item.name.lower() + + if kind == "Preferences": + if name.startswith(bundle_id) and item.suffix in {".plist", ".lockfile", ".plist.lockfile", ".plist.disabled"}: + matches.append(item) + continue + + if kind == "Saved State": + if name.startswith(bundle_id) and name.endswith(".savedstate"): + matches.append(item) + continue + + if kind == "Containers": + if name == bundle_id: + matches.append(item) + continue + + if kind == "Group Containers": + if _matches_bundle_suffix(name, bundle_id) or f".{bundle_id}" in name: + matches.append(item) + continue + + if kind in {"Launch Agent", "Launch Daemon", "Helper Tool"}: + if _matches_bundle_prefix(name, bundle_id) or f".{bundle_id}" in name: + matches.append(item) + continue + + if kind in {"HTTP Storage", "Cookies", "Synced Prefs", "WebKit Data"}: + if _matches_bundle_prefix(name, bundle_id) or _matches_bundle_suffix(name, bundle_id): + matches.append(item) + continue + + if vendor and name == vendor and item.is_dir(): + for child in iterdir_safe(item): + if _matches_vendor_child(child.name.lower(), bundle_id, tokens): + matches.append(child) + continue + + if _matches_direct_name(name, bundle_id, tokens, app_name): + matches.append(item) + + return matches + + +def _label_root(root: Path, default: str) -> str: + root_str = str(root).lower() + if "saved application state" in root_str: + return "Saved State" + return default + + +def build_uninstall_plan( + app: AppInfo, + whitelist_set: Optional[Set[Path]] = None, + roots: Optional[Iterable[Path]] = None, + keep_preferences: bool = False, +) -> UninstallPlan: + """Build an uninstall plan for the given app.""" + whitelist = whitelist_set or set() + plan = UninstallPlan(app=app) + seen: Set[Path] = set() + + def _add_item(path: Path, category: str) -> None: + if path in seen: + return + seen.add(path) + if not path.exists(): + return + if path in whitelist or any(wl in path.parents for wl in whitelist): + return + size = size_of(path) + if size <= 0: + return + safe, reason = validate_path_for_deletion(path) + item = UninstallItem( + path=path, + category=category, + size=size, + protected=not safe, + reason=reason, + ) + if item.protected: + plan.protected_items.append(item) + else: + plan.items.append(item) + + _add_item(app.path, "App Bundle") + + bundle_id, vendor, tokens, app_name = _identifier_set(app) + for root in roots or SEARCH_ROOTS: + if not root.exists(): + continue + category = _label_root(root, _root_kind(root)) + if keep_preferences and category in {"Preferences", "Saved State"}: + continue + for match in _matching_paths(root, bundle_id, vendor, tokens, app_name): + _add_item(match, category) + + plan.items.sort(key=lambda i: i.size, reverse=True) + plan.protected_items.sort(key=lambda i: i.size, reverse=True) + return plan + + +def execute_uninstall(plan: UninstallPlan, session=None) -> UninstallResult: + """Execute an uninstall plan (delete or stage).""" + from core.cleaner import write_deletion_log + from utils import safe_remove + + result = UninstallResult() + deleted_entries: List[tuple[str, int]] = [] + + if session is not None: + from core.undo import stage_file + + for item in plan.deletable_items: + safe, reason = validate_path_for_deletion(item.path) + if not safe: + result.skipped += 1 + result.errors.append(f"{item.path}: {reason}") + continue + + if session is not None: + ok, sz = stage_file(item.path, session, category=item.category) + if ok: + result.staged += 1 + result.bytes_freed += sz + deleted_entries.append((str(item.path), sz)) + else: + result.skipped += 1 + continue + + ok, sz = safe_remove(item.path) + if ok: + result.deleted += 1 + result.bytes_freed += sz + deleted_entries.append((str(item.path), sz)) + else: + result.skipped += 1 + + if deleted_entries: + write_deletion_log(deleted_entries) + + return result diff --git a/src/scanners/browser_data.py b/src/scanners/browser_data.py new file mode 100644 index 0000000..89e23d1 --- /dev/null +++ b/src/scanners/browser_data.py @@ -0,0 +1,274 @@ +"""Browser data scanner and cleaner.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +from constants import HOME +from core.safety import validate_path_for_deletion +from utils import size_of + +BROWSER_LABELS = { + "safari": "Safari", + "chrome": "Google Chrome", + "firefox": "Firefox", + "edge": "Microsoft Edge", + "brave": "Brave Browser", +} + +CATEGORY_LABELS = { + "cache": "Cache", + "cookies": "Cookies", + "history": "History", + "downloads": "Downloads", + "site-data": "Site Data", + "sessions": "Sessions", +} + +_ALLOWED_ROOTS: List[Path] = [ + HOME / "Library" / "Safari", + HOME / "Library" / "Caches" / "com.apple.Safari", + HOME / "Library" / "Containers" / "com.apple.Safari", + HOME / "Library" / "Cookies", + HOME / "Library" / "Application Support" / "Google" / "Chrome", + HOME / "Library" / "Application Support" / "Microsoft Edge", + HOME / "Library" / "Application Support" / "BraveSoftware", + HOME / "Library" / "Application Support" / "Firefox", +] + + +@dataclass +class BrowserDataItem: + """One browser data path.""" + browser: str + category: str + path: Path + size: int + profile: Optional[str] = None + + +@dataclass +class DeleteResult: + """Summary of a deletion run.""" + deleted: int = 0 + skipped: int = 0 + bytes_freed: int = 0 + errors: List[str] = field(default_factory=list) + + +def _add_item( + items: List[BrowserDataItem], + seen: Set[Path], + browser: str, + category: str, + path: Path, + profile: Optional[str] = None, +) -> None: + if path in seen or not path.exists(): + return + sz = size_of(path) + if sz <= 0: + return + items.append(BrowserDataItem(browser=browser, category=category, path=path, size=sz, profile=profile)) + seen.add(path) + + +def _chromium_profiles(base: Path) -> List[Path]: + if not base.exists(): + return [] + profiles: List[Path] = [] + for child in base.iterdir(): + if not child.is_dir(): + continue + if child.name == "Default" or child.name.startswith("Profile ") or child.name == "Guest Profile": + profiles.append(child) + return profiles + + +def _collect_chromium(browser: str, base: Path) -> List[BrowserDataItem]: + items: List[BrowserDataItem] = [] + seen: Set[Path] = set() + + for profile in _chromium_profiles(base): + profile_name = profile.name + cache_dirs = [ + profile / "Cache", + profile / "Code Cache", + profile / "GPUCache", + profile / "Media Cache", + profile / "Service Worker" / "CacheStorage", + profile / "Service Worker" / "ScriptCache", + profile / "ShaderCache", + ] + for p in cache_dirs: + _add_item(items, seen, browser, "cache", p, profile=profile_name) + + _add_item(items, seen, browser, "cookies", profile / "Cookies", profile=profile_name) + _add_item(items, seen, browser, "history", profile / "History", profile=profile_name) + _add_item(items, seen, browser, "site-data", profile / "Local Storage", profile=profile_name) + _add_item(items, seen, browser, "site-data", profile / "IndexedDB", profile=profile_name) + _add_item(items, seen, browser, "site-data", profile / "WebStorage", profile=profile_name) + _add_item(items, seen, browser, "sessions", profile / "Sessions", profile=profile_name) + + return items + + +def _collect_firefox() -> List[BrowserDataItem]: + base = HOME / "Library" / "Application Support" / "Firefox" / "Profiles" + items: List[BrowserDataItem] = [] + seen: Set[Path] = set() + + if not base.exists(): + return items + + for profile in base.iterdir(): + if not profile.is_dir(): + continue + profile_name = profile.name + _add_item(items, seen, "firefox", "cache", profile / "cache2", profile=profile_name) + _add_item(items, seen, "firefox", "cookies", profile / "cookies.sqlite", profile=profile_name) + _add_item(items, seen, "firefox", "history", profile / "places.sqlite", profile=profile_name) + _add_item(items, seen, "firefox", "site-data", profile / "storage", profile=profile_name) + _add_item(items, seen, "firefox", "sessions", profile / "sessionstore-backups", profile=profile_name) + + return items + + +def _collect_safari() -> List[BrowserDataItem]: + items: List[BrowserDataItem] = [] + seen: Set[Path] = set() + + safari_root = HOME / "Library" / "Safari" + safari_container = HOME / "Library" / "Containers" / "com.apple.Safari" / "Data" / "Library" + + _add_item(items, seen, "safari", "history", safari_root / "History.db") + _add_item(items, seen, "safari", "downloads", safari_root / "Downloads.plist") + _add_item(items, seen, "safari", "site-data", safari_root / "LocalStorage") + _add_item(items, seen, "safari", "site-data", safari_root / "Databases") + + _add_item(items, seen, "safari", "history", safari_container / "Safari" / "History.db") + _add_item(items, seen, "safari", "downloads", safari_container / "Safari" / "Downloads.plist") + _add_item(items, seen, "safari", "cache", safari_container / "Caches") + _add_item(items, seen, "safari", "site-data", safari_container / "WebKit" / "WebsiteData") + + _add_item(items, seen, "safari", "cookies", HOME / "Library" / "Cookies" / "Cookies.binarycookies") + _add_item(items, seen, "safari", "cookies", safari_container / "Cookies") + _add_item(items, seen, "safari", "cache", HOME / "Library" / "Caches" / "com.apple.Safari") + + return items + + +def collect_browser_data(browsers: Optional[List[str]] = None) -> List[BrowserDataItem]: + """Collect browser data items for the selected browsers.""" + selected = {b.lower() for b in (browsers or BROWSER_LABELS.keys())} + items: List[BrowserDataItem] = [] + + if "safari" in selected: + items.extend(_collect_safari()) + + if "chrome" in selected: + items.extend( + _collect_chromium( + "chrome", + HOME / "Library" / "Application Support" / "Google" / "Chrome", + ) + ) + + if "edge" in selected: + items.extend( + _collect_chromium( + "edge", + HOME / "Library" / "Application Support" / "Microsoft Edge", + ) + ) + + if "brave" in selected: + items.extend( + _collect_chromium( + "brave", + HOME / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser", + ) + ) + + if "firefox" in selected: + items.extend(_collect_firefox()) + + return items + + +def summarize_browser_data(items: List[BrowserDataItem]) -> List[Tuple[str, str, int, int]]: + """Return summary rows for tables.""" + summary: Dict[Tuple[str, str], List[BrowserDataItem]] = {} + for item in items: + key = (item.browser, item.category) + summary.setdefault(key, []).append(item) + + rows: List[Tuple[str, str, int, int]] = [] + for (browser, category), group in sorted(summary.items()): + total = sum(i.size for i in group) + rows.append(( + BROWSER_LABELS.get(browser, browser.title()), + CATEGORY_LABELS.get(category, category.title()), + len(group), + total, + )) + + return rows + + +def delete_browser_data( + items: List[BrowserDataItem], + categories: Optional[List[str]] = None, +) -> "DeleteResult": + """Delete selected browser data items.""" + from utils import safe_remove + + result = DeleteResult() + targets: List[BrowserDataItem] = [] + category_set = {c.lower() for c in (categories or [])} + + for item in items: + if category_set and item.category not in category_set: + continue + targets.append(item) + + seen: Set[Path] = set() + for item in targets: + if item.path in seen: + continue + seen.add(item.path) + safe, reason = validate_path_for_deletion(item.path) + if not safe and not _is_allowed_browser_path(item.path): + result.skipped += 1 + result.errors.append(f"{item.path}: {reason}") + continue + ok, freed = safe_remove(item.path) + if ok: + result.deleted += 1 + result.bytes_freed += freed + else: + result.skipped += 1 + + return result + + +def _is_allowed_browser_path(path: Path) -> bool: + try: + resolved = path.expanduser().resolve() + except OSError: + resolved = path.expanduser() + for root in _ALLOWED_ROOTS: + try: + if resolved.is_relative_to(root.expanduser().resolve()): + return True + except OSError: + continue + except AttributeError: + try: + if str(resolved).startswith(str(root.expanduser().resolve())): + return True + except OSError: + continue + return False diff --git a/src/scanners/photos_analyzer.py b/src/scanners/photos_analyzer.py new file mode 100644 index 0000000..08b3ad8 --- /dev/null +++ b/src/scanners/photos_analyzer.py @@ -0,0 +1,127 @@ +"""Photos library analyzer.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +from constants import HOME +from utils import bytes_human, count_files_recursive, size_of + + +@dataclass +class PhotoLibraryReport: + """Summary of a Photos library.""" + path: Path + size: int + originals_size: int + previews_size: int + database_size: int + originals_count: int + extension_counts: Dict[str, int] = field(default_factory=dict) + extension_sizes: Dict[str, int] = field(default_factory=dict) + + @property + def name(self) -> str: + return self.path.stem + + def top_extensions(self, limit: int = 8) -> List[Tuple[str, int, int]]: + entries = [] + for ext, count in self.extension_counts.items(): + size = self.extension_sizes.get(ext, 0) + entries.append((ext, count, size)) + entries.sort(key=lambda e: e[2], reverse=True) + return entries[:limit] + + def to_dict(self) -> dict: + return { + "path": str(self.path), + "size": self.size, + "size_human": bytes_human(self.size), + "originals_size": self.originals_size, + "originals_size_human": bytes_human(self.originals_size), + "previews_size": self.previews_size, + "previews_size_human": bytes_human(self.previews_size), + "database_size": self.database_size, + "database_size_human": bytes_human(self.database_size), + "originals_count": self.originals_count, + "extensions": self.extension_counts, + "extension_sizes": self.extension_sizes, + } + + +def find_photo_libraries(search_roots: Optional[Iterable[Path]] = None) -> List[Path]: + """Return Photos libraries found under search roots.""" + roots = list(search_roots) if search_roots else [HOME / "Pictures"] + libraries: List[Path] = [] + + for root in roots: + if not root.exists(): + continue + if root.suffix in {".photoslibrary", ".photolibrary"}: + libraries.append(root) + continue + try: + for child in root.iterdir(): + if child.is_dir() and child.suffix in {".photoslibrary", ".photolibrary"}: + libraries.append(child) + except (PermissionError, OSError): + continue + + return sorted(libraries) + + +def _extension_stats(root: Path) -> Tuple[Dict[str, int], Dict[str, int]]: + counts: Dict[str, int] = {} + sizes: Dict[str, int] = {} + if not root.exists(): + return counts, sizes + + for dirpath, _dirs, files in os.walk(root): + for fname in files: + fpath = Path(dirpath) / fname + try: + sz = fpath.stat().st_size + except OSError: + continue + ext = fpath.suffix.lower() or "" + counts[ext] = counts.get(ext, 0) + 1 + sizes[ext] = sizes.get(ext, 0) + sz + + return counts, sizes + + +def analyze_photo_library(path: Path) -> PhotoLibraryReport: + """Analyze a Photos library bundle.""" + size = size_of(path) + + originals_dir = path / "originals" + if not originals_dir.exists(): + originals_dir = path / "Masters" + + previews_dir = path / "resources" + if not previews_dir.exists(): + previews_dir = path / "Resources" + + database_dir = path / "database" + if not database_dir.exists(): + database_dir = path / "Database" + + originals_size = size_of(originals_dir) if originals_dir.exists() else 0 + previews_size = size_of(previews_dir) if previews_dir.exists() else 0 + database_size = size_of(database_dir) if database_dir.exists() else 0 + originals_count = count_files_recursive(originals_dir) if originals_dir.exists() else 0 + ext_counts, ext_sizes = _extension_stats(originals_dir) + + return PhotoLibraryReport( + path=path, + size=size, + originals_size=originals_size, + previews_size=previews_size, + database_size=database_size, + originals_count=originals_count, + extension_counts=ext_counts, + extension_sizes=ext_sizes, + ) diff --git a/src/scanners/simulators.py b/src/scanners/simulators.py new file mode 100644 index 0000000..b821f08 --- /dev/null +++ b/src/scanners/simulators.py @@ -0,0 +1,127 @@ +"""iOS Simulator data scanner and cleaner.""" + +from __future__ import annotations + +import plistlib +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from constants import HOME +from utils import size_of + +SIM_ROOT = HOME / "Library" / "Developer" / "CoreSimulator" +DEVICES_DIR = SIM_ROOT / "Devices" +CACHES_DIR = SIM_ROOT / "Caches" +LOGS_DIR = SIM_ROOT / "Logs" + + +@dataclass +class SimulatorDevice: + """One simulator device bundle.""" + udid: str + name: str + runtime: str + state: str + is_available: bool + path: Path + size: int + + +@dataclass +class SimulatorCache: + """One simulator cache directory.""" + category: str + path: Path + size: int + + +@dataclass +class PurgeResult: + """Summary of a purge operation.""" + deleted: int = 0 + bytes_freed: int = 0 + errors: List[str] = field(default_factory=list) + + +def _read_device_plist(path: Path) -> dict: + try: + with open(path, "rb") as f: + return plistlib.load(f) + except (OSError, plistlib.InvalidFileException, ValueError): + return {} + + +def find_simulator_devices(devices_root: Optional[Path] = None) -> List[SimulatorDevice]: + """Return simulator devices sorted by size descending.""" + root = devices_root or DEVICES_DIR + devices: List[SimulatorDevice] = [] + + if not root.exists(): + return devices + + for child in root.iterdir(): + if not child.is_dir(): + continue + info = _read_device_plist(child / "device.plist") + name = str(info.get("name", child.name[:8])) + runtime = str(info.get("runtime", "Unknown")) + state = str(info.get("state", "unknown")) + is_available = bool(info.get("isAvailable", True)) + devices.append(SimulatorDevice( + udid=child.name, + name=name, + runtime=runtime, + state=state, + is_available=is_available, + path=child, + size=size_of(child), + )) + + devices.sort(key=lambda d: d.size, reverse=True) + return devices + + +def find_simulator_caches(sim_root: Optional[Path] = None) -> List[SimulatorCache]: + """Return CoreSimulator cache directories.""" + root = sim_root or SIM_ROOT + caches: List[SimulatorCache] = [] + + for category, path in [ + ("Caches", root / "Caches"), + ("Logs", root / "Logs"), + ]: + if path.exists(): + caches.append(SimulatorCache(category=category, path=path, size=size_of(path))) + + return caches + + +def purge_simulator_devices(devices: List[SimulatorDevice]) -> PurgeResult: + """Delete simulator device directories.""" + from utils import safe_remove + + result = PurgeResult() + for device in devices: + ok, freed = safe_remove(device.path) + if ok: + result.deleted += 1 + result.bytes_freed += freed + else: + result.errors.append(str(device.path)) + return result + + +def purge_simulator_caches(caches: List[SimulatorCache]) -> PurgeResult: + """Delete simulator cache directories.""" + from utils import safe_remove + + result = PurgeResult() + for cache in caches: + ok, freed = safe_remove(cache.path) + if ok: + result.deleted += 1 + result.bytes_freed += freed + else: + result.errors.append(str(cache.path)) + return result diff --git a/src/scanners/space_map.py b/src/scanners/space_map.py new file mode 100644 index 0000000..5ae5330 --- /dev/null +++ b/src/scanners/space_map.py @@ -0,0 +1,113 @@ +"""Visual disk space map.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, List, Optional, Set + +from rich.tree import Tree + +from utils import bytes_human, iterdir_safe, size_of + +_SKIP_PREFIXES = ( + "/System", + "/private/var", + "/Volumes/.com.apple.TimeMachine", + "/dev", + "/proc", +) + +_SKIP_NAMES: Set[str] = { + ".git", + ".Spotlight-V100", + ".fseventsd", + ".DocumentRevisions-V100", + ".TemporaryItems", + "__pycache__", + "node_modules", +} + + +@dataclass +class DiskUsageNode: + """One node in the disk usage tree.""" + path: Path + size: int + children: List["DiskUsageNode"] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "path": str(self.path), + "size": self.size, + "size_human": bytes_human(self.size), + "children": [c.to_dict() for c in self.children], + } + + +def _should_skip(path: Path) -> bool: + s = str(path) + if any(s.startswith(prefix) for prefix in _SKIP_PREFIXES): + return True + if path.name in _SKIP_NAMES: + return True + if path.name.startswith("."): + return True + return False + + +def _build_node(path: Path, depth: int, max_depth: int, min_size: int) -> DiskUsageNode: + size = size_of(path) + node = DiskUsageNode(path=path, size=size) + if depth >= max_depth or not path.is_dir(): + return node + + children: List[DiskUsageNode] = [] + for child in iterdir_safe(path): + if _should_skip(child): + continue + if not child.is_dir(): + continue + child_node = _build_node(child, depth + 1, max_depth, min_size) + if child_node.size >= min_size: + children.append(child_node) + + children.sort(key=lambda c: c.size, reverse=True) + node.children = children + return node + + +def build_usage_tree( + root: Path, + max_depth: int = 2, + min_size: int = 0, +) -> DiskUsageNode: + """Build a disk usage tree for a root directory.""" + return _build_node(root, 0, max_depth, min_size) + + +def _bar(size: int, total: int, width: int = 20) -> str: + if total <= 0: + return "".ljust(width) + filled = int((size / total) * width) + if filled < 1 and size > 0: + filled = 1 + return ("#" * filled) + ("." * (width - filled)) + + +def _render_node(tree: Tree, node: DiskUsageNode, parent_size: int, limit: int) -> None: + for child in node.children[:limit]: + bar = _bar(child.size, parent_size) + label = f"{child.path.name} {bytes_human(child.size)} {bar}" + branch = tree.add(label) + _render_node(branch, child, child.size, limit) + if len(node.children) > limit: + tree.add(f"... {len(node.children) - limit} more") + + +def render_usage_tree(node: DiskUsageNode, limit: int = 12) -> Tree: + """Render a DiskUsageNode as a Rich Tree.""" + root_label = f"{node.path} {bytes_human(node.size)}" + tree = Tree(root_label) + _render_node(tree, node, node.size, limit) + return tree diff --git a/tests/test_features_p0_p1.py b/tests/test_features_p0_p1.py new file mode 100644 index 0000000..730d647 --- /dev/null +++ b/tests/test_features_p0_p1.py @@ -0,0 +1,93 @@ +"""Tests for P0/P1 features.""" + +from __future__ import annotations + +import pathlib +import plistlib +import sys + +import pytest + +REPO_ROOT = pathlib.Path(__file__).parent.parent +SRC_DIR = REPO_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + + +def test_space_map_tree_builds(tmp_path: pathlib.Path) -> None: + from scanners.space_map import build_usage_tree + + alpha = tmp_path / "alpha" + beta = tmp_path / "beta" + alpha.mkdir() + beta.mkdir() + (alpha / "a.bin").write_bytes(b"a" * 4096) + (beta / "b.bin").write_bytes(b"b" * 2048) + + node = build_usage_tree(tmp_path, max_depth=1, min_size=0) + child_names = {c.path.name for c in node.children} + assert "alpha" in child_names + assert "beta" in child_names + + +def test_photo_library_analyzer(tmp_path: pathlib.Path) -> None: + from scanners.photos_analyzer import analyze_photo_library, find_photo_libraries + + pictures = tmp_path / "Pictures" + pictures.mkdir() + lib = pictures / "Test.photoslibrary" + originals = lib / "originals" + database = lib / "database" + originals.mkdir(parents=True) + database.mkdir(parents=True) + + (originals / "img1.jpg").write_bytes(b"x" * 1024) + (database / "Photos.sqlite").write_bytes(b"y" * 256) + + libs = find_photo_libraries([pictures]) + assert lib in libs + + report = analyze_photo_library(lib) + assert report.originals_count == 1 + assert report.originals_size > 0 + + +def test_browser_data_collect_chrome(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: + from scanners import browser_data as bd + + monkeypatch.setattr(bd, "HOME", tmp_path) + + base = tmp_path / "Library" / "Application Support" / "Google" / "Chrome" / "Default" + cache_dir = base / "Cache" + cache_dir.mkdir(parents=True) + (cache_dir / "data").write_bytes(b"z" * 1024) + + items = bd.collect_browser_data(browsers=["chrome"]) + categories = {i.category for i in items} + assert "cache" in categories + + +def test_simulator_device_parsing(tmp_path: pathlib.Path) -> None: + from scanners.simulators import find_simulator_devices, purge_simulator_devices + + devices_root = tmp_path / "Devices" + device_dir = devices_root / "ABC-123" + device_dir.mkdir(parents=True) + + info = { + "name": "iPhone 15", + "runtime": "com.apple.CoreSimulator.SimRuntime.iOS-17-0", + "state": "Shutdown", + "isAvailable": False, + } + with open(device_dir / "device.plist", "wb") as f: + plistlib.dump(info, f) + + devices = find_simulator_devices(devices_root) + assert len(devices) == 1 + assert devices[0].name == "iPhone 15" + assert devices[0].is_available is False + + result = purge_simulator_devices(devices) + assert result.deleted == 1 + assert not device_dir.exists() From 0981305c57cd68ba0ea8f3137a97dfd567879df1 Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Tue, 12 May 2026 09:28:39 +0530 Subject: [PATCH 2/8] new features --- README.md | 47 +++ src/cli.py | 644 ++++++++++++++++++++++++++++++++ src/core/apfs_snapshots.py | 108 ++++++ src/core/breach_monitor.py | 96 +++++ src/core/brew_manager.py | 172 +++++++++ src/core/memory_pressure.py | 198 ++++++++++ src/core/menubar.py | 128 +++++++ src/core/permissions_auditor.py | 121 ++++++ src/reporting/storage_trend.py | 156 ++++++++ src/scanners/cloud_junk.py | 130 +++++++ src/scanners/recent_activity.py | 102 +++++ tests/test_features_p2_p3.py | 140 +++++++ 12 files changed, 2042 insertions(+) create mode 100644 src/core/apfs_snapshots.py create mode 100644 src/core/breach_monitor.py create mode 100644 src/core/brew_manager.py create mode 100644 src/core/memory_pressure.py create mode 100644 src/core/menubar.py create mode 100644 src/core/permissions_auditor.py create mode 100644 src/reporting/storage_trend.py create mode 100644 src/scanners/cloud_junk.py create mode 100644 src/scanners/recent_activity.py create mode 100644 tests/test_features_p2_p3.py diff --git a/README.md b/README.md index 8c18ccc..8093045 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ mdc scan - **Diff** — compare any two scans to see what's new or resolved - **HTML report** — self-contained with Chart.js doughnut + collapsible sections - **System inspector** — LaunchAgents, LaunchDaemons, login items, SIP status +- **Memory pressure reliever** — reports pressure, optional cache purge +- **Homebrew manager** — cache sizes, outdated list, cleanup and autoremove +- **Storage trend tracker** — snapshots disk usage over time +- **Recent activity cleaner** — scans recent-items files (safe clear) +- **Permissions auditor** — TCC privacy access audit (read-only) +- **APFS snapshot guard** — list and prune local snapshots +- **Menu bar companion** — SwiftBar/xbar plugin for last scan summary +- **Data breach monitor** — checks emails via HIBP API (opt-in) +- **Cloud storage junk** — scans Dropbox/Drive/OneDrive/Box caches - **Scheduler** — installs a LaunchAgent for weekly auto-scans - **macOS notifications** — via `osascript`, no dependencies - **CI mode** — JSON-only scan summary with threshold-based exit code @@ -218,6 +227,44 @@ mac-cleaner system --login-items mac-cleaner system --health ``` +### P2/P3 system utilities +```bash +# Memory pressure +mac-cleaner memory-pressure +mac-cleaner memory-pressure --relieve + +# Homebrew manager +mac-cleaner brew --outdated +mac-cleaner brew --cleanup --yes + +# Storage trend snapshots +mac-cleaner storage-trend --record +mac-cleaner storage-trend --days 7 + +# Recent activity cleanup (Recent Items folder only) +mac-cleaner recent-activity +mac-cleaner recent-activity --clear + +# Permissions audit (TCC) +mac-cleaner permissions +mac-cleaner permissions --system --export tcc.json + +# APFS snapshots +mac-cleaner snapshots +mac-cleaner snapshots --delete-older-than 14 --yes + +# Menu bar companion +mac-cleaner menubar install --interval 15 +mac-cleaner menubar status --format swiftbar + +# Breach monitor (HIBP) +mac-cleaner breach --email you@example.com --api-key $HIBP_API_KEY + +# Cloud storage junk +mac-cleaner cloud-junk +mac-cleaner cloud-junk --provider dropbox --clean +``` + ### Scheduler ```bash mac-cleaner schedule install diff --git a/src/cli.py b/src/cli.py index 17b08e2..835a246 100644 --- a/src/cli.py +++ b/src/cli.py @@ -24,6 +24,15 @@ history Show past scan records diff Compare two scans system Launch items + SIP + login items + memory-pressure Inspect memory pressure, optional cache purge + brew Homebrew manager (cache + cleanup) + storage-trend Storage usage trend tracker + recent-activity Recent files/activity scanner + permissions Audit macOS privacy permissions (TCC) + snapshots APFS local snapshot guard + menubar Menu bar companion (SwiftBar/xbar) + breach Data breach monitor (HIBP API) + cloud-junk Cloud storage cache/log scanner schedule Install / remove / status of weekly scan update Check for and apply upgrades config Show / init config file @@ -1755,6 +1764,641 @@ def cmd_system( console.print() +# ══════════════════════════════════════════════════════════════════════════════ +# MEMORY PRESSURE +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("memory-pressure") +@click.option("--relieve", is_flag=True, default=False, + help="Run purge to relieve memory pressure.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for purge.") +@click.pass_context +def cmd_memory_pressure( + ctx: click.Context, + relieve: bool, + yes: bool, +) -> None: + """Inspect memory pressure and optionally purge caches.""" + from core.dry_run import skip_if_dry_run + from core.memory_pressure import collect_memory_stats, relieve_memory_pressure + + stats = collect_memory_stats() + if stats is None: + console.print("[yellow]Could not read memory statistics.[/yellow]") + return + + free_percent = stats.free_percent + if free_percent is None and stats.total_bytes > 0: + free_percent = (stats.free_bytes / stats.total_bytes) * 100 + + console.print() + console.print(Panel("[bold cyan]Memory Pressure[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Metric", style="bold") + table.add_column("Value", style="yellow") + table.add_row("Total", bytes_human(stats.total_bytes)) + table.add_row("Used", bytes_human(stats.used_bytes)) + table.add_row("Free", bytes_human(stats.free_bytes)) + if free_percent is not None: + table.add_row("Free %", f"{free_percent:.1f}%") + if stats.pressure_level: + table.add_row("Pressure", stats.pressure_level) + + console.print(table) + + if not relieve: + return + + if skip_if_dry_run(ctx, console, "memory pressure purge"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Run purge to clear inactive caches?", default=False) + if not do_it: + return + + result = relieve_memory_pressure() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# HOMEBREW MANAGER +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("brew") +@click.option("--outdated", is_flag=True, default=False, + help="Check for outdated formulae and casks.") +@click.option("--cleanup", is_flag=True, default=False, + help="Run brew cleanup.") +@click.option("--prune-all", is_flag=True, default=False, + help="Run brew cleanup --prune=all.") +@click.option("--autoremove", is_flag=True, default=False, + help="Run brew autoremove.") +@click.option("--doctor", is_flag=True, default=False, + help="Run brew doctor.") +@click.option("--update", is_flag=True, default=False, + help="Run brew update.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for maintenance actions.") +@click.pass_context +def cmd_brew( + ctx: click.Context, + outdated: bool, + cleanup: bool, + prune_all: bool, + autoremove: bool, + doctor: bool, + update: bool, + yes: bool, +) -> None: + """Manage Homebrew caches and maintenance.""" + from core.brew_manager import ( + brew_autoremove, + brew_cleanup, + brew_doctor, + brew_installed, + brew_update, + collect_brew_status, + ) + from core.dry_run import skip_if_dry_run + + if not brew_installed(): + console.print("[yellow]Homebrew not found in PATH.[/yellow]") + return + + status = collect_brew_status(include_outdated=outdated) + + console.print() + console.print(Panel("[bold cyan]Homebrew Manager[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=False, border_style="dim", padding=(0, 1)) + table.add_column("Key", style="bold") + table.add_column("Value", style="yellow") + table.add_row("Version", status.version or "unknown") + table.add_row("Prefix", str(status.prefix) if status.prefix else "unknown") + table.add_row("Cache", f"{status.cache} ({bytes_human(status.cache_size)})" if status.cache else "unknown") + table.add_row("Cellar", f"{status.cellar} ({bytes_human(status.cellar_size)})" if status.cellar else "unknown") + table.add_row("Formulae", str(status.formulae)) + table.add_row("Casks", str(status.casks)) + console.print(table) + + if outdated: + if status.outdated_formulae: + console.print(f"\n Outdated formulae: {len(status.outdated_formulae)}") + for name in status.outdated_formulae[:10]: + console.print(f" [dim]- {name}[/dim]") + if len(status.outdated_formulae) > 10: + console.print(f" [dim]... {len(status.outdated_formulae) - 10} more[/dim]") + if status.outdated_casks: + console.print(f"\n Outdated casks: {len(status.outdated_casks)}") + for name in status.outdated_casks[:10]: + console.print(f" [dim]- {name}[/dim]") + if len(status.outdated_casks) > 10: + console.print(f" [dim]... {len(status.outdated_casks) - 10} more[/dim]") + + if not any([cleanup, prune_all, autoremove, doctor, update]): + return + + if skip_if_dry_run(ctx, console, "Homebrew maintenance"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Run selected Homebrew actions?", default=False) + if not do_it: + return + + if update: + result = brew_update() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if doctor: + result = brew_doctor() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if cleanup or prune_all: + result = brew_cleanup(prune_all=prune_all) + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + if autoremove: + result = brew_autoremove() + color = "green" if result.success else "red" + console.print(f" [{color}]{result.message}[/{color}]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# STORAGE TREND +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("storage-trend") +@click.option("--record", is_flag=True, default=False, + help="Record a new snapshot before showing results.") +@click.option("--limit", default=12, show_default=True, + help="Maximum snapshots to display.") +@click.option("--days", default=None, type=int, + help="Summarize only the last N days.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export snapshots to JSON.") +@click.option("--volume", default="/", show_default=True, + help="Volume path to record.") +def cmd_storage_trend( + record: bool, + limit: int, + days: Optional[int], + export_path: Optional[str], + volume: str, +) -> None: + """Track disk usage trends over time.""" + from reporting.storage_trend import append_snapshot, load_snapshots, record_snapshot, summarize_trend + + if record: + snapshot = record_snapshot(Path(volume)) + append_snapshot(snapshot) + + snapshots = load_snapshots() + if not snapshots: + console.print("[yellow]No storage snapshots yet. Run with --record.[/yellow]") + return + + console.print() + console.print(Panel("[bold cyan]Storage Trend[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Timestamp", min_width=20) + table.add_column("Used", justify="right", style="yellow", width=12) + table.add_column("Free", justify="right", width=12) + + for snap in snapshots[-limit:]: + table.add_row(snap.timestamp[:19], snap.used_human, snap.free_human) + console.print(table) + + summary = summarize_trend(snapshots, days=days) + if summary: + direction = "more" if summary.delta_used > 0 else "less" + console.print( + f"\n Used {direction} space by {summary.delta_used_human} over {summary.days} day(s)." + ) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "snapshots": [s.to_dict() for s in snapshots], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# RECENT ACTIVITY +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("recent-activity") +@click.option("--clear", is_flag=True, default=False, + help="Clear items under ~/Library/Recent Items.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for clearing.") +@click.pass_context +def cmd_recent_activity(ctx: click.Context, clear: bool, yes: bool) -> None: + """Scan and optionally clear recent activity files.""" + from core.dry_run import skip_if_dry_run + from scanners.recent_activity import clear_recent_items, collect_recent_activity + + items = collect_recent_activity() + if not items: + console.print("[green]No recent activity files found.[/green]") + return + + by_cat: dict = {} + for item in items: + by_cat.setdefault(item.category, []).append(item) + + console.print() + console.print(Panel("[bold cyan]Recent Activity[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Category", min_width=18) + table.add_column("Items", justify="right", width=8) + table.add_column("Size", justify="right", style="yellow", width=12) + table.add_column("Removable", justify="center", width=10) + + for category, entries in by_cat.items(): + total = sum(e.size for e in entries) + removable = all(e.safe_to_delete for e in entries) + table.add_row(category, str(len(entries)), bytes_human(total), "yes" if removable else "no") + + console.print(table) + + if not clear: + return + + if skip_if_dry_run(ctx, console, "recent activity clearing"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask("Clear Recent Items folder?", default=False) + if not do_it: + return + + result = clear_recent_items() + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# PERMISSIONS AUDITOR +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("permissions") +@click.option("--system", is_flag=True, default=False, + help="Include the system-wide TCC database (may require privileges).") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export entries to JSON.") +def cmd_permissions(system: bool, export_path: Optional[str]) -> None: + """Audit macOS privacy permissions.""" + from core.permissions_auditor import audit_permissions + + report = audit_permissions(include_system=system) + if not report.entries: + console.print("[yellow]No TCC entries found or access denied.[/yellow]") + return + + console.print() + console.print(Panel("[bold cyan]Permissions Audit[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + grouped = report.by_service() + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Service", min_width=28) + table.add_column("Allowed", justify="right", width=8) + table.add_column("Denied", justify="right", width=8) + + for service, entries in grouped.items(): + allowed = sum(1 for e in entries if e.allowed) + denied = len(entries) - allowed + name = entries[0].service_name if entries else service + table.add_row(name, str(allowed), str(denied)) + + console.print(table) + + for warning in report.warnings: + console.print(f"\n [yellow]⚠ {warning}[/yellow]") + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "entries": [e.__dict__ for e in report.entries], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# APFS SNAPSHOTS +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("snapshots") +@click.option("--volume", default="/", show_default=True, + help="Volume path to inspect.") +@click.option("--delete-older-than", default=None, type=int, + help="Delete snapshots older than N days.") +@click.option("--keep", default=None, type=int, + help="Keep the newest N snapshots.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for deletions.") +@click.pass_context +def cmd_snapshots( + ctx: click.Context, + volume: str, + delete_older_than: Optional[int], + keep: Optional[int], + yes: bool, +) -> None: + """Inspect and prune APFS local snapshots.""" + from core.apfs_snapshots import list_snapshots, select_snapshots_to_delete, delete_snapshot + from core.dry_run import skip_if_dry_run + + snapshots = list_snapshots(volume) + console.print() + console.print(Panel("[bold cyan]APFS Snapshots[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + if not snapshots: + console.print(" [dim]No local snapshots found.[/dim]") + return + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Snapshot", min_width=34) + table.add_column("Created", width=20) + for snap in snapshots: + created = snap.created_at.strftime("%Y-%m-%d %H:%M") if snap.created_at else "unknown" + table.add_row(snap.name, created) + console.print(table) + + if delete_older_than is None and keep is None: + return + + targets = select_snapshots_to_delete(snapshots, keep=keep, older_than_days=delete_older_than) + if not targets: + console.print(" [dim]No snapshots matched the prune criteria.[/dim]") + return + + if skip_if_dry_run(ctx, console, "snapshot deletion"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask(f"Delete {len(targets)} snapshot(s)?", default=False) + if not do_it: + return + + deleted = 0 + for snap in targets: + if delete_snapshot(snap): + deleted += 1 + console.print(f"\n [green]Deleted {deleted} snapshot(s)[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# MENU BAR COMPANION +# ══════════════════════════════════════════════════════════════════════════════ + +@main.group("menubar") +def cmd_menubar() -> None: + """Menu bar companion for SwiftBar/xbar.""" + + +@cmd_menubar.command("status") +@click.option("--format", "output_format", default="plain", + type=click.Choice(["plain", "swiftbar"], case_sensitive=False)) +def menubar_status(output_format: str) -> None: + """Emit status for menu bar tools.""" + from core.menubar import build_status_from_history, format_swiftbar + + status = build_status_from_history() + if status is None: + console.print("No scan history yet. Run 'mac-cleaner scan' first.") + return + + if output_format.lower() == "swiftbar": + click.echo(format_swiftbar(status)) + return + + console.print(status.label) + console.print(status.subtitle) + + +@cmd_menubar.command("install") +@click.option("--target", default=None, + type=click.Choice(["swiftbar", "xbar"], case_sensitive=False), + help="Choose SwiftBar or xbar plugin directory.") +@click.option("--interval", default=15, show_default=True, + help="Refresh interval in minutes.") +@click.option("--dir", "custom_dir", default=None, type=click.Path(), + help="Custom plugin directory.") +def menubar_install(target: Optional[str], interval: int, custom_dir: Optional[str]) -> None: + """Install a menu bar plugin script.""" + from core.menubar import detect_plugin_dirs, install_plugin + + if custom_dir: + path = install_plugin(Path(custom_dir), interval_minutes=interval) + console.print(f"[green]Installed plugin at {path}[/green]") + return + + dirs = detect_plugin_dirs() + if not dirs: + console.print("[yellow]No SwiftBar/xbar plugin folder found.[/yellow]") + return + + chosen = target.lower() if target else ("swiftbar" if "swiftbar" in dirs else "xbar") + plugin_dir = dirs.get(chosen) + if not plugin_dir: + console.print("[yellow]Selected plugin directory not found.[/yellow]") + return + + path = install_plugin(plugin_dir, interval_minutes=interval) + console.print(f"[green]Installed plugin at {path}[/green]") + + +@cmd_menubar.command("remove") +@click.option("--target", default=None, + type=click.Choice(["swiftbar", "xbar"], case_sensitive=False)) +@click.option("--dir", "custom_dir", default=None, type=click.Path()) +def menubar_remove(target: Optional[str], custom_dir: Optional[str]) -> None: + """Remove menu bar plugin scripts.""" + from core.menubar import detect_plugin_dirs, remove_plugin + + removed = 0 + if custom_dir: + removed = remove_plugin(Path(custom_dir)) + else: + dirs = detect_plugin_dirs() + if target: + t = target.lower() + if t in dirs: + removed = remove_plugin(dirs[t]) + else: + for path in dirs.values(): + removed += remove_plugin(path) + + console.print(f"Removed {removed} plugin(s).") + + +# ══════════════════════════════════════════════════════════════════════════════ +# BREACH MONITOR +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("breach") +@click.option("--email", "emails", multiple=True, + help="Email address to check. Can be repeated.") +@click.option("--api-key", default=None, + help="HIBP API key (or set HIBP_API_KEY env var).") +@click.option("--use-watchlist", is_flag=True, default=False, + help="Check addresses saved in the watchlist.") +@click.option("--save", is_flag=True, default=False, + help="Save provided emails to the watchlist.") +@click.option("--export", "export_path", type=click.Path(), default=None, + help="Export results to JSON.") +def cmd_breach( + emails: Tuple[str, ...], + api_key: Optional[str], + use_watchlist: bool, + save: bool, + export_path: Optional[str], +) -> None: + """Check emails against Have I Been Pwned.""" + from core.breach_monitor import check_email, load_watchlist, resolve_api_key, save_watchlist + + email_list = list(emails) + if use_watchlist: + email_list.extend(load_watchlist()) + email_list = [e.strip() for e in email_list if e.strip()] + + if not email_list: + console.print("[yellow]No email addresses provided.[/yellow]") + return + + if save: + save_watchlist(email_list) + + key = resolve_api_key(api_key) + results = [check_email(email, key or "") for email in email_list] + + console.print() + console.print(Panel("[bold cyan]Breach Monitor[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Email", min_width=28) + table.add_column("Breached", width=10) + table.add_column("Count", justify="right", width=6) + table.add_column("Error", style="red") + + for r in results: + table.add_row( + r.email, + "yes" if r.breached else "no", + str(len(r.breaches)), + r.error or "", + ) + + console.print(table) + + if export_path: + import json + payload = { + "generated_at": __import__("datetime").datetime.now().isoformat(), + "results": [r.__dict__ for r in results], + } + with open(export_path, "w") as f: + json.dump(payload, f, indent=2) + console.print(f"\n [green]Exported to {export_path}[/green]") + + +# ══════════════════════════════════════════════════════════════════════════════ +# CLOUD STORAGE JUNK +# ══════════════════════════════════════════════════════════════════════════════ + +@main.command("cloud-junk") +@click.option("--provider", "providers", multiple=True, + type=click.Choice(["dropbox", "google-drive", "onedrive", "box"], case_sensitive=False), + help="Limit to a specific provider.") +@click.option("--clean", is_flag=True, default=False, + help="Delete detected cache/log directories.") +@click.option("--yes", is_flag=True, default=False, + help="Skip confirmation for deletions.") +@click.pass_context +def cmd_cloud_junk( + ctx: click.Context, + providers: Tuple[str, ...], + clean: bool, + yes: bool, +) -> None: + """Scan cloud storage caches and logs.""" + from core.dry_run import skip_if_dry_run + from scanners.cloud_junk import collect_cloud_junk, delete_cloud_junk + + items = collect_cloud_junk(providers=providers or None) + if not items: + console.print("[green]No cloud cache data found.[/green]") + return + + console.print() + console.print(Panel("[bold cyan]Cloud Storage Junk[/bold cyan]", + border_style="cyan", padding=(0, 2))) + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Provider", width=14) + table.add_column("Category", width=10) + table.add_column("Size", justify="right", style="yellow", width=10) + table.add_column("Path", style="dim") + + for item in items: + table.add_row(item.provider, item.category, bytes_human(item.size), str(item.path)) + console.print(table) + + if not clean: + return + + if skip_if_dry_run(ctx, console, "cloud cache cleanup"): + return + + do_it = yes + if not do_it: + from rich.prompt import Confirm + do_it = Confirm.ask(f"Delete {len(items)} cache/log item(s)?", default=False) + if not do_it: + return + + result = delete_cloud_junk(items) + console.print( + f"\n [green]Deleted {result.deleted} item(s), freed {bytes_human(result.bytes_freed)}[/green]" + ) + if result.skipped: + console.print(f" [dim]{result.skipped} item(s) skipped[/dim]") + # ══════════════════════════════════════════════════════════════════════════════ # SCHEDULE # ══════════════════════════════════════════════════════════════════════════════ diff --git a/src/core/apfs_snapshots.py b/src/core/apfs_snapshots.py new file mode 100644 index 0000000..0d0a704 --- /dev/null +++ b/src/core/apfs_snapshots.py @@ -0,0 +1,108 @@ +"""APFS snapshot listing and pruning helpers.""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Optional + + +_TMUTIL_RE = re.compile( + r"com\.apple\.TimeMachine\.(\d{4}-\d{2}-\d{2}-\d{6})\.local" +) + + +@dataclass +class Snapshot: + """One APFS local snapshot entry.""" + name: str + token: str + created_at: Optional[datetime] + + +def parse_tmutil_output(output: str) -> List[Snapshot]: + """Parse tmutil listlocalsnapshots output.""" + snapshots: List[Snapshot] = [] + for line in output.splitlines(): + match = _TMUTIL_RE.search(line) + if match: + token = match.group(1) + created_at = None + try: + created_at = datetime.strptime(token, "%Y-%m-%d-%H%M%S") + except ValueError: + created_at = None + name = f"com.apple.TimeMachine.{token}.local" + snapshots.append(Snapshot(name=name, token=token, created_at=created_at)) + return snapshots + + +def list_snapshots( + volume: str = "/", + runner=subprocess.run, +) -> List[Snapshot]: + """List local APFS snapshots for a volume using tmutil.""" + try: + result = runner( + ["tmutil", "listlocalsnapshots", volume], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + + if result.returncode != 0: + return [] + + return parse_tmutil_output(result.stdout) + + +def select_snapshots_to_delete( + snapshots: List[Snapshot], + keep: Optional[int] = None, + older_than_days: Optional[int] = None, +) -> List[Snapshot]: + """Select snapshots to delete based on age or keep count.""" + ordered = sorted( + [s for s in snapshots if s.created_at is not None], + key=lambda s: s.created_at, + ) + if not ordered: + return [] + + to_delete: List[Snapshot] = [] + + if older_than_days is not None and older_than_days > 0: + cutoff = datetime.now() - timedelta(days=older_than_days) + for snap in ordered: + if snap.created_at and snap.created_at < cutoff: + to_delete.append(snap) + + if keep is not None and keep >= 0: + excess = len(ordered) - keep + if excess > 0: + to_delete.extend(ordered[:excess]) + + unique = {s.name: s for s in to_delete} + return list(unique.values()) + + +def delete_snapshot( + snapshot: Snapshot, + runner=subprocess.run, +) -> bool: + """Delete a snapshot by token using tmutil.""" + try: + result = runner( + ["tmutil", "deletelocalsnapshots", snapshot.token], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.TimeoutExpired): + return False + + return result.returncode == 0 diff --git a/src/core/breach_monitor.py b/src/core/breach_monitor.py new file mode 100644 index 0000000..bed8f22 --- /dev/null +++ b/src/core/breach_monitor.py @@ -0,0 +1,96 @@ +"""Data breach monitoring via Have I Been Pwned API.""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from constants import CONFIG_DIR + +HIBP_API_BASE = "https://haveibeenpwned.com/api/v3/breachedaccount" +WATCHLIST_FILE = CONFIG_DIR / "breach_watchlist.json" +DEFAULT_USER_AGENT = "mac-cleaner/1.2.0" + + +@dataclass +class BreachResult: + """Result for one email address.""" + email: str + breached: bool + breaches: List[dict] = field(default_factory=list) + error: Optional[str] = None + checked_at: str = field(default_factory=lambda: datetime.now().isoformat()) + + +def _build_request(email: str, api_key: str) -> urllib.request.Request: + url = f"{HIBP_API_BASE}/{urllib.parse.quote(email)}?truncateResponse=true" + return urllib.request.Request( + url, + headers={ + "hibp-api-key": api_key, + "user-agent": DEFAULT_USER_AGENT, + }, + ) + + +def parse_breach_response(payload: str) -> List[dict]: + """Parse HIBP response JSON into a list.""" + try: + data = json.loads(payload) + except json.JSONDecodeError: + return [] + if isinstance(data, list): + return data + return [] + + +def check_email(email: str, api_key: str) -> BreachResult: + """Check a single email using the HIBP API.""" + if not api_key: + return BreachResult(email=email, breached=False, error="HIBP API key missing") + + req = _build_request(email, api_key) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + body = resp.read().decode("utf-8") + breaches = parse_breach_response(body) + return BreachResult(email=email, breached=bool(breaches), breaches=breaches) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return BreachResult(email=email, breached=False) + if exc.code in (401, 403): + return BreachResult(email=email, breached=False, error="Invalid API key") + return BreachResult(email=email, breached=False, error=f"HTTP {exc.code}") + except (urllib.error.URLError, OSError) as exc: + return BreachResult(email=email, breached=False, error=str(exc)) + + +def load_watchlist(path: Path = WATCHLIST_FILE) -> List[str]: + if not path.exists(): + return [] + try: + data = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return [] + emails = data.get("emails", []) + return [str(e) for e in emails if isinstance(e, str)] + + +def save_watchlist(emails: List[str], path: Path = WATCHLIST_FILE) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "updated_at": datetime.now().isoformat(), + "emails": sorted(set(emails)), + } + path.write_text(json.dumps(payload, indent=2)) + + +def resolve_api_key(explicit: Optional[str] = None) -> Optional[str]: + return explicit or os.environ.get("HIBP_API_KEY") diff --git a/src/core/brew_manager.py b/src/core/brew_manager.py new file mode 100644 index 0000000..a198f9b --- /dev/null +++ b/src/core/brew_manager.py @@ -0,0 +1,172 @@ +"""Homebrew status and maintenance helpers.""" + +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from utils import size_of + + +@dataclass +class BrewStatus: + """Summary of Homebrew installation and caches.""" + installed: bool + version: Optional[str] = None + prefix: Optional[Path] = None + cache: Optional[Path] = None + cellar: Optional[Path] = None + cache_size: int = 0 + cellar_size: int = 0 + formulae: int = 0 + casks: int = 0 + outdated_formulae: List[str] = field(default_factory=list) + outdated_casks: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +@dataclass +class BrewActionResult: + """Summary of a Homebrew command execution.""" + success: bool + message: str + stdout: str = "" + stderr: str = "" + + +def _run_brew(args: List[str], runner=subprocess.run, timeout: int = 30) -> subprocess.CompletedProcess: + return runner( + ["brew"] + args, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _split_lines(text: str) -> List[str]: + return [line.strip() for line in text.splitlines() if line.strip()] + + +def brew_installed() -> bool: + return shutil.which("brew") is not None + + +def collect_brew_status( + include_outdated: bool = False, + runner=subprocess.run, +) -> BrewStatus: + """Collect Homebrew status and cache sizes.""" + if not brew_installed(): + return BrewStatus(installed=False) + + status = BrewStatus(installed=True) + + try: + version = _run_brew(["--version"], runner=runner) + if version.returncode == 0: + status.version = version.stdout.splitlines()[0].strip() + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew --version failed: {exc}") + + for label, args, attr in [ + ("prefix", ["--prefix"], "prefix"), + ("cache", ["--cache"], "cache"), + ("cellar", ["--cellar"], "cellar"), + ]: + try: + result = _run_brew(args, runner=runner) + if result.returncode == 0: + path = Path(result.stdout.strip()) + setattr(status, attr, path) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew {label} lookup failed: {exc}") + + if status.cache and status.cache.exists(): + status.cache_size = size_of(status.cache) + if status.cellar and status.cellar.exists(): + status.cellar_size = size_of(status.cellar) + + try: + formulas = _run_brew(["list", "--formula"], runner=runner) + if formulas.returncode == 0: + status.formulae = len(_split_lines(formulas.stdout)) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew list --formula failed: {exc}") + + try: + casks = _run_brew(["list", "--cask"], runner=runner) + if casks.returncode == 0: + status.casks = len(_split_lines(casks.stdout)) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew list --cask failed: {exc}") + + if include_outdated: + try: + outdated_formula = _run_brew(["outdated", "--formula"], runner=runner) + if outdated_formula.returncode == 0: + status.outdated_formulae = _split_lines(outdated_formula.stdout) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew outdated --formula failed: {exc}") + + try: + outdated_casks = _run_brew(["outdated", "--cask"], runner=runner) + if outdated_casks.returncode == 0: + status.outdated_casks = _split_lines(outdated_casks.stdout) + except (OSError, subprocess.TimeoutExpired) as exc: + status.errors.append(f"brew outdated --cask failed: {exc}") + + return status + + +def brew_cleanup(prune_all: bool = False, runner=subprocess.run) -> BrewActionResult: + """Run brew cleanup (optionally with --prune=all).""" + args = ["cleanup"] + if prune_all: + args.append("--prune=all") + try: + result = _run_brew(args, runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew cleanup failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew cleanup completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew cleanup failed", result.stdout, result.stderr) + + +def brew_autoremove(runner=subprocess.run) -> BrewActionResult: + """Run brew autoremove to remove unused dependencies.""" + try: + result = _run_brew(["autoremove"], runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew autoremove failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew autoremove completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew autoremove failed", result.stdout, result.stderr) + + +def brew_update(runner=subprocess.run) -> BrewActionResult: + """Run brew update.""" + try: + result = _run_brew(["update"], runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew update failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew update completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew update failed", result.stdout, result.stderr) + + +def brew_doctor(runner=subprocess.run) -> BrewActionResult: + """Run brew doctor.""" + try: + result = _run_brew(["doctor"], runner=runner, timeout=120) + except (OSError, subprocess.TimeoutExpired) as exc: + return BrewActionResult(False, f"brew doctor failed: {exc}") + + if result.returncode == 0: + return BrewActionResult(True, "brew doctor completed", result.stdout, result.stderr) + return BrewActionResult(False, "brew doctor failed", result.stdout, result.stderr) diff --git a/src/core/memory_pressure.py b/src/core/memory_pressure.py new file mode 100644 index 0000000..947bedc --- /dev/null +++ b/src/core/memory_pressure.py @@ -0,0 +1,198 @@ +"""Memory pressure inspection and cache purge helpers.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Tuple + + +@dataclass +class MemoryStats: + """Snapshot of system memory statistics.""" + captured_at: str + page_size: int + pages_free: int + pages_active: int + pages_inactive: int + pages_speculative: int + pages_wired: int + pages_compressed: int + free_percent: Optional[float] = None + pressure_level: Optional[str] = None + + @property + def total_pages(self) -> int: + return ( + self.pages_free + + self.pages_active + + self.pages_inactive + + self.pages_speculative + + self.pages_wired + + self.pages_compressed + ) + + @property + def free_pages(self) -> int: + return self.pages_free + self.pages_speculative + + @property + def total_bytes(self) -> int: + return self.total_pages * self.page_size + + @property + def free_bytes(self) -> int: + return self.free_pages * self.page_size + + @property + def used_bytes(self) -> int: + used_pages = max(self.total_pages - self.free_pages, 0) + return used_pages * self.page_size + + +@dataclass +class ReliefResult: + """Summary of a cache purge attempt.""" + success: bool + message: str + stdout: str = "" + stderr: str = "" + + +_VM_PAGE_RE = re.compile(r"page size of (\d+) bytes", re.IGNORECASE) +_VM_NUMBER_RE = re.compile(r"([0-9]+)\.") + + +def parse_vm_stat(output: str) -> Optional[MemoryStats]: + """Parse vm_stat output into MemoryStats.""" + page_size = None + pages = { + "free": 0, + "active": 0, + "inactive": 0, + "speculative": 0, + "wired": 0, + "compressed": 0, + } + + for line in output.splitlines(): + if page_size is None: + match = _VM_PAGE_RE.search(line) + if match: + page_size = int(match.group(1)) + continue + lower = line.strip().lower() + if not lower.startswith("pages "): + continue + number = _VM_NUMBER_RE.search(lower) + if not number: + continue + value = int(number.group(1)) + if lower.startswith("pages free"): + pages["free"] = value + elif lower.startswith("pages active"): + pages["active"] = value + elif lower.startswith("pages inactive"): + pages["inactive"] = value + elif lower.startswith("pages speculative"): + pages["speculative"] = value + elif lower.startswith("pages wired"): + pages["wired"] = value + elif "pages occupied by compressor" in lower or lower.startswith("pages compressed"): + pages["compressed"] = value + + if page_size is None: + return None + + return MemoryStats( + captured_at=datetime.now().isoformat(), + page_size=page_size, + pages_free=pages["free"], + pages_active=pages["active"], + pages_inactive=pages["inactive"], + pages_speculative=pages["speculative"], + pages_wired=pages["wired"], + pages_compressed=pages["compressed"], + ) + + +def parse_memory_pressure(output: str) -> Tuple[Optional[float], Optional[str]]: + """Parse memory_pressure output for free percentage and level.""" + free_percent = None + level = None + for line in output.splitlines(): + if "memory free percentage" in line.lower(): + parts = re.findall(r"(\d+(?:\.\d+)?)%", line) + if parts: + free_percent = float(parts[0]) + if "memory pressure" in line.lower(): + parts = line.split(":", 1) + if len(parts) == 2: + level = parts[1].strip().lower() + return free_percent, level + + +def collect_memory_stats( + runner=subprocess.run, +) -> Optional[MemoryStats]: + """Collect memory stats using vm_stat and memory_pressure when available.""" + try: + result = runner( + ["vm_stat"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return None + + if result.returncode != 0: + return None + + stats = parse_vm_stat(result.stdout) + if stats is None: + return None + + mp_bin = shutil.which("memory_pressure") + if mp_bin: + try: + mp = runner( + [mp_bin, "-Q"], + capture_output=True, + text=True, + timeout=5, + ) + if mp.returncode == 0: + free_percent, level = parse_memory_pressure(mp.stdout) + stats.free_percent = free_percent + stats.pressure_level = level + except (OSError, subprocess.TimeoutExpired): + pass + + return stats + + +def relieve_memory_pressure(runner=subprocess.run) -> ReliefResult: + """Attempt to purge inactive memory caches using the system purge tool.""" + purge_bin = shutil.which("purge") + if not purge_bin: + return ReliefResult(False, "purge not available on this system") + + try: + result = runner( + [purge_bin], + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + return ReliefResult(False, "purge timed out after 120 seconds") + except OSError as exc: + return ReliefResult(False, f"purge failed: {exc}") + + if result.returncode == 0: + return ReliefResult(True, "purge completed", stdout=result.stdout) + return ReliefResult(False, "purge failed", stdout=result.stdout, stderr=result.stderr) diff --git a/src/core/menubar.py b/src/core/menubar.py new file mode 100644 index 0000000..0b586ff --- /dev/null +++ b/src/core/menubar.py @@ -0,0 +1,128 @@ +"""Menu bar companion helpers (SwiftBar/xbar plugin).""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional + +from constants import CONFIG_DIR, HOME +from utils import bytes_human + + +SWIFTBAR_DIR = HOME / "Library" / "Application Support" / "SwiftBar" / "Plugins" +XBAR_DIR = HOME / "Library" / "Application Support" / "xbar" / "plugins" + + +@dataclass +class MenubarStatus: + """Computed status for the menu bar plugin.""" + label: str + subtitle: str + orphan_bytes: int + junk_bytes: int + dev_junk_bytes: int + scanned_at: str + + +def detect_plugin_dirs() -> Dict[str, Path]: + """Return available plugin directories for menu bar tools.""" + dirs: Dict[str, Path] = {} + if SWIFTBAR_DIR.exists(): + dirs["swiftbar"] = SWIFTBAR_DIR + if XBAR_DIR.exists(): + dirs["xbar"] = XBAR_DIR + return dirs + + +def _command_path() -> str: + return shutil.which("mac-cleaner") or shutil.which("mdc") or "mac-cleaner" + + +def build_status_from_history() -> Optional[MenubarStatus]: + """Use the most recent scan record to build a status.""" + try: + from config.history import latest_scan + except Exception: + return None + + record = latest_scan() + if record is None: + return None + + orphan_bytes = record.orphan_bytes + junk_bytes = record.junk_bytes + dev_bytes = record.dev_junk_bytes + total = orphan_bytes + junk_bytes + dev_bytes + + label = f"Cleaner: {bytes_human(total)}" + subtitle = f"Last scan: {record.scanned_at:%Y-%m-%d %H:%M}" + + return MenubarStatus( + label=label, + subtitle=subtitle, + orphan_bytes=orphan_bytes, + junk_bytes=junk_bytes, + dev_junk_bytes=dev_bytes, + scanned_at=record.scanned_at.isoformat(), + ) + + +def format_swiftbar(status: MenubarStatus) -> str: + """Format status as a SwiftBar/xbar-compatible script output.""" + cmd = _command_path() + lines = [status.label, "---"] + lines.append(status.subtitle) + lines.append(f"Orphans: {bytes_human(status.orphan_bytes)}") + lines.append(f"Junk: {bytes_human(status.junk_bytes)}") + lines.append(f"Dev Junk: {bytes_human(status.dev_junk_bytes)}") + lines.append("---") + lines.append( + f"Run scan | bash={cmd} param1=scan terminal=true refresh=true" + ) + lines.append( + f"Open history | bash={cmd} param1=history terminal=true" + ) + return "\n".join(lines) + + +def install_plugin( + target_dir: Path, + interval_minutes: int = 15, +) -> Path: + """Install a SwiftBar/xbar plugin script.""" + target_dir.mkdir(parents=True, exist_ok=True) + filename = f"mac-cleaner.{interval_minutes}m.sh" + path = target_dir / filename + + script = "\n".join([ + "#!/bin/sh", + "# mac-cleaner menu bar status", + f"{_command_path()} menubar status --format swiftbar", + "", + ]) + path.write_text(script) + os.chmod(path, 0o755) + return path + + +def remove_plugin(target_dir: Path) -> int: + """Remove mac-cleaner plugins from a directory.""" + removed = 0 + if not target_dir.exists(): + return 0 + for child in target_dir.iterdir(): + if child.name.startswith("mac-cleaner.") and child.suffix == ".sh": + try: + child.unlink() + removed += 1 + except OSError: + continue + return removed + + +def ensure_config_dir() -> Path: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + return CONFIG_DIR diff --git a/src/core/permissions_auditor.py b/src/core/permissions_auditor.py new file mode 100644 index 0000000..58c098c --- /dev/null +++ b/src/core/permissions_auditor.py @@ -0,0 +1,121 @@ +"""Permissions auditor for macOS TCC database.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from constants import HOME + +USER_TCC_DB = HOME / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db" +SYSTEM_TCC_DB = Path("/Library/Application Support/com.apple.TCC/TCC.db") + + +SERVICE_LABELS: Dict[str, str] = { + "kTCCServiceSystemPolicyAllFiles": "Full Disk Access", + "kTCCServiceAccessibility": "Accessibility", + "kTCCServiceScreenCapture": "Screen Recording", + "kTCCServiceAppleEvents": "Apple Events", + "kTCCServiceDeveloperTool": "Developer Tools", + "kTCCServiceListenEvent": "Input Monitoring", + "kTCCServiceCamera": "Camera", + "kTCCServiceMicrophone": "Microphone", +} + +RISKY_SERVICES = { + "kTCCServiceSystemPolicyAllFiles", + "kTCCServiceAccessibility", + "kTCCServiceScreenCapture", + "kTCCServiceAppleEvents", + "kTCCServiceDeveloperTool", + "kTCCServiceListenEvent", +} + + +@dataclass +class PermissionEntry: + """One row from the TCC access table.""" + service: str + client: str + client_type: int + auth_value: int + auth_reason: int + auth_version: int + last_modified: int + + @property + def service_name(self) -> str: + return SERVICE_LABELS.get(self.service, self.service) + + @property + def allowed(self) -> bool: + return int(self.auth_value) == 1 + + +@dataclass +class PermissionsReport: + """Summary of permissions by service.""" + entries: List[PermissionEntry] + warnings: List[str] = field(default_factory=list) + + def by_service(self) -> Dict[str, List[PermissionEntry]]: + grouped: Dict[str, List[PermissionEntry]] = {} + for entry in self.entries: + grouped.setdefault(entry.service, []).append(entry) + return grouped + + +def _read_access_rows(db_path: Path) -> List[PermissionEntry]: + if not db_path.exists(): + return [] + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT service, client, client_type, auth_value, auth_reason, auth_version, last_modified " + "FROM access" + ).fetchall() + finally: + conn.close() + + entries: List[PermissionEntry] = [] + for row in rows: + entries.append(PermissionEntry( + service=row["service"], + client=row["client"], + client_type=int(row["client_type"]), + auth_value=int(row["auth_value"]), + auth_reason=int(row["auth_reason"]), + auth_version=int(row["auth_version"]), + last_modified=int(row["last_modified"]), + )) + return entries + + +def audit_permissions( + include_system: bool = False, + db_paths: Optional[List[Path]] = None, +) -> PermissionsReport: + """Audit TCC permissions for the current user.""" + paths = db_paths or [USER_TCC_DB] + if include_system: + paths = paths + [SYSTEM_TCC_DB] + + entries: List[PermissionEntry] = [] + for path in paths: + try: + entries.extend(_read_access_rows(path)) + except sqlite3.Error: + continue + + warnings: List[str] = [] + risky = [e for e in entries if e.service in RISKY_SERVICES and e.allowed] + if risky: + warnings.append( + f"{len(risky)} app(s) have sensitive permissions (Full Disk Access, Accessibility, Screen Recording)" + ) + + return PermissionsReport(entries=entries, warnings=warnings) diff --git a/src/reporting/storage_trend.py b/src/reporting/storage_trend.py new file mode 100644 index 0000000..6d4b39f --- /dev/null +++ b/src/reporting/storage_trend.py @@ -0,0 +1,156 @@ +"""Storage trend tracker for disk usage snapshots.""" + +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Iterable, List, Optional + +from constants import CONFIG_DIR +from utils import bytes_human + +TREND_FILE = CONFIG_DIR / "storage_trend.json" +MAX_ENTRIES = 180 + + +@dataclass +class StorageSnapshot: + """One storage usage snapshot for a volume.""" + timestamp: str + volume: str + total_bytes: int + used_bytes: int + free_bytes: int + + @property + def total_human(self) -> str: + return bytes_human(self.total_bytes) + + @property + def used_human(self) -> str: + return bytes_human(self.used_bytes) + + @property + def free_human(self) -> str: + return bytes_human(self.free_bytes) + + def to_dict(self) -> dict: + return { + "timestamp": self.timestamp, + "volume": self.volume, + "total_bytes": self.total_bytes, + "used_bytes": self.used_bytes, + "free_bytes": self.free_bytes, + } + + @classmethod + def from_dict(cls, data: dict) -> "StorageSnapshot": + return cls( + timestamp=data["timestamp"], + volume=data.get("volume", "/"), + total_bytes=int(data["total_bytes"]), + used_bytes=int(data["used_bytes"]), + free_bytes=int(data["free_bytes"]), + ) + + +@dataclass +class TrendSummary: + """Summary statistics for a collection of snapshots.""" + start: StorageSnapshot + end: StorageSnapshot + delta_used: int + delta_free: int + days: int + + @property + def delta_used_human(self) -> str: + return bytes_human(abs(self.delta_used)) + + @property + def delta_free_human(self) -> str: + return bytes_human(abs(self.delta_free)) + + +def record_snapshot( + volume: Path = Path("/"), + disk_usage=shutil.disk_usage, +) -> StorageSnapshot: + """Capture a snapshot for the given volume.""" + usage = disk_usage(str(volume)) + return StorageSnapshot( + timestamp=datetime.now().isoformat(), + volume=str(volume), + total_bytes=int(usage.total), + used_bytes=int(usage.used), + free_bytes=int(usage.free), + ) + + +def load_snapshots(trend_path: Path = TREND_FILE) -> List[StorageSnapshot]: + if not trend_path.exists(): + return [] + try: + data = json.loads(trend_path.read_text()) + except (json.JSONDecodeError, OSError): + return [] + snapshots = [StorageSnapshot.from_dict(d) for d in data.get("snapshots", [])] + return sorted(snapshots, key=lambda s: s.timestamp) + + +def append_snapshot( + snapshot: StorageSnapshot, + trend_path: Path = TREND_FILE, + max_entries: int = MAX_ENTRIES, +) -> List[StorageSnapshot]: + """Append a snapshot and persist to disk. Returns full list.""" + existing = load_snapshots(trend_path) + existing.append(snapshot) + existing = sorted(existing, key=lambda s: s.timestamp) + if len(existing) > max_entries: + existing = existing[-max_entries:] + + payload = { + "generated_at": datetime.now().isoformat(), + "snapshots": [s.to_dict() for s in existing], + } + trend_path.parent.mkdir(parents=True, exist_ok=True) + trend_path.write_text(json.dumps(payload, indent=2)) + return existing + + +def summarize_trend( + snapshots: Iterable[StorageSnapshot], + days: Optional[int] = None, +) -> Optional[TrendSummary]: + """Return a summary across a time range.""" + items = list(sorted(snapshots, key=lambda s: s.timestamp)) + if not items: + return None + + if days is not None and days > 0: + cutoff = datetime.now() - timedelta(days=days) + items = [s for s in items if datetime.fromisoformat(s.timestamp) >= cutoff] + if not items: + return None + + start = items[0] + end = items[-1] + delta_used = end.used_bytes - start.used_bytes + delta_free = end.free_bytes - start.free_bytes + + span_days = ( + datetime.fromisoformat(end.timestamp) + - datetime.fromisoformat(start.timestamp) + ).days + + return TrendSummary( + start=start, + end=end, + delta_used=delta_used, + delta_free=delta_free, + days=max(span_days, 0), + ) diff --git a/src/scanners/cloud_junk.py b/src/scanners/cloud_junk.py new file mode 100644 index 0000000..82f31af --- /dev/null +++ b/src/scanners/cloud_junk.py @@ -0,0 +1,130 @@ +"""Cloud storage cache and log scanner.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set + +from constants import HOME +from utils import size_of, safe_remove + + +@dataclass +class CloudJunkItem: + """One cloud cache or log directory.""" + provider: str + category: str + path: Path + size: int + safe_to_delete: bool = True + + +@dataclass +class CloudDeleteResult: + """Summary of cloud junk deletions.""" + deleted: int = 0 + skipped: int = 0 + bytes_freed: int = 0 + errors: List[str] = field(default_factory=list) + + +_PROVIDER_ALIASES: Dict[str, str] = { + "dropbox": "Dropbox", + "google-drive": "Google Drive", + "googledrive": "Google Drive", + "drive": "Google Drive", + "onedrive": "OneDrive", + "box": "Box", +} + + +def _add_item(items: List[CloudJunkItem], provider: str, category: str, path: Path) -> None: + if not path.exists(): + return + sz = size_of(path) + if sz <= 0: + return + items.append(CloudJunkItem(provider=provider, category=category, path=path, size=sz)) + + +def _collect_dropbox(home: Path) -> List[CloudJunkItem]: + items: List[CloudJunkItem] = [] + _add_item(items, "Dropbox", "Cache", home / "Dropbox" / ".dropbox.cache") + _add_item(items, "Dropbox", "Cache", home / "Library" / "Caches" / "com.dropbox.Dropbox") + _add_item(items, "Dropbox", "Logs", home / "Library" / "Logs" / "Dropbox") + return items + + +def _collect_google_drive(home: Path) -> List[CloudJunkItem]: + items: List[CloudJunkItem] = [] + drive_root = home / "Library" / "Application Support" / "Google" / "DriveFS" + if drive_root.exists(): + for child in drive_root.iterdir(): + if not child.is_dir(): + continue + _add_item(items, "Google Drive", "Cache", child / "content_cache") + _add_item(items, "Google Drive", "Logs", child / "Logs") + _add_item(items, "Google Drive", "Cache", home / "Library" / "Caches" / "com.google.drivefs") + return items + + +def _collect_onedrive(home: Path) -> List[CloudJunkItem]: + items: List[CloudJunkItem] = [] + base = home / "Library" / "Application Support" / "OneDrive" + _add_item(items, "OneDrive", "Cache", home / "Library" / "Caches" / "com.microsoft.OneDrive") + _add_item(items, "OneDrive", "Logs", home / "Library" / "Logs" / "OneDrive") + if base.exists(): + for name in ["Logs", "Cache", "Caches"]: + _add_item(items, "OneDrive", "Cache", base / name) + return items + + +def _collect_box(home: Path) -> List[CloudJunkItem]: + items: List[CloudJunkItem] = [] + _add_item(items, "Box", "Cache", home / "Library" / "Caches" / "com.box.desktop") + _add_item(items, "Box", "Logs", home / "Library" / "Logs" / "Box") + return items + + +def collect_cloud_junk( + providers: Optional[Iterable[str]] = None, + home: Optional[Path] = None, +) -> List[CloudJunkItem]: + """Collect cloud storage cache/log entries.""" + base = home or HOME + selected: Set[str] = set() + + if providers: + for p in providers: + key = p.lower().strip() + selected.add(_PROVIDER_ALIASES.get(key, p)) + + items: List[CloudJunkItem] = [] + if not providers or "Dropbox" in selected: + items.extend(_collect_dropbox(base)) + if not providers or "Google Drive" in selected: + items.extend(_collect_google_drive(base)) + if not providers or "OneDrive" in selected: + items.extend(_collect_onedrive(base)) + if not providers or "Box" in selected: + items.extend(_collect_box(base)) + + return items + + +def delete_cloud_junk(items: List[CloudJunkItem]) -> CloudDeleteResult: + """Delete cloud junk items marked safe.""" + result = CloudDeleteResult() + for item in items: + if not item.safe_to_delete: + result.skipped += 1 + continue + ok, freed = safe_remove(item.path) + if ok: + result.deleted += 1 + result.bytes_freed += freed + else: + result.skipped += 1 + result.errors.append(str(item.path)) + return result diff --git a/src/scanners/recent_activity.py b/src/scanners/recent_activity.py new file mode 100644 index 0000000..aa06d02 --- /dev/null +++ b/src/scanners/recent_activity.py @@ -0,0 +1,102 @@ +"""Recent files and activity scanner.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +from constants import HOME +from utils import size_of, safe_remove + + +RECENT_ITEMS_DIR = HOME / "Library" / "Recent Items" +SHARED_LIST_DIR = HOME / "Library" / "Application Support" / "com.apple.sharedfilelist" +LEGACY_SHARED_LIST_DIR = HOME / "Library" / "Application Support" / "com.apple.LSSharedFileList" + + +@dataclass +class RecentActivityItem: + """One recent activity file entry.""" + category: str + path: Path + size: int + safe_to_delete: bool + + +@dataclass +class ClearResult: + """Summary of a clear operation.""" + deleted: int = 0 + skipped: int = 0 + bytes_freed: int = 0 + errors: List[str] = field(default_factory=list) + + +def _collect_dir_items( + root: Path, + category: str, + safe_to_delete: bool, +) -> List[RecentActivityItem]: + items: List[RecentActivityItem] = [] + if not root.exists(): + return items + try: + for child in root.iterdir(): + if child.is_dir(): + continue + sz = size_of(child) + if sz <= 0: + continue + items.append(RecentActivityItem( + category=category, + path=child, + size=sz, + safe_to_delete=safe_to_delete, + )) + except OSError: + return items + return items + + +def collect_recent_activity(home: Optional[Path] = None) -> List[RecentActivityItem]: + """Collect recent activity files in known locations.""" + base = home or HOME + items: List[RecentActivityItem] = [] + + recent_dir = base / "Library" / "Recent Items" + items.extend(_collect_dir_items(recent_dir, "Recent Items", True)) + + shared_dir = base / "Library" / "Application Support" / "com.apple.sharedfilelist" + items.extend(_collect_dir_items(shared_dir, "Shared Lists", False)) + + legacy_dir = base / "Library" / "Application Support" / "com.apple.LSSharedFileList" + items.extend(_collect_dir_items(legacy_dir, "Shared Lists (Legacy)", False)) + + return items + + +def clear_recent_items(home: Optional[Path] = None) -> ClearResult: + """Clear files under ~/Library/Recent Items only.""" + base = home or HOME + target = base / "Library" / "Recent Items" + result = ClearResult() + + if not target.exists(): + return result + + try: + for child in target.iterdir(): + if child.is_dir(): + continue + ok, freed = safe_remove(child) + if ok: + result.deleted += 1 + result.bytes_freed += freed + else: + result.skipped += 1 + result.errors.append(str(child)) + except OSError: + result.errors.append(str(target)) + + return result diff --git a/tests/test_features_p2_p3.py b/tests/test_features_p2_p3.py new file mode 100644 index 0000000..eb8775e --- /dev/null +++ b/tests/test_features_p2_p3.py @@ -0,0 +1,140 @@ +"""Tests for P2/P3 feature modules.""" + +from __future__ import annotations + +import pathlib +import sqlite3 +import sys +from collections import namedtuple + +REPO_ROOT = pathlib.Path(__file__).parent.parent +SRC_DIR = REPO_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + + +def test_storage_trend_record_and_summary(tmp_path: pathlib.Path) -> None: + from reporting.storage_trend import append_snapshot, load_snapshots, record_snapshot, summarize_trend + + Usage = namedtuple("Usage", ["total", "used", "free"]) + + def fake_usage(_path: str) -> Usage: + return Usage(1000, 600, 400) + + trend_file = tmp_path / "trend.json" + snap1 = record_snapshot(volume=tmp_path, disk_usage=fake_usage) + append_snapshot(snap1, trend_path=trend_file) + + def fake_usage2(_path: str) -> Usage: + return Usage(1000, 700, 300) + + snap2 = record_snapshot(volume=tmp_path, disk_usage=fake_usage2) + append_snapshot(snap2, trend_path=trend_file) + + snapshots = load_snapshots(trend_path=trend_file) + assert len(snapshots) == 2 + + summary = summarize_trend(snapshots) + assert summary is not None + assert summary.delta_used == 100 + + +def test_recent_activity_collect_and_clear(tmp_path: pathlib.Path) -> None: + from scanners.recent_activity import clear_recent_items, collect_recent_activity + + recent_dir = tmp_path / "Library" / "Recent Items" + recent_dir.mkdir(parents=True) + target = recent_dir / "recent.alias" + target.write_bytes(b"x" * 64) + + items = collect_recent_activity(home=tmp_path) + assert any(i.path == target for i in items) + + result = clear_recent_items(home=tmp_path) + assert result.deleted == 1 + assert not target.exists() + + +def test_cloud_junk_collect(tmp_path: pathlib.Path) -> None: + from scanners.cloud_junk import collect_cloud_junk + + cache_dir = tmp_path / "Library" / "Caches" / "com.dropbox.Dropbox" + cache_dir.mkdir(parents=True) + (cache_dir / "cache.bin").write_bytes(b"z" * 128) + + items = collect_cloud_junk(home=tmp_path) + assert any(i.provider == "Dropbox" for i in items) + + +def test_permissions_auditor_reads_db(tmp_path: pathlib.Path) -> None: + from core.permissions_auditor import audit_permissions + + db_path = tmp_path / "TCC.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE access (service TEXT, client TEXT, client_type INTEGER, " + "auth_value INTEGER, auth_reason INTEGER, auth_version INTEGER, last_modified INTEGER)" + ) + conn.execute( + "INSERT INTO access VALUES (?, ?, ?, ?, ?, ?, ?)", + ("kTCCServiceSystemPolicyAllFiles", "com.example.app", 0, 1, 0, 1, 0), + ) + conn.commit() + conn.close() + + report = audit_permissions(db_paths=[db_path]) + assert len(report.entries) == 1 + assert report.entries[0].allowed is True + + +def test_apfs_snapshot_parse() -> None: + from core.apfs_snapshots import parse_tmutil_output + + sample = """Snapshots for disk /: +com.apple.TimeMachine.2024-05-10-120102.local +com.apple.TimeMachine.2024-05-11-083012.local +""" + snaps = parse_tmutil_output(sample) + assert len(snaps) == 2 + assert snaps[0].token == "2024-05-10-120102" + + +def test_memory_pressure_parse() -> None: + from core.memory_pressure import parse_vm_stat + + sample = """Mach Virtual Memory Statistics: (page size of 4096 bytes) +Pages free: 1000. +Pages active: 2000. +Pages inactive: 3000. +Pages speculative: 400. +Pages wired down: 500. +Pages occupied by compressor: 600. +""" + stats = parse_vm_stat(sample) + assert stats is not None + assert stats.page_size == 4096 + assert stats.pages_wired == 500 + + +def test_breach_monitor_parse() -> None: + from core.breach_monitor import parse_breach_response + + payload = "[{\"Name\": \"Example\", \"Title\": \"Example\"}]" + data = parse_breach_response(payload) + assert data and data[0]["Name"] == "Example" + + +def test_menubar_format_swiftbar() -> None: + from core.menubar import MenubarStatus, format_swiftbar + + status = MenubarStatus( + label="Cleaner: 1.0 GB", + subtitle="Last scan: 2026-05-12 10:00", + orphan_bytes=1024, + junk_bytes=2048, + dev_junk_bytes=0, + scanned_at="2026-05-12T10:00:00", + ) + output = format_swiftbar(status) + assert "---" in output + assert "mac-cleaner" in output From 742b98eedc3c1b8a73060375ea5745087d31e2be Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Tue, 12 May 2026 09:37:32 +0530 Subject: [PATCH 3/8] new features --- checklist.md | 18 ++++----- src/cli.py | 21 +++++++++-- src/core/breach_monitor.py | 48 +++++++++++++++++++++--- src/core/memory_pressure.py | 48 ++++++++++++++++++++++++ src/core/permissions_auditor.py | 66 +++++++++++++++++++++++++++------ src/reporting/storage_trend.py | 22 +++++++++-- src/scanners/cloud_junk.py | 42 +++++++++++++++++++++ tests/test_features_p2_p3.py | 28 +++++++++++++- 8 files changed, 259 insertions(+), 34 deletions(-) diff --git a/checklist.md b/checklist.md index d059814..240d7e5 100644 --- a/checklist.md +++ b/checklist.md @@ -16,17 +16,17 @@ Use this file as the execution order. Check items only after the feature is full - [x] iOS simulator deep cleaner (src/scanners/simulators.py) ## P2 (system utilities and maintenance) -- [ ] 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) +- [x] Memory pressure reliever (src/core/memory_pressure.py) +- [x] Homebrew deep manager (src/core/brew_manager.py) +- [x] Storage trend tracker (src/reporting/storage_trend.py) +- [x] Recent files and activity cleaner (src/scanners/recent_activity.py) ## P3 (advanced and higher risk features) -- [ ] 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) +- [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) ## Additional (not yet scheduled) - [ ] Purgeable space reclaimer (src/scanners/purgeable.py) diff --git a/src/cli.py b/src/cli.py index 835a246..f29d5a1 100644 --- a/src/cli.py +++ b/src/cli.py @@ -23,7 +23,7 @@ undo Restore files from staging area history Show past scan records diff Compare two scans - system Launch items + SIP + login items + system Launch items + SIP + login items memory-pressure Inspect memory pressure, optional cache purge brew Homebrew manager (cache + cleanup) storage-trend Storage usage trend tracker @@ -1802,10 +1802,15 @@ def cmd_memory_pressure( table.add_row("Total", bytes_human(stats.total_bytes)) table.add_row("Used", bytes_human(stats.used_bytes)) table.add_row("Free", bytes_human(stats.free_bytes)) + table.add_row("Compressed", bytes_human(stats.compressed_bytes)) if free_percent is not None: table.add_row("Free %", f"{free_percent:.1f}%") if stats.pressure_level: table.add_row("Pressure", stats.pressure_level) + if stats.swap_used_bytes is not None: + table.add_row("Swap Used", bytes_human(stats.swap_used_bytes)) + if stats.swap_free_bytes is not None: + table.add_row("Swap Free", bytes_human(stats.swap_free_bytes)) console.print(table) @@ -1966,7 +1971,8 @@ def cmd_storage_trend( snapshot = record_snapshot(Path(volume)) append_snapshot(snapshot) - snapshots = load_snapshots() + resolved_volume = str(Path(volume)) + snapshots = load_snapshots(volume=resolved_volume) if not snapshots: console.print("[yellow]No storage snapshots yet. Run with --record.[/yellow]") return @@ -1995,6 +2001,7 @@ def cmd_storage_trend( import json payload = { "generated_at": __import__("datetime").datetime.now().isoformat(), + "volume": resolved_volume, "snapshots": [s.to_dict() for s in snapshots], } with open(export_path, "w") as f: @@ -2275,6 +2282,8 @@ def menubar_remove(target: Optional[str], custom_dir: Optional[str]) -> None: help="Email address to check. Can be repeated.") @click.option("--api-key", default=None, help="HIBP API key (or set HIBP_API_KEY env var).") +@click.option("--delay", default=1.6, show_default=True, + help="Delay between requests in seconds.") @click.option("--use-watchlist", is_flag=True, default=False, help="Check addresses saved in the watchlist.") @click.option("--save", is_flag=True, default=False, @@ -2284,12 +2293,13 @@ def menubar_remove(target: Optional[str], custom_dir: Optional[str]) -> None: def cmd_breach( emails: Tuple[str, ...], api_key: Optional[str], + delay: float, use_watchlist: bool, save: bool, export_path: Optional[str], ) -> None: """Check emails against Have I Been Pwned.""" - from core.breach_monitor import check_email, load_watchlist, resolve_api_key, save_watchlist + from core.breach_monitor import check_emails, load_watchlist, resolve_api_key, save_watchlist email_list = list(emails) if use_watchlist: @@ -2304,7 +2314,10 @@ def cmd_breach( save_watchlist(email_list) key = resolve_api_key(api_key) - results = [check_email(email, key or "") for email in email_list] + if not key: + console.print("[yellow]HIBP API key missing. Use --api-key or set HIBP_API_KEY.[/yellow]") + return + results = check_emails(email_list, key, min_delay=delay) console.print() console.print(Panel("[bold cyan]Breach Monitor[/bold cyan]", diff --git a/src/core/breach_monitor.py b/src/core/breach_monitor.py index bed8f22..d8db637 100644 --- a/src/core/breach_monitor.py +++ b/src/core/breach_monitor.py @@ -4,19 +4,21 @@ import json import os +import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import List, Optional +from typing import Iterable, List, Optional from constants import CONFIG_DIR HIBP_API_BASE = "https://haveibeenpwned.com/api/v3/breachedaccount" WATCHLIST_FILE = CONFIG_DIR / "breach_watchlist.json" DEFAULT_USER_AGENT = "mac-cleaner/1.2.0" +DEFAULT_MIN_DELAY = 1.6 @dataclass @@ -27,6 +29,8 @@ class BreachResult: breaches: List[dict] = field(default_factory=list) error: Optional[str] = None checked_at: str = field(default_factory=lambda: datetime.now().isoformat()) + status_code: Optional[int] = None + retry_after: Optional[int] = None def _build_request(email: str, api_key: str) -> urllib.request.Request: @@ -61,17 +65,51 @@ def check_email(email: str, api_key: str) -> BreachResult: with urllib.request.urlopen(req, timeout=15) as resp: body = resp.read().decode("utf-8") breaches = parse_breach_response(body) - return BreachResult(email=email, breached=bool(breaches), breaches=breaches) + return BreachResult(email=email, breached=bool(breaches), breaches=breaches, status_code=resp.status) except urllib.error.HTTPError as exc: if exc.code == 404: - return BreachResult(email=email, breached=False) + return BreachResult(email=email, breached=False, status_code=exc.code) if exc.code in (401, 403): - return BreachResult(email=email, breached=False, error="Invalid API key") - return BreachResult(email=email, breached=False, error=f"HTTP {exc.code}") + return BreachResult(email=email, breached=False, error="Invalid API key", status_code=exc.code) + if exc.code == 429: + retry = exc.headers.get("Retry-After") if exc.headers else None + retry_after = int(retry) if retry and retry.isdigit() else None + message = "Rate limited by HIBP" + if retry_after is not None: + message = f"Rate limited by HIBP (retry after {retry_after}s)" + return BreachResult( + email=email, + breached=False, + error=message, + status_code=exc.code, + retry_after=retry_after, + ) + return BreachResult(email=email, breached=False, error=f"HTTP {exc.code}", status_code=exc.code) except (urllib.error.URLError, OSError) as exc: return BreachResult(email=email, breached=False, error=str(exc)) +def check_emails( + emails: Iterable[str], + api_key: str, + min_delay: float = DEFAULT_MIN_DELAY, +) -> List[BreachResult]: + """Check multiple emails with basic rate limiting.""" + results: List[BreachResult] = [] + email_list = list(emails) + for idx, email in enumerate(email_list): + results.append(check_email(email, api_key)) + if idx >= len(email_list) - 1: + continue + delay = max(min_delay, 0) + last = results[-1] + if last.status_code == 429 and last.retry_after: + delay = max(delay, float(last.retry_after)) + if delay > 0: + time.sleep(delay) + return results + + def load_watchlist(path: Path = WATCHLIST_FILE) -> List[str]: if not path.exists(): return [] diff --git a/src/core/memory_pressure.py b/src/core/memory_pressure.py index 947bedc..5b1818a 100644 --- a/src/core/memory_pressure.py +++ b/src/core/memory_pressure.py @@ -23,6 +23,9 @@ class MemoryStats: pages_compressed: int free_percent: Optional[float] = None pressure_level: Optional[str] = None + swap_total_bytes: Optional[int] = None + swap_used_bytes: Optional[int] = None + swap_free_bytes: Optional[int] = None @property def total_pages(self) -> int: @@ -52,6 +55,10 @@ def used_bytes(self) -> int: used_pages = max(self.total_pages - self.free_pages, 0) return used_pages * self.page_size + @property + def compressed_bytes(self) -> int: + return self.pages_compressed * self.page_size + @dataclass class ReliefResult: @@ -64,6 +71,7 @@ class ReliefResult: _VM_PAGE_RE = re.compile(r"page size of (\d+) bytes", re.IGNORECASE) _VM_NUMBER_RE = re.compile(r"([0-9]+)\.") +_SWAP_RE = re.compile(r"(total|used|free) = ([0-9.]+)([KMGTP])", re.IGNORECASE) def parse_vm_stat(output: str) -> Optional[MemoryStats]: @@ -135,6 +143,32 @@ def parse_memory_pressure(output: str) -> Tuple[Optional[float], Optional[str]]: return free_percent, level +def _size_to_bytes(value: str, unit: str) -> int: + scale = { + "k": 1024, + "m": 1024 ** 2, + "g": 1024 ** 3, + "t": 1024 ** 4, + "p": 1024 ** 5, + }.get(unit.lower(), 1) + return int(float(value) * scale) + + +def parse_swapusage(output: str) -> Optional[Tuple[int, int, int]]: + """Parse sysctl vm.swapusage output into byte totals.""" + values = {} + for key, amount, unit in _SWAP_RE.findall(output): + values[key.lower()] = _size_to_bytes(amount, unit) + if not values: + return None + total = values.get("total") + used = values.get("used") + free = values.get("free") + if total is None or used is None or free is None: + return None + return total, used, free + + def collect_memory_stats( runner=subprocess.run, ) -> Optional[MemoryStats]: @@ -172,6 +206,20 @@ def collect_memory_stats( except (OSError, subprocess.TimeoutExpired): pass + try: + swap = runner( + ["sysctl", "vm.swapusage"], + capture_output=True, + text=True, + timeout=5, + ) + if swap.returncode == 0: + parsed = parse_swapusage(swap.stdout) + if parsed: + stats.swap_total_bytes, stats.swap_used_bytes, stats.swap_free_bytes = parsed + except (OSError, subprocess.TimeoutExpired): + pass + return stats diff --git a/src/core/permissions_auditor.py b/src/core/permissions_auditor.py index 58c098c..708d362 100644 --- a/src/core/permissions_auditor.py +++ b/src/core/permissions_auditor.py @@ -5,7 +5,7 @@ import sqlite3 from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Sequence from constants import HOME @@ -67,30 +67,70 @@ def by_service(self) -> Dict[str, List[PermissionEntry]]: return grouped +def _column_names(conn: sqlite3.Connection) -> Sequence[str]: + rows = conn.execute("PRAGMA table_info(access)").fetchall() + names: List[str] = [] + for row in rows: + try: + names.append(row["name"]) + except (KeyError, IndexError, TypeError): + try: + names.append(row[1]) + except (IndexError, TypeError): + continue + return names + + +def _row_value(row: sqlite3.Row, key: str, default: int = 0) -> int: + try: + value = row[key] + except (KeyError, IndexError, TypeError): + return default + if value is None: + return default + return int(value) + + def _read_access_rows(db_path: Path) -> List[PermissionEntry]: if not db_path.exists(): return [] conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row + rows: List[sqlite3.Row] try: + names = set(_column_names(conn)) + auth_col = None + if "auth_value" in names: + auth_col = "auth_value" + elif "allowed" in names: + auth_col = "allowed" + + if auth_col is None: + return [] + + select_cols = ["service", "client", auth_col] + for optional in ["client_type", "auth_reason", "auth_version", "last_modified"]: + if optional in names: + select_cols.append(optional) + rows = conn.execute( - "SELECT service, client, client_type, auth_value, auth_reason, auth_version, last_modified " - "FROM access" + f"SELECT {', '.join(select_cols)} FROM access" ).fetchall() finally: conn.close() entries: List[PermissionEntry] = [] for row in rows: + auth_value = _row_value(row, "auth_value", _row_value(row, "allowed", 0)) entries.append(PermissionEntry( - service=row["service"], - client=row["client"], - client_type=int(row["client_type"]), - auth_value=int(row["auth_value"]), - auth_reason=int(row["auth_reason"]), - auth_version=int(row["auth_version"]), - last_modified=int(row["last_modified"]), + service=str(row["service"]), + client=str(row["client"]), + client_type=_row_value(row, "client_type", 0), + auth_value=auth_value, + auth_reason=_row_value(row, "auth_reason", 0), + auth_version=_row_value(row, "auth_version", 0), + last_modified=_row_value(row, "last_modified", 0), )) return entries @@ -105,10 +145,12 @@ def audit_permissions( paths = paths + [SYSTEM_TCC_DB] entries: List[PermissionEntry] = [] + errors: List[str] = [] for path in paths: try: entries.extend(_read_access_rows(path)) - except sqlite3.Error: + except sqlite3.Error as exc: + errors.append(f"{path}: {exc}") continue warnings: List[str] = [] @@ -117,5 +159,7 @@ def audit_permissions( warnings.append( f"{len(risky)} app(s) have sensitive permissions (Full Disk Access, Accessibility, Screen Recording)" ) + if errors and not entries: + warnings.append("Permissions database could not be read. Full Disk Access may be required.") return PermissionsReport(entries=entries, warnings=warnings) diff --git a/src/reporting/storage_trend.py b/src/reporting/storage_trend.py index 6d4b39f..8a2a035 100644 --- a/src/reporting/storage_trend.py +++ b/src/reporting/storage_trend.py @@ -90,7 +90,10 @@ def record_snapshot( ) -def load_snapshots(trend_path: Path = TREND_FILE) -> List[StorageSnapshot]: +def load_snapshots( + trend_path: Path = TREND_FILE, + volume: Optional[str] = None, +) -> List[StorageSnapshot]: if not trend_path.exists(): return [] try: @@ -98,6 +101,8 @@ def load_snapshots(trend_path: Path = TREND_FILE) -> List[StorageSnapshot]: except (json.JSONDecodeError, OSError): return [] snapshots = [StorageSnapshot.from_dict(d) for d in data.get("snapshots", [])] + if volume: + snapshots = [s for s in snapshots if s.volume == volume] return sorted(snapshots, key=lambda s: s.timestamp) @@ -110,8 +115,19 @@ def append_snapshot( existing = load_snapshots(trend_path) existing.append(snapshot) existing = sorted(existing, key=lambda s: s.timestamp) - if len(existing) > max_entries: - existing = existing[-max_entries:] + + grouped: dict[str, List[StorageSnapshot]] = {} + for snap in existing: + grouped.setdefault(snap.volume, []).append(snap) + + trimmed: List[StorageSnapshot] = [] + for volume, snaps in grouped.items(): + snaps = sorted(snaps, key=lambda s: s.timestamp) + if len(snaps) > max_entries: + snaps = snaps[-max_entries:] + trimmed.extend(snaps) + + existing = sorted(trimmed, key=lambda s: s.timestamp) payload = { "generated_at": datetime.now().isoformat(), diff --git a/src/scanners/cloud_junk.py b/src/scanners/cloud_junk.py index 82f31af..20cba9c 100644 --- a/src/scanners/cloud_junk.py +++ b/src/scanners/cloud_junk.py @@ -38,6 +38,44 @@ class CloudDeleteResult: "box": "Box", } +_ALLOWED_ROOTS: List[Path] = [ + HOME / "Dropbox" / ".dropbox.cache", + HOME / "Library" / "Caches" / "com.dropbox.Dropbox", + HOME / "Library" / "Logs" / "Dropbox", + HOME / "Library" / "Caches" / "com.google.drivefs", + HOME / "Library" / "Caches" / "com.microsoft.OneDrive", + HOME / "Library" / "Logs" / "OneDrive", + HOME / "Library" / "Application Support" / "OneDrive" / "Logs", + HOME / "Library" / "Application Support" / "OneDrive" / "Cache", + HOME / "Library" / "Application Support" / "OneDrive" / "Caches", + HOME / "Library" / "Caches" / "com.box.desktop", + HOME / "Library" / "Logs" / "Box", +] + + +def _is_allowed_cloud_path(path: Path) -> bool: + try: + resolved = path.expanduser().resolve() + except OSError: + resolved = path.expanduser() + + if "DriveFS" in resolved.parts and resolved.name in {"content_cache", "Logs"}: + return True + + for root in _ALLOWED_ROOTS: + try: + if resolved.is_relative_to(root.expanduser().resolve()): + return True + except AttributeError: + try: + if str(resolved).startswith(str(root.expanduser().resolve())): + return True + except OSError: + continue + except OSError: + continue + return False + def _add_item(items: List[CloudJunkItem], provider: str, category: str, path: Path) -> None: if not path.exists(): @@ -120,6 +158,10 @@ def delete_cloud_junk(items: List[CloudJunkItem]) -> CloudDeleteResult: if not item.safe_to_delete: result.skipped += 1 continue + if not _is_allowed_cloud_path(item.path): + result.skipped += 1 + result.errors.append(f"Blocked outside allowlist: {item.path}") + continue ok, freed = safe_remove(item.path) if ok: result.deleted += 1 diff --git a/tests/test_features_p2_p3.py b/tests/test_features_p2_p3.py index eb8775e..1aa8c55 100644 --- a/tests/test_features_p2_p3.py +++ b/tests/test_features_p2_p3.py @@ -31,7 +31,7 @@ def fake_usage2(_path: str) -> Usage: snap2 = record_snapshot(volume=tmp_path, disk_usage=fake_usage2) append_snapshot(snap2, trend_path=trend_file) - snapshots = load_snapshots(trend_path=trend_file) + snapshots = load_snapshots(trend_path=trend_file, volume=str(tmp_path)) assert len(snapshots) == 2 summary = summarize_trend(snapshots) @@ -87,6 +87,26 @@ def test_permissions_auditor_reads_db(tmp_path: pathlib.Path) -> None: assert report.entries[0].allowed is True +def test_permissions_auditor_allowed_column(tmp_path: pathlib.Path) -> None: + from core.permissions_auditor import audit_permissions + + db_path = tmp_path / "TCC.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE access (service TEXT, client TEXT, client_type INTEGER, allowed INTEGER, last_modified INTEGER)" + ) + conn.execute( + "INSERT INTO access VALUES (?, ?, ?, ?, ?)", + ("kTCCServiceAccessibility", "com.example.app", 0, 1, 0), + ) + conn.commit() + conn.close() + + report = audit_permissions(db_paths=[db_path]) + assert len(report.entries) == 1 + assert report.entries[0].allowed is True + + def test_apfs_snapshot_parse() -> None: from core.apfs_snapshots import parse_tmutil_output @@ -100,7 +120,7 @@ def test_apfs_snapshot_parse() -> None: def test_memory_pressure_parse() -> None: - from core.memory_pressure import parse_vm_stat + from core.memory_pressure import parse_vm_stat, parse_swapusage sample = """Mach Virtual Memory Statistics: (page size of 4096 bytes) Pages free: 1000. @@ -115,6 +135,10 @@ def test_memory_pressure_parse() -> None: assert stats.page_size == 4096 assert stats.pages_wired == 500 + swap = parse_swapusage("vm.swapusage: total = 1.00G used = 0.25G free = 0.75G (encrypted)") + assert swap is not None + assert swap[1] > 0 + def test_breach_monitor_parse() -> None: from core.breach_monitor import parse_breach_response From eb87b64efe8ba8ce05b2b1779c5c8f2329f059e3 Mon Sep 17 00:00:00 2001 From: Nitish Kumar Date: Tue, 12 May 2026 09:49:45 +0530 Subject: [PATCH 4/8] new features --- CHANGELOG.md | 38 ++++++++++++------ README.md | 2 +- checklist.md | 26 ------------ docs/FEATURES.md | 2 +- docs/PYPI_PUBLISHING.md | 6 +-- roadmap.md | 73 ---------------------------------- scripts/build.sh | 2 +- src/__init__.py | 2 +- src/cli.py | 24 +++++------ src/config/config.py | 2 +- src/config/history.py | 2 +- src/config/models.py | 2 +- src/constants.py | 37 ++++++----------- src/core/apfs_snapshots.py | 4 +- src/core/cleaner.py | 6 +-- src/core/completions.py | 16 ++++++-- src/core/safety.py | 2 +- src/core/scanner.py | 2 +- src/core/scheduler.py | 2 +- src/core/system_inspector.py | 2 +- src/core/undo.py | 2 +- src/core/updater.py | 2 +- src/reporting/exporter.py | 6 +-- src/reporting/html_report.py | 6 +-- src/reporting/reporter.py | 6 +-- src/reporting/reporter_v5.py | 2 +- src/scanners/binary_thinner.py | 2 +- src/scanners/dev_junk.py | 2 +- src/scanners/discovery.py | 2 +- src/scanners/duplicates.py | 2 +- src/scanners/extras.py | 4 +- src/scanners/large_files.py | 2 +- src/scanners/matching.py | 2 +- src/scanners/symlinks.py | 2 +- src/utils.py | 2 +- 35 files changed, 103 insertions(+), 193 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3b307..b71b04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,19 +4,31 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. ## Unreleased -## v1.3.0 (2026-05-12) +## v1.5.0 (2026-05-12) ### Added -- Global dry-run flag that blocks destructive actions (`--dry-run`) -- Shell completions command for bash/zsh/fish -- Full app uninstaller command with undo staging support -- Browser data cleaner command (cache, cookies, history, sessions) -- Disk space map command for folder usage summaries -- Photos library analyzer command for Photos bundles -- iOS simulator cleaner command (devices, caches, logs) -- P0/P1 modules in core/scanners with CLI wiring -- Tests for the new P0/P1 features -- Debug logging flags (`--verbose`, `--log-file`) with file rotation -- Basic test coverage for utilities and matching +#### P0 (baseline UX and safety) +- [x] Global --dry-run flag (src/core/dry_run.py) +- [x] Shell completion command (src/core/completions.py) +- [x] Full app uninstaller (src/core/uninstaller.py) + +#### P1 (highest demand data and visibility) +- [x] Browser data cleaner (src/scanners/browser_data.py) +- [x] Visual disk space map (src/scanners/space_map.py) +- [x] Photo library analyzer (src/scanners/photos_analyzer.py) +- [x] iOS simulator deep cleaner (src/scanners/simulators.py) + +#### P2 (system utilities and maintenance) +- [x] Memory pressure reliever (src/core/memory_pressure.py) +- [x] Homebrew deep manager (src/core/brew_manager.py) +- [x] Storage trend tracker (src/reporting/storage_trend.py) +- [x] Recent files and activity cleaner (src/scanners/recent_activity.py) + +#### P3 (advanced and higher risk features) +- [x] Permissions auditor (src/core/permissions_auditor.py) +- [x] APFS snapshot guard (src/core/apfs_snapshots.py) +- [x] Menu bar companion (src/core/menubar.py) +- [x] Data breach monitor (src/core/breach_monitor.py) +- [x] Cloud storage junk scanner (src/scanners/cloud_junk.py) ### Changed - CLI wiring for new P0/P1 commands and dry-run behavior @@ -35,7 +47,7 @@ All notable changes to **mac-deep-cleaner** will be documented in this file. ### Changed - Live dashboard now shows top findings and dev junk totals - Scan history schema extended with developer junk totals -- Version bump to v1.2.0 across docs and UI +- Version bump to v1.5.0 across docs and UI ## v1.0.0 (2026-05-10) ### Added diff --git a/README.md b/README.md index 8093045..4476eee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Mac Deep Cleaner v1.2.0 +# Mac Deep Cleaner v1.5.0 **Professional macOS cleanup tool — Smart App Orphan Detector** diff --git a/checklist.md b/checklist.md index 240d7e5..0d2e7c0 100644 --- a/checklist.md +++ b/checklist.md @@ -2,32 +2,6 @@ Date: 2026-05-11 -Use this file as the execution order. Check items only after the feature is fully implemented, wired to CLI, and covered by tests when feasible. - -## P0 (baseline UX and safety) -- [x] Global --dry-run flag (src/core/dry_run.py) -- [x] Shell completion command (src/core/completions.py) -- [x] Full app uninstaller (src/core/uninstaller.py) - -## P1 (highest demand data and visibility) -- [x] Browser data cleaner (src/scanners/browser_data.py) -- [x] Visual disk space map (src/scanners/space_map.py) -- [x] Photo library analyzer (src/scanners/photos_analyzer.py) -- [x] iOS simulator deep cleaner (src/scanners/simulators.py) - -## P2 (system utilities and maintenance) -- [x] Memory pressure reliever (src/core/memory_pressure.py) -- [x] Homebrew deep manager (src/core/brew_manager.py) -- [x] Storage trend tracker (src/reporting/storage_trend.py) -- [x] Recent files and activity cleaner (src/scanners/recent_activity.py) - -## P3 (advanced and higher risk features) -- [x] Permissions auditor (src/core/permissions_auditor.py) -- [x] APFS snapshot guard (src/core/apfs_snapshots.py) -- [x] Menu bar companion (src/core/menubar.py) -- [x] Data breach monitor (src/core/breach_monitor.py) -- [x] Cloud storage junk scanner (src/scanners/cloud_junk.py) - ## Additional (not yet scheduled) - [ ] Purgeable space reclaimer (src/scanners/purgeable.py) - [ ] Installer and PKG file hunter (src/scanners/installer_hunter.py) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f0911bd..9386be4 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,4 +1,4 @@ -# Mac Deep Cleaner — Features (v1.2.0) +# Mac Deep Cleaner — Features (v1.5.0) **Mac Deep Cleaner** is a professional macOS cleanup tool that safely detects and helps you remove leftover data from uninstalled apps, along with general junk that accumulates over time. It is designed to be **safe by default**, with **preview-first** workflows and **undo/restore support**. diff --git a/docs/PYPI_PUBLISHING.md b/docs/PYPI_PUBLISHING.md index 0d2e400..5c3bd45 100644 --- a/docs/PYPI_PUBLISHING.md +++ b/docs/PYPI_PUBLISHING.md @@ -1,4 +1,4 @@ -# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v1.2.0) +# Publishing to PyPI (pypi.org) — mac-deep-cleaner (v1.5.0) ## What you’ll publish This project is configured to build with `setuptools` from `pyproject.toml` (PEP 621). The package name is: @@ -50,8 +50,8 @@ pip install mac-deep-cleaner==1.2.0 If you want to keep credentials out of shell history: - Use your CI secret store to set `TWINE_USERNAME` and `TWINE_PASSWORD`. -## Quick checklist for v1.2.0 +## Quick checklist for v1.5.0 - `pyproject.toml` → `project.version = "1.2.0"` -- `README.md` / docs reflect v1.2.0 +- `README.md` / docs reflect v1.5.0 - `python3 -m build` produces valid wheel + sdist - `twine upload dist/*` succeeds diff --git a/roadmap.md b/roadmap.md index e92d5dd..b6da033 100644 --- a/roadmap.md +++ b/roadmap.md @@ -10,79 +10,6 @@ Date: 2026-05-11 ## Proposed Feature Modules (one per feature) -Privacy and security -- src/scanners/browser_data.py -- src/scanners/recent_activity.py -- src/core/breach_monitor.py -- src/core/permissions_auditor.py - -Storage intelligence -- src/scanners/space_map.py -- src/scanners/purgeable.py -- src/scanners/cloud_junk.py -- src/scanners/photos_analyzer.py -- src/scanners/installer_hunter.py - -Performance and system -- src/core/memory_pressure.py -- src/core/dns_cache.py -- src/core/font_cache.py -- src/core/spotlight.py -- src/core/power_optimizer.py - -Application management -- src/core/uninstaller.py -- src/core/update_checker.py -- src/core/brew_manager.py -- src/core/pkg_receipts.py - -Simulation and development -- src/scanners/simulators.py -- src/scanners/xcode_cleaner.py - -Reporting and insights -- src/reporting/weekly_digest.py -- src/reporting/storage_trend.py -- src/reporting/impact_score.py - -UX and workflow -- src/core/completions.py -- src/core/tui_picker.py -- src/core/dry_run.py -- src/core/config_sync.py -- src/core/menubar.py - -Safety enhancements -- src/core/time_machine_guard.py -- src/core/apfs_snapshots.py -- src/core/restore_checksums.py - -## Phases and Order - -P0 (baseline UX and safety) -- Global --dry-run flag (core/dry_run) -- Shell completion command (core/completions) -- Full app uninstaller (core/uninstaller) - -P1 (highest demand data and visibility) -- Browser data cleaner (scanners/browser_data) -- Visual disk space map (scanners/space_map) -- Photo library analyzer (scanners/photos_analyzer) -- iOS simulator deep cleaner (scanners/simulators) - -P2 (system utilities and maintenance) -- Memory pressure reliever (system/memory_pressure) -- Homebrew deep manager (apps/brew_manager) -- Storage trend tracker (reporting/storage_trend) -- Recent files and activity cleaner (privacy/recent_activity) - -P3 (advanced and higher risk features) -- Permissions auditor (security/permissions_auditor) -- APFS snapshot guard (safety/apfs_snapshots) -- Menu bar companion (ux/menubar) -- Data breach monitor (security/breach_monitor) -- Cloud storage junk scanner (storage/cloud_junk) - ## Cross-cutting Work - Add new CLI subcommands and options in src/cli.py - Extend config schema in src/config/config.py for new features diff --git a/scripts/build.sh b/scripts/build.sh index 1b691ec..809aef3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# Mac Deep Cleaner v1.2.0 — Build & Install Script +# Mac Deep Cleaner v1.5.0 — Build & Install Script # ============================================================================= # Usage: # bash build.sh → default: build wheel + sdist diff --git a/src/__init__.py b/src/__init__.py index 71bd087..12f1817 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Professional Edition +Mac Deep Cleaner v1.5.0 — Professional Edition Smart App Orphan Detector & System Cleanup Tool for macOS """ diff --git a/src/cli.py b/src/cli.py index f29d5a1..8b3c0b5 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Mac Deep Cleaner v1.2.0 — CLI Entry Point +Mac Deep Cleaner v1.5.0 — CLI Entry Point ======================================= All subcommands, new and updated. @@ -23,16 +23,16 @@ undo Restore files from staging area history Show past scan records diff Compare two scans - system Launch items + SIP + login items - memory-pressure Inspect memory pressure, optional cache purge - brew Homebrew manager (cache + cleanup) - storage-trend Storage usage trend tracker - recent-activity Recent files/activity scanner - permissions Audit macOS privacy permissions (TCC) - snapshots APFS local snapshot guard - menubar Menu bar companion (SwiftBar/xbar) - breach Data breach monitor (HIBP API) - cloud-junk Cloud storage cache/log scanner + system Launch items + SIP + login items + memory-pressure Inspect memory pressure, optional cache purge + brew Homebrew manager (cache + cleanup) + storage-trend Storage usage trend tracker + recent-activity Recent files/activity scanner + permissions Audit macOS privacy permissions (TCC) + snapshots APFS local snapshot guard + menubar Menu bar companion (SwiftBar/xbar) + breach Data breach monitor (HIBP API) + cloud-junk Cloud storage cache/log scanner schedule Install / remove / status of weekly scan update Check for and apply upgrades config Show / init config file @@ -165,7 +165,7 @@ def main( log_file: Optional[str], dry_run: bool, ) -> None: - """Mac Deep Cleaner v1.2.0 — Professional macOS cleanup tool.""" + """Mac Deep Cleaner v1.5.0 — Professional macOS cleanup tool.""" from core.dry_run import set_dry_run configure_logging( verbose=verbose, diff --git a/src/config/config.py b/src/config/config.py index dbbb63b..b9fddf7 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Configuration Manager +Mac Deep Cleaner v1.5.0 — Configuration Manager ============================================= Reads and writes a YAML config file at ~/.config/mac-cleaner/config.yaml. diff --git a/src/config/history.py b/src/config/history.py index 9778520..b8c50f7 100644 --- a/src/config/history.py +++ b/src/config/history.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Scan History & Diff +Mac Deep Cleaner v1.5.0 — Scan History & Diff ========================================== Stores past scan results in ~/.config/mac-cleaner/history/ as JSON files. Allows comparing two scans to show what's new or resolved since the last run. diff --git a/src/config/models.py b/src/config/models.py index b37fe8a..631c461 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Data Models +Mac Deep Cleaner v1.5.0 — Data Models ================================== Immutable data classes for apps, orphan entries, and junk entries. """ diff --git a/src/constants.py b/src/constants.py index a194f58..72b5215 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,19 +1,8 @@ """ -Mac Deep Cleaner v1.2.0 — Constants & Configuration +Mac Deep Cleaner v1.5.0 — Constants & Configuration ================================================= All safelists, alias tables, search roots, and configuration constants. -Changes from v1.2.0 ---------------- -- Added CONFIG_DIR (used by config.py, history.py, scheduler.py) -- Expanded APP_DIR_ALIASES: legacy aliases retained, ~30 new entries added -- Expanded TEAM_ID_MAP: legacy mappings retained, additional vendor IDs added -- SYSTEM_GROUP_PREFIXES: unchanged (already a proper set) -- SYSTEM_KEYWORD_SAFELIST: unchanged -- SYSTEM_EXACT_SAFELIST: legacy entries retained, ~50 daemon names added -- SYSTEM_CACHE_PREFIXES: unchanged -- SYSTEM_FILE_EXTENSIONS: unchanged -- SYSTEM_PREF_PATTERNS: unchanged """ from pathlib import Path @@ -21,7 +10,7 @@ HOME = Path.home() LOG_FILE = HOME / ".mac_cleaner_deleted.log" -CONFIG_DIR = HOME / ".config" / "mac-cleaner" # NEW in v1.2.0 +CONFIG_DIR = HOME / ".config" / "mac-cleaner" # NEW in v1.5.0 # ── Scan roots ──────────────────────────────────────────────────────────────── @@ -79,7 +68,7 @@ "microsoft teams": "com.microsoft.teams", "microsoft teams (work or school)": "com.microsoft.teams2", "microsoft teams classic": "com.microsoft.teams", - # v1.2.0: short-form aliases for Microsoft apps + # v1.5.0: short-form aliases for Microsoft apps "excel": "com.microsoft.excel", "word": "com.microsoft.word", "powerpoint": "com.microsoft.powerpoint", @@ -97,7 +86,7 @@ "google chrome canary": "com.google.chrome.canary", "google drive": "com.google.drivefs", "google earth pro": "com.google.googleearthpro", - # v1.2.0 + # v1.5.0 "googledrive": "com.google.drivefs", # ── JetBrains ───────────────────────────────────────────────────────── @@ -116,7 +105,7 @@ "appcode": "com.jetbrains.appcode", "fleet": "com.jetbrains.fleet", "jetbrains toolbox": "com.jetbrains.toolbox", - # v1.2.0 + # v1.5.0 "jetbrains toolbox app": "com.jetbrains.toolbox", # ── Browsers ────────────────────────────────────────────────────────── @@ -134,7 +123,7 @@ "tor browser": "org.torproject.torbrowser", "waterfox": "net.nickolaj.nickelodeon", "sidekick": "com.nicklodeon.nickelodeon", - # v1.2.0: extra short-form browser aliases + # v1.5.0: extra short-form browser aliases "brave": "com.brave.browser", "edge": "com.microsoft.edgemac", @@ -184,7 +173,7 @@ "ia writer": "pro.writer.mac", "devonthink 3": "com.devon-technologies.think3", "devonthink": "com.devon-technologies.think3", - # v1.2.0 + # v1.5.0 "linear": "com.linear.linear", "superhuman": "com.superhuman.desktop", @@ -204,7 +193,7 @@ "gimp": "org.gimp.gimp-2.10", "canva": "com.canva.canva", "principle": "com.principleformac.principle", - # v1.2.0 + # v1.5.0 "pixelmator": "com.pixelmatorteam.pixelmator", # ── Dev tools ───────────────────────────────────────────────────────── @@ -236,7 +225,7 @@ "coderunner": "com.krill.coderunner", "coteditor": "com.coteditor.coteditor", "textedit": "com.apple.textedit", - # v1.2.0 + # v1.5.0 "simulator": "com.apple.iphonesimulator", "xcode": "com.apple.dt.xcode", "textmate": "com.macromates.textmate", @@ -353,7 +342,7 @@ # ── Apple Developer Team ID → owner name ───────────────────────────────────── TEAM_ID_MAP: Dict[str, str] = { - # ── v1.2.0 original entries (verbatim) ──────────────────────────────────── + # ── v1.5.0 original entries (verbatim) ──────────────────────────────────── "ubf8t346g9": "Microsoft Office", "2bua8c4s2c": "1Password", "7pkpll4vld": "Dropbox", @@ -393,7 +382,7 @@ "t9um3f5r6t": "Spark / Readdle", "w5364u7y5r": "Canva", - # ── v1.2.0 additions ────────────────────────────────────────────────────── + # ── v1.5.0 additions ────────────────────────────────────────────────────── "ug75gva3v9": "Microsoft (general)", "jq525l2msd": "Adobe", "g7hh3359t7": "Dropbox", @@ -500,7 +489,7 @@ # ── Exact-stem safelist — the stem (lowercased) matches exactly ─────────────── SYSTEM_EXACT_SAFELIST: Set[str] = { - # ── v1.2.0 original entries (verbatim) ──────────────────────────────────── + # ── v1.5.0 original entries (verbatim) ──────────────────────────────────── # Networking / directory "systemconfiguration", "opendirectory", "directoryservice", @@ -598,7 +587,7 @@ "storedownloadd", "commerced", - # ── v1.2.0 additions ────────────────────────────────────────────────────── + # ── v1.5.0 additions ────────────────────────────────────────────────────── "apsd", "appleid", "airplay", diff --git a/src/core/apfs_snapshots.py b/src/core/apfs_snapshots.py index 0d0a704..444b696 100644 --- a/src/core/apfs_snapshots.py +++ b/src/core/apfs_snapshots.py @@ -6,7 +6,7 @@ import subprocess from dataclasses import dataclass from datetime import datetime, timedelta -from typing import List, Optional +from typing import List, Optional, cast _TMUTIL_RE = re.compile( @@ -68,7 +68,7 @@ def select_snapshots_to_delete( """Select snapshots to delete based on age or keep count.""" ordered = sorted( [s for s in snapshots if s.created_at is not None], - key=lambda s: s.created_at, + key=lambda s: cast(datetime, s.created_at), ) if not ordered: return [] diff --git a/src/core/cleaner.py b/src/core/cleaner.py index 8a22fdf..10bad25 100644 --- a/src/core/cleaner.py +++ b/src/core/cleaner.py @@ -1,10 +1,10 @@ """ -Mac Deep Cleaner v1.2.0 — Cleaner Module +Mac Deep Cleaner v1.5.0 — Cleaner Module ====================================== Handles deletion of orphan and junk files with safety checks, audit logging, and optional staged-deletion (undo) support. -Changes from v1.2.0 +Changes from v1.5.0 --------------- - do_cleanup() now accepts an optional `session` parameter. When provided, files are moved to the staging area (undo.stage_file) @@ -41,7 +41,7 @@ def write_deletion_log(entries: List[Tuple[str, int]]) -> None: try: with open(LOG_FILE, "a") as f: f.write(f"\n{'=' * 60}\n") - f.write(f"Mac Deep Cleaner v1.2.0 — Deletion Log\n") + f.write(f"Mac Deep Cleaner v1.5.0 — Deletion Log\n") f.write(f"Timestamp: {datetime.now().isoformat()}\n") f.write(f"Items deleted: {len(entries)}\n") f.write(f"Total freed: {bytes_human(sum(s for _, s in entries))}\n") diff --git a/src/core/completions.py b/src/core/completions.py index 32a0be1..3f01a96 100644 --- a/src/core/completions.py +++ b/src/core/completions.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from typing import Callable, cast SUPPORTED_SHELLS = ("bash", "zsh", "fish") @@ -37,9 +38,16 @@ def completion_script(shell: str, prog_name: str, command=None) -> str: raise ValueError(f"Unsupported shell: {shell}") try: - from click.shell_completion import get_completion_script + import click.shell_completion as shell_completion except Exception: return _fallback_script(shell, prog_name) + + get_completion_script = getattr(shell_completion, "get_completion_script", None) + if not callable(get_completion_script): + return _fallback_script(shell, prog_name) + + get_completion_script_typed = cast(Callable[..., str], get_completion_script) + import inspect params = list(inspect.signature(get_completion_script).parameters) @@ -48,10 +56,10 @@ def completion_script(shell: str, prog_name: str, command=None) -> str: try: if has_command and command is not None: - return get_completion_script(prog_name, shell, command) + return get_completion_script_typed(prog_name, shell, command) if has_complete_var: - return get_completion_script(prog_name, shell, _complete_var(prog_name)) - return get_completion_script(prog_name, shell) + return get_completion_script_typed(prog_name, shell, _complete_var(prog_name)) + return get_completion_script_typed(prog_name, shell) except TypeError: return _fallback_script(shell, prog_name) diff --git a/src/core/safety.py b/src/core/safety.py index 3a9f0e4..6e27f33 100644 --- a/src/core/safety.py +++ b/src/core/safety.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Safety Module +Mac Deep Cleaner v1.5.0 — Safety Module ===================================== All safety checks, safelist lookups, and system-file protection logic. Ensures that system-critical files are NEVER deleted. diff --git a/src/core/scanner.py b/src/core/scanner.py index 5ddef2d..b4cfb94 100644 --- a/src/core/scanner.py +++ b/src/core/scanner.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Scanner Module +Mac Deep Cleaner v1.5.0 — Scanner Module ====================================== Core scanning logic for orphan detection and general junk discovery. """ diff --git a/src/core/scheduler.py b/src/core/scheduler.py index 19f3f19..2dc9f72 100644 --- a/src/core/scheduler.py +++ b/src/core/scheduler.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Notifications & Scheduler +Mac Deep Cleaner v1.5.0 — Notifications & Scheduler ================================================= Notifications diff --git a/src/core/system_inspector.py b/src/core/system_inspector.py index 34b21f7..5375c0f 100644 --- a/src/core/system_inspector.py +++ b/src/core/system_inspector.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — System Inspector +Mac Deep Cleaner v1.5.0 — System Inspector ======================================== Three sub-features bundled together because they share macOS system queries: diff --git a/src/core/undo.py b/src/core/undo.py index ffd429c..25cd96a 100644 --- a/src/core/undo.py +++ b/src/core/undo.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Undo / Restore (Staged Deletion) +Mac Deep Cleaner v1.5.0 — Undo / Restore (Staged Deletion) ======================================================== Instead of permanently deleting files, mac-cleaner moves them to a staging area (~/.mac_cleaner_trash/) with a JSON manifest so they can be restored. diff --git a/src/core/updater.py b/src/core/updater.py index eaccb26..091d3b9 100644 --- a/src/core/updater.py +++ b/src/core/updater.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Self-Update +Mac Deep Cleaner v1.5.0 — Self-Update =================================== Checks PyPI for a newer version and upgrades the package in-place using pip. diff --git a/src/reporting/exporter.py b/src/reporting/exporter.py index e8bca86..6fa7d15 100644 --- a/src/reporting/exporter.py +++ b/src/reporting/exporter.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — Export Module +Mac Deep Cleaner v1.5.0 — Export Module ===================================== Exports scan results to JSON or YAML format. """ @@ -27,7 +27,7 @@ def export_json( ) -> None: """Export full scan results to JSON.""" data = { - "tool": "Mac Deep Cleaner v1.2.0", + "tool": "Mac Deep Cleaner v1.5.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: { @@ -84,7 +84,7 @@ def export_yaml( return data = { - "tool": "Mac Deep Cleaner v1.2.0", + "tool": "Mac Deep Cleaner v1.5.0", "generated_at": datetime.now().isoformat(), "orphaned_apps": { name: [e.to_dict() for e in entries] diff --git a/src/reporting/html_report.py b/src/reporting/html_report.py index cf71022..3bede8b 100644 --- a/src/reporting/html_report.py +++ b/src/reporting/html_report.py @@ -1,5 +1,5 @@ """ -Mac Deep Cleaner v1.2.0 — HTML Report Exporter +Mac Deep Cleaner v1.5.0 — HTML Report Exporter ============================================ Generates a self-contained HTML report with: - Collapsible sections per category @@ -94,7 +94,7 @@

◆ Mac Deep Cleaner — Scan Report

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