Первый выпуск 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,328 @@
"""Движок сканирования: раскрывает правила в конкретные находки."""
from __future__ import annotations
import fnmatch
import glob
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Callable
from . import devscan, heavy, programs as progmod
from .advisor import build_recommendations
from .model import Category, Finding, Risk, ScanResult
from .rules import Rule, all_rules
from .util import (Cancel, disk_usage, drives, human, is_protected, known_paths,
running_processes)
Progress = Callable[[str, float], None] # (сообщение, доля 0..1)
@dataclass
class ScanOptions:
categories: set[Category] = field(default_factory=lambda: {
Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT,
Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS,
})
project_roots: list[str] = field(default_factory=list)
scan_big_files: bool = False
big_file_min: int = 200 * 1024 * 1024
scan_duplicates: bool = False
duplicate_min: int = 10 * 1024 * 1024
scan_programs: bool = True
measure_program_size: bool = True
min_finding_size: int = 64 * 1024
workers: int = 12
project_min_size: int = 1024 * 1024
drives: set[str] = field(default_factory=set)
"""Ограничение по дискам, например ``{"C:", "D:"}``. Пусто — искать везде.
Фильтр применяется и к правилам путей, и к поиску проектов, крупных файлов
и дубликатов, поэтому «просканировать только D:» действительно не трогает C:.
"""
def drive_of(path: str) -> str:
"""'D:\\Desktop\\x' -> 'D:'. Для UNC-путей вернёт сам сетевой корень."""
return os.path.splitdrive(os.path.abspath(path))[0].upper()
def normalize_drives(values) -> set[str]:
"""Привести 'd', 'D:', 'D:\\' к единому виду 'D:'."""
out: set[str] = set()
for v in values or ():
v = str(v).strip().upper().rstrip("\\/")
if not v:
continue
if not v.endswith(":"):
v += ":"
out.add(v)
return out
def on_selected_drive(path: str, drives: set[str]) -> bool:
return not drives or drive_of(path) in drives
def roots_for(opts: "ScanOptions") -> list[str]:
"""Где искать проекты с учётом выбранных дисков."""
roots = opts.project_roots or default_project_roots()
if not opts.drives:
return roots
kept = [r for r in roots if drive_of(r) in opts.drives]
# диск выбран, но ни одной папки на нём не настроено — берём диск целиком
for d in sorted(opts.drives):
if any(drive_of(r) == d for r in kept):
continue
root = d + "\\"
if os.path.isdir(root):
kept.append(root)
return kept
def default_project_roots() -> list[str]:
"""Разумные места, где обычно лежат проекты."""
k = known_paths()
home = k["HOME"]
candidates = [
os.path.join(home, "Documents"), os.path.join(home, "Desktop"),
os.path.join(home, "source"), os.path.join(home, "src"),
os.path.join(home, "Projects"), os.path.join(home, "projects"),
os.path.join(home, "dev"), os.path.join(home, "Dev"),
os.path.join(home, "repos"), os.path.join(home, "git"),
os.path.join(home, "code"), os.path.join(home, "work"),
]
roots = [p for p in candidates if os.path.isdir(p)]
for d in drives():
if os.path.normcase(d) == os.path.normcase(k["SYSTEMDRIVE"]):
continue
roots.append(d) # несистемные диски целиком
# убираем вложенные дубликаты
uniq: list[str] = []
for r in sorted(set(roots), key=len):
n = os.path.normcase(os.path.abspath(r))
if any(n.startswith(os.path.normcase(os.path.abspath(u)) + os.sep) for u in uniq):
continue
uniq.append(r)
return uniq
# ------------------------------------------------------- раскрытие правил ---
def _expand(pattern: str, kp: dict[str, str]) -> list[str]:
try:
raw = pattern.format(**kp)
except (KeyError, IndexError, ValueError):
return []
if any(ch in raw for ch in "*?["):
try:
return glob.glob(raw)
except OSError:
return []
return [raw] if os.path.exists(raw) else []
def _measure(rule: Rule, path: str, cancel: Cancel, procs: set[str]) -> Finding | None:
from .util import dir_stats, path_size
if not os.path.exists(path):
return None
if rule.contents_only and is_protected(path):
# чистим только содержимое — сам защищённый каталог не трогаем, это допустимо
pass
elif is_protected(path):
return None
if rule.files_only:
total = 0
newest = 0.0
count = 0
try:
for e in os.scandir(path):
try:
if not e.is_file(follow_symlinks=False):
continue
if not any(fnmatch.fnmatch(e.name.lower(), m.lower())
for m in rule.files_only):
continue
st = e.stat(follow_symlinks=False)
except OSError:
continue
total += st.st_size
count += 1
newest = max(newest, st.st_mtime)
except OSError:
return None
size, files, dirs, mtime = total, count, 0, newest
elif os.path.isdir(path):
size, files, dirs, mtime = dir_stats(path, cancel)
else:
size, files, dirs, mtime = path_size(path, cancel)
if size < max(rule.min_size, 1):
return None
busy = [p for p in rule.processes if p.lower() in procs]
detail = rule.detail
if busy:
detail += ("\n\n⚠ Сейчас запущено: " + ", ".join(busy)
+ ". Закройте программу, иначе часть файлов не удалится.")
if rule.needs_admin:
detail += "\n\nТребуются права администратора."
return Finding(
path=path,
title=rule.title,
category=rule.category,
risk=rule.risk,
size=size, files=files, dirs=dirs, mtime=mtime,
detail=detail,
group=rule.id,
is_dir=os.path.isdir(path),
delete_contents_only=rule.contents_only,
extra={"rule": rule.id, "needs_admin": rule.needs_admin,
"busy": busy, "file_masks": list(rule.files_only)},
)
def scan_rules(
opts: ScanOptions,
cancel: Cancel,
progress: Progress | None = None,
) -> tuple[list[Finding], list[str]]:
kp = known_paths()
procs = running_processes()
rules = [r for r in all_rules() if r.category in opts.categories]
jobs: list[tuple[Rule, str]] = []
for rule in rules:
for pattern in rule.paths:
for path in _expand(pattern, kp):
jobs.append((rule, path))
# дедупликация путей (одно правило может дать один и тот же путь дважды)
seen: set[tuple[str, str]] = set()
uniq_jobs = []
for rule, path in jobs:
if not on_selected_drive(path, opts.drives):
continue
key = (rule.id, os.path.normcase(os.path.abspath(path)))
if key in seen:
continue
seen.add(key)
uniq_jobs.append((rule, path))
findings: list[Finding] = []
errors: list[str] = []
total = max(1, len(uniq_jobs))
done = 0
with ThreadPoolExecutor(max_workers=opts.workers) as pool:
futures = {pool.submit(_measure, r, p, cancel, procs): (r, p) for r, p in uniq_jobs}
for fut in as_completed(futures):
done += 1
rule, path = futures[fut]
if progress is not None and done % 5 == 0:
progress(f"Проверяю: {rule.title}", done / total * 0.55)
if cancel.cancelled:
break
try:
f = fut.result()
except Exception as e: # noqa: BLE001
errors.append(f"{path}: {e}")
continue
if f is not None and f.size >= opts.min_finding_size:
findings.append(f)
# если одно правило дало несколько путей (профили браузера) — оставляем все,
# но убираем вложенные пути, чтобы не считать объём дважды
findings = _drop_nested(findings)
findings.sort(key=lambda f: -f.size)
return findings, errors
def _drop_nested(findings: list[Finding]) -> list[Finding]:
"""Убрать находки, лежащие внутри других находок того же правила."""
by_path = sorted(findings, key=lambda f: len(f.path))
kept: list[Finding] = []
roots: list[str] = []
for f in by_path:
n = os.path.normcase(os.path.abspath(f.path))
if any(n.startswith(r + os.sep) for r in roots):
continue
kept.append(f)
roots.append(n)
return kept
# ------------------------------------------------------------ полный скан ---
def full_scan(
opts: ScanOptions,
cancel: Cancel,
progress: Progress | None = None,
) -> ScanResult:
res = ScanResult(started=time.time())
def say(msg: str, frac: float) -> None:
if progress is not None:
progress(msg, min(1.0, max(0.0, frac)))
say("Собираю сведения о дисках…", 0.01)
res.disks = [disk_usage(d) for d in drives()
if on_selected_drive(d, opts.drives)]
say("Ищу кеши и мусор по каталогу правил…", 0.03)
findings, errors = scan_rules(opts, cancel, progress)
res.findings.extend(findings)
res.errors.extend(errors)
if cancel.cancelled:
res.finished = time.time()
return res
if Category.DEV_PROJECT in opts.categories:
roots = roots_for(opts)
say("Ищу артефакты сборки в проектах…", 0.58)
proj = devscan.scan_projects(
roots, cancel,
progress=lambda p: say(f"Проекты: {_short(p)}", 0.62),
min_size=opts.project_min_size,
)
res.findings.extend(proj)
if opts.scan_big_files and not cancel.cancelled:
roots = roots_for(opts)
say("Ищу крупные файлы…", 0.72)
res.findings.extend(heavy.scan_big_files(
roots, min_size=opts.big_file_min, cancel=cancel,
progress=lambda p: say(f"Крупные файлы: {_short(p)}", 0.76),
))
if opts.scan_duplicates and not cancel.cancelled:
roots = roots_for(opts)
say("Ищу дубликаты (это самая долгая часть)…", 0.80)
res.findings.extend(heavy.scan_duplicates(
roots, min_size=opts.duplicate_min, cancel=cancel,
progress=lambda p: say(f"Дубликаты: {_short(p)}", 0.85),
))
if opts.scan_programs and not cancel.cancelled:
say("Читаю список установленных программ…", 0.90)
res.programs = progmod.scan_programs(
opts.measure_program_size, cancel,
progress=lambda n: say(f"Программы: {n}", 0.93),
)
say("Формирую рекомендации…", 0.97)
res.recommendations = build_recommendations(res)
res.scanned_paths = len(res.findings)
res.finished = time.time()
say(f"Готово: найдено {human(res.total())} в {len(res.findings)} местах", 1.0)
return res
def _short(path: str, width: int = 60) -> str:
return path if len(path) <= width else "…" + path[-(width - 1):]