Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to **mac-deep-cleaner** will be documented in this file.

## v2.2.0 (2026-06-06)

### Added
- Docker Cleanup scanner via `mdc clean --docker` to reclaim space from dangling images and unused volumes.
- Interactive Terminal User Interface (TUI) via `mdc tui` (requires `textual`).
- Deep App Uninstaller via `mdc uninstall` to simultaneously hunt and remove app bundles and their dependencies.
- Background Scheduling enhancement via `mdc schedule install --clean` to automatically clean junk weekly.
- DNS Cache Flushing via `mdc flush-dns` to troubleshoot networking routing issues.
- `--auto-with-cache` flag to `clean` command to prompt for clearing System Caches.
- Beautified CLI UI with rounded boxes, rich colors, and emojis for `reporter.py` and `cli.py`.
- Dozens of modern apps (AI tools, dev environments, productivity apps) added to aliases and safe lists to improve detection.

### Fixed
- Fixed bug where Brave Browser configurations (`BraveSoftware`) were incorrectly flagged as orphaned leftovers.
- Fixed UI bug where an empty table was rendered for the "General Junk" category when no user-actionable junk was found.

### Changed
- Refactored monolithic `constants.py` into a modular `src/constants/` package for better maintainability.

## v2.0.1 (2026-05-22)

