Первый выпуск SysDevCleaner

Анализ Windows и очистка кешей, мусора и остатков разработки.

Ядро:
- каталог правил: 228 правил путей (496 шаблонов) + 43 правила артефактов
  проектов — Windows, браузеры, кеши сборки, IDE, приложения, пакетные
  менеджеры;
- рекурсивный поиск node_modules, target, build, __pycache__, .venv и т.п.
  с проверкой маркеров проекта, чтобы не ловить ложные срабатывания;
- поиск крупных файлов и дубликатов (хеш по началу/середине/концу и полная
  сверка), инвентаризация установленных программ через реестр;
- ограничение области сканирования по дискам;
- рекомендации и текстовый отчёт.

Безопасность:
- четыре уровня риска, автоматически отмечается только «Безопасно»;
- список защищённых путей проверяется и при выборе, и перед удалением;
- для каталогов вроде %TEMP% удаляется содержимое, а не сам каталог;
- удаление в Корзину по умолчанию, пробный запуск, журнал очисток.

Интерфейс на Dear PyGui:
- JetBrains Mono Nerd Font: кириллица и иконки в одном шрифте;
- тёмная и светлая темы, адаптивная раскладка;
- заголовок окна ставится через SetWindowTextW — Dear PyGui отдаёт его
  в ANSI-функцию как UTF-8 и портит кириллицу;
- запуск всегда с правами администратора: манифест UAC в exe и
  самоперезапуск через ShellExecuteW при работе из исходников.

Сборка в один exe через PyInstaller со вшитым шрифтом и значком.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:55:00 +03:00
commit b01cf37e66
33 changed files with 6472 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
"""Рекурсивный поиск артефактов сборки и зависимостей внутри проектов."""
from __future__ import annotations
import fnmatch
import os
from typing import Callable, Iterable
from .model import Category, Finding
from .rules import NEVER_DESCEND, PROJECT_RULES, SKIP_DIRS, ProjectRule
from .util import Cancel, dir_stats, human
# имя каталога -> список правил с таким именем
_BY_NAME: dict[str, list[ProjectRule]] = {}
for _r in PROJECT_RULES:
_BY_NAME.setdefault(_r.name.lower(), []).append(_r)
# файлы-маркеры, по которым каталог считается корнем проекта
PROJECT_MARKERS = (
"package.json", "pyproject.toml", "setup.py", "requirements.txt", "Cargo.toml",
"go.mod", "pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "Gemfile",
"pubspec.yaml", "CMakeLists.txt", "Makefile", ".git", "*.sln", "*.csproj",
"ProjectSettings", "*.uproject",
)
def _matches_marker(entries: set[str], markers: Iterable[str]) -> bool:
"""Есть ли рядом файл, подтверждающий тип проекта."""
for m in markers:
if "*" in m or "?" in m:
if any(fnmatch.fnmatch(e, m.lower()) for e in entries):
return True
elif m.lower() in entries:
return True
return False
def _has_inner(path: str, inner: Iterable[str]) -> bool:
"""Есть ли внутри каталога подтверждающий файл (pyvenv.cfg и т.п.)."""
try:
names = {e.name.lower() for e in os.scandir(path)}
except (OSError, ValueError):
return False
return any(m.lower() in names for m in inner)
def scan_projects(
roots: Iterable[str],
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
max_depth: int = 9,
min_size: int = 1024 * 1024,
include_hidden: bool = False,
) -> list[Finding]:
"""Обойти ``roots`` и найти node_modules, target, build, __pycache__ и т.п.
Найденный артефакт не раскрывается вглубь — его размер считается целиком,
а вложенные артефакты не дублируются в отчёте.
"""
findings: list[Finding] = []
seen: set[str] = set()
scanned = 0
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack: list[tuple[str, int]] = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return findings
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
scanned += 1
if progress is not None and scanned % 200 == 0:
progress(cur)
names = {e.name.lower() for e in entries}
subdirs: list[os.DirEntry] = []
for e in entries:
try:
if not e.is_dir(follow_symlinks=False):
continue
except OSError:
continue
subdirs.append(e)
for e in subdirs:
low = e.name.lower()
if low in SKIP_DIRS:
continue
if not include_hidden and low.startswith(".") and low not in _BY_NAME:
# скрытые каталоги без собственного правила пропускаем
continue
rules = _BY_NAME.get(low)
matched: ProjectRule | None = None
if rules:
for rule in rules:
if rule.marker and not _matches_marker(names, rule.marker):
continue
if rule.inner and not _has_inner(e.path, rule.inner):
continue
matched = rule
break
if matched is not None:
key = os.path.normcase(e.path)
if key in seen:
continue
seen.add(key)
size, nfiles, ndirs, mtime = dir_stats(e.path, cancel)
if size < min_size:
continue
project = os.path.basename(cur) or cur
findings.append(Finding(
path=e.path,
title=f"{matched.title} — {project}",
category=Category.DEV_PROJECT,
risk=matched.risk,
size=size, files=nfiles, dirs=ndirs, mtime=mtime,
detail=(f"{matched.detail}\nПроект: {cur}\n"
f"Экосистема: {matched.ecosystem or '—'} · "
f"{nfiles:,} файлов · {human(size)}".replace(",", " ")),
group=matched.title,
extra={"project": cur, "ecosystem": matched.ecosystem,
"artifact": matched.name},
))
if matched.stop:
continue # внутрь не идём
if low in NEVER_DESCEND:
continue # чужие зависимости — своих проектов внутри нет
stack.append((e.path, depth + 1))
findings.sort(key=lambda f: -f.size)
return findings
def find_project_roots(
roots: Iterable[str],
cancel: Cancel | None = None,
max_depth: int = 6,
) -> list[str]:
"""Быстро найти корни проектов — чтобы показать пользователю, где он программирует."""
found: list[str] = []
for root in roots:
root = os.path.abspath(root)
if not os.path.isdir(root):
continue
stack = [(root, 0)]
while stack:
if cancel is not None and cancel.cancelled:
return found
cur, depth = stack.pop()
if depth > max_depth:
continue
try:
entries = list(os.scandir(cur))
except (OSError, ValueError):
continue
names = {e.name.lower() for e in entries}
if _matches_marker(names, PROJECT_MARKERS):
found.append(cur)
continue # вложенные подпроекты не ищем
for e in entries:
try:
if e.is_dir(follow_symlinks=False) and e.name.lower() not in SKIP_DIRS \
and not e.name.startswith("."):
stack.append((e.path, depth + 1))
except OSError:
continue
return found
def summarize_by_ecosystem(findings: list[Finding]) -> dict[str, tuple[int, int]]:
"""{'JavaScript': (размер, количество)}"""
out: dict[str, tuple[int, int]] = {}
for f in findings:
eco = f.extra.get("ecosystem") or "Прочее"
size, cnt = out.get(eco, (0, 0))
out[eco] = (size + f.size, cnt + 1)
return dict(sorted(out.items(), key=lambda kv: -kv[1][0]))