### Added
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mac-deep-cleaner"
version = "2.0.1"
version = "2.2.0"
description = "Professional Mac cleanup tool — Smart App Orphan Detector"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -15,6 +15,7 @@ dependencies = [
"click>=8.1.0",
"pyyaml>=6.0",
"packaging>=23.0",
"textual>=0.40.0",
]
classifiers = [
"Development Status :: 4 - Beta",
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
Smart App Orphan Detector & System Cleanup Tool for macOS
"""

__version__ = "2.0.1"
__version__ = "2.2.0"
__author__ = "NK2552003"
122 changes: 116 additions & 6 deletions src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def main(
log_file: Optional[str],
dry_run: bool,
) -> None:
"""Mac Deep Cleaner v2.0.1 — Professional macOS cleanup tool."""
"""Mac Deep Cleaner v2.2.0 — Professional macOS cleanup tool."""
from core.dry_run import set_dry_run
configure_logging(
verbose=verbose,
Expand Down Expand Up @@ -478,6 +478,10 @@ 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.option("--docker", is_flag=True, default=False,
help="Clean unused Docker containers, images, and networks.")
@click.option("--auto-with-cache", is_flag=True, default=False,
help="Prompt to delete system caches when using auto clean.")
@click.pass_context
def clean(
ctx: click.Context,
Expand All @@ -492,6 +496,8 @@ def clean(
custom_roots: Tuple[str, ...],
notify: bool,
no_undo: bool,
auto_with_cache: bool,
docker: bool,
) -> None:
"""Interactively clean orphaned app leftovers and junk.

Expand All @@ -513,6 +519,14 @@ def clean(
if dry_run:
console.print("[yellow]Dry-run enabled; clean will run in preview mode.[/yellow]")

clean_system_caches = False
if auto_with_cache and not dry_run:
from rich.prompt import Confirm
clean_system_caches = Confirm.ask(
"[bold red]Do you want to delete ~/Library/Caches as well? (System caches)[/bold red]",
default=False
)

_run(
delete=not dry_run,
auto=auto,
Expand All @@ -527,9 +541,77 @@ def clean(
undo_mode=undo_mode,
dev_junk=dev_junk,
dev_junk_global=dev_junk_global,
clean_system_caches=clean_system_caches,
docker=docker,
)


# ══════════════════════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════════════════════
# UNINSTALL & DNS FLUSH
# ══════════════════════════════════════════════════════════════════════════════

@main.command()
@click.argument("app_name")
@click.option("--auto", is_flag=True, default=False, help="Skip confirmation prompt")
@click.option("--no-undo", is_flag=True, default=False, help="Permanently delete instead of staging for undo")
@click.pass_context
def uninstall(ctx: click.Context, app_name: str, auto: bool, no_undo: bool) -> None:
"""Deep uninstall an application and all its data."""
from core.uninstaller import find_app_candidates, build_uninstall_plan, execute_uninstall
from core.scanner import discover_installed_apps
from core.undo import new_session
from rich.prompt import Confirm
from utils import bytes_human

apps = discover_installed_apps()
candidates = find_app_candidates(app_name, apps)
if not candidates:
console.print(f"[red]Could not find an installed app matching '{app_name}'.[/red]")
return

app = candidates[0]
if len(candidates) > 1:
console.print(f"[yellow]Multiple apps matched. Selecting {app.name} ({app.bundle_id}).[/yellow]")

plan = build_uninstall_plan(app)

console.print(f"\n[bold red]Uninstall Plan for {app.name}[/bold red]")
console.print(f"Total size: {bytes_human(plan.total_size)}")
for item in plan.deletable_items:
console.print(f" [dim]- {item.path}[/dim] ([yellow]{bytes_human(item.size)}[/yellow])")

do_it = auto or Confirm.ask(f"\nUninstall {app.name} and delete {bytes_human(plan.total_size)}?", default=False)
if do_it:
cfg = load_config()
session = new_session() if (cfg.undo_mode and not no_undo) else None
res = execute_uninstall(plan, session=session)
console.print(f"\n[green]✓ Freed {bytes_human(res.bytes_freed)}. Removed {res.deleted} items. Staged {res.staged}.[/green]")

@main.command("flush-dns")
def flush_dns() -> None:
"""Flush DNS and network caches."""
from core.dns_cache import flush_dns_cache

console.print("Flushing DNS caches...")
res = flush_dns_cache()
if res.success:
console.print("[bold green]✓ DNS cache flushed successfully.[/bold green]")
else:
console.print("[bold red]✗ Failed to flush DNS cache.[/bold red]")

@main.command("tui")
def tui() -> None:
"""Launch the interactive Terminal User Interface."""
try:
import textual
except ImportError:
console.print("[red]Textual is not installed. Run 'poetry install' or install textual manually.[/red]")
return

from tui.app import run_tui
run_tui()

# ══════════════════════════════════════════════════════════════════════════════
# DEVELOPER JUNK
# ══════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -3622,14 +3704,15 @@ def cmd_schedule() -> None:

@cmd_schedule.command("install")
@click.option("--no-notify", is_flag=True, default=False)
@click.option("--clean", is_flag=True, default=False, help="Run background clean instead of just scan.")
@click.pass_context
def schedule_install(ctx: click.Context, no_notify: bool) -> None:
def schedule_install(ctx: click.Context, no_notify: bool, clean: 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)
ok, msg = install_schedule(notify=not no_notify, clean=clean)
color = "green" if ok else "red"
console.print(f"\n [{color}]{msg}[/{color}]\n")

Expand Down Expand Up @@ -3779,6 +3862,8 @@ def _run(
dev_junk_global: bool = False,
ci: bool = False,
threshold_mb: int = 0,
clean_system_caches: bool = False,
docker: bool = False,
) -> None:
"""Core scan + optional cleanup logic (shared by scan and clean)."""
# Local import to satisfy static analysis and avoid name-resolution issues.
Expand Down Expand Up @@ -3984,10 +4069,31 @@ def _run_step(description: str, fn):
except Exception as exc:
logger.debug("Failed to send notification: %s", exc)

if grand == 0:
if grand == 0 and not docker:
console.print("\n[bold green]✓ Your Mac is spotless! Nothing to clean.[/bold green]\n")
return

if docker:
from scanners.docker_cleaner import scan_docker_bloat, clean_docker_bloat
bloat = scan_docker_bloat()
if bloat:
console.print()
console.rule("[bold]Docker Junk", style="blue")
docker_total = sum(e.size for e in bloat)
console.print(f" [blue]Docker Bloat:[/blue] {bytes_human(docker_total)}")

do_del = auto
if not do_del:
from rich.prompt import Confirm
do_del = Confirm.ask(
f" Delete Docker bloat ([yellow]{bytes_human(docker_total)}[/yellow])?",
default=False,
)
if do_del:
freed_docker = clean_docker_bloat()
console.print(f" [green]✓ Freed {bytes_human(freed_docker)} from Docker[/green]")
grand += freed_docker

# Diff hint
try:
from config.history import list_history, diff_scans
Expand Down Expand Up @@ -4035,7 +4141,11 @@ def _run_step(description: str, fn):
if ok:
freed += sz

user_junk = [j for j in junk if not j.is_system]
if clean_system_caches:
user_junk = junk
else:
user_junk = [j for j in junk if not j.is_system]

if user_junk:
do_del = auto
if not do_del:
Expand Down Expand Up @@ -4087,7 +4197,7 @@ def _run_step(description: str, fn):
f"[dim]Restore with: mac-cleaner undo --session {session.session_id[:8]}[/dim]"
)
else:
freed = do_cleanup(orphans, junk, auto=auto)
freed = do_cleanup(orphans, junk, auto=auto, clean_system_caches=clean_system_caches)
if dev_junk_entries:
from rich.prompt import Confirm
do_del = auto or Confirm.ask(
Expand Down
Loading
Loading