Files
SysDevCleaner/sysdevcleaner/core/cleaner.py
HeagBoKaT b01cf37e66 Первый выпуск 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>
2026-09-11 14:55:00 +03:00

252 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Выполнение очистки: удаление выбранных находок с журналом и защитой."""
from __future__ import annotations
import fnmatch
import json
import os
import time
from dataclasses import dataclass, field
from typing import Callable, Iterable
from .model import Finding
from .util import Cancel, clear_contents, delete_path, human, is_safe_target
@dataclass
class CleanOptions:
to_recycle_bin: bool = True # False = удалять безвозвратно
dry_run: bool = False
stop_on_error: bool = False
journal_dir: str = ""
@dataclass
class ItemResult:
finding: Finding
ok: bool
freed: int = 0
removed: int = 0
skipped: int = 0
message: str = ""
@dataclass
class CleanResult:
items: list[ItemResult] = field(default_factory=list)
started: float = 0.0
finished: float = 0.0
cancelled: bool = False
journal_path: str = ""
@property
def freed(self) -> int:
return sum(i.freed for i in self.items)
@property
def ok_count(self) -> int:
return sum(1 for i in self.items if i.ok)
@property
def fail_count(self) -> int:
return sum(1 for i in self.items if not i.ok)
def summary(self) -> str:
dur = max(0.0, (self.finished or time.time()) - self.started)
head = (f"Освобождено {human(self.freed)} · успешно {self.ok_count} · "
f"с ошибками {self.fail_count} · {dur:.0f} с")
if self.cancelled:
head = "ОТМЕНЕНО. " + head
return head
def _free_space_bytes(paths: Iterable[str]) -> dict[str, int]:
import shutil
out: dict[str, int] = {}
for p in paths:
drive = os.path.splitdrive(os.path.abspath(p))[0] or "C:"
root = drive + "\\"
if root in out:
continue
try:
out[root] = shutil.disk_usage(root).free
except OSError:
continue
return out
def _delete_masked_files(path: str, masks: list[str], recycle: bool) -> tuple[int, int, int, list[str]]:
"""Удалить в каталоге только файлы, подходящие под маски."""
freed = removed = skipped = 0
errs: list[str] = []
try:
entries = list(os.scandir(path))
except OSError as e:
return 0, 0, 0, [str(e)]
for e in entries:
try:
if not e.is_file(follow_symlinks=False):
continue
if not any(fnmatch.fnmatch(e.name.lower(), m.lower()) for m in masks):
continue
size = e.stat(follow_symlinks=False).st_size
except OSError:
continue
ok, err = delete_path(e.path, recycle)
if ok:
removed += 1
freed += size
else:
skipped += 1
if err and len(errs) < 10:
errs.append(f"{e.name}: {err}")
return freed, removed, skipped, errs
def clean(
findings: Iterable[Finding],
opts: CleanOptions,
cancel: Cancel | None = None,
progress: Callable[[str, float], None] | None = None,
) -> CleanResult:
"""Удалить выбранные находки. Возвращает подробный результат по каждой."""
items = list(findings)
res = CleanResult(started=time.time())
total = max(1, len(items))
before = _free_space_bytes(f.path for f in items)
journal: list[dict] = []
for i, f in enumerate(items):
if cancel is not None and cancel.cancelled:
res.cancelled = True
break
if progress is not None:
progress(f"{f.title} — {human(f.size)}", i / total)
safe, why = is_safe_target(f.path)
if not f.delete_contents_only and not safe:
res.items.append(ItemResult(f, False, message=f"пропущено: {why}"))
continue
if not os.path.exists(f.path):
res.items.append(ItemResult(f, True, message="уже отсутствует"))
continue
if opts.dry_run:
res.items.append(ItemResult(f, True, freed=f.size, removed=f.files,
message="пробный запуск — ничего не удалено"))
continue
masks = f.extra.get("file_masks") or []
try:
if f.members: # группа дубликатов
freed = removed = skipped = 0
errs: list[str] = []
unit = int(f.extra.get("unit") or 0)
for member in f.members:
ok, err = delete_path(member, opts.to_recycle_bin)
if ok:
removed += 1
freed += unit
else:
skipped += 1
if err:
errs.append(f"{os.path.basename(member)}: {err}")
item = ItemResult(f, skipped == 0, freed, removed, skipped,
"; ".join(errs[:3]))
elif masks:
freed, removed, skipped, errs = _delete_masked_files(
f.path, masks, opts.to_recycle_bin)
item = ItemResult(f, skipped == 0, freed, removed, skipped,
"; ".join(errs[:3]))
elif f.delete_contents_only:
done, skipped, errs = clear_contents(f.path, opts.to_recycle_bin)
# оцениваем освобождённое по доле удалённых объектов
share = done / max(1, done + skipped)
item = ItemResult(f, skipped == 0, int(f.size * share), done, skipped,
"; ".join(errs[:3]))
else:
ok, err = delete_path(f.path, opts.to_recycle_bin)
item = ItemResult(f, ok, f.size if ok else 0, 1 if ok else 0,
0 if ok else 1, err)
except Exception as e: # noqa: BLE001
item = ItemResult(f, False, message=str(e))
res.items.append(item)
journal.append({
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"path": f.path,
"title": f.title,
"category": f.category.label,
"risk": f.risk.label,
"mode": ("содержимое" if f.delete_contents_only else
"копии" if f.members else "целиком"),
"recycle_bin": opts.to_recycle_bin,
"ok": item.ok,
"freed": item.freed,
"removed": item.removed,
"skipped": item.skipped,
"message": item.message,
})
if opts.stop_on_error and not item.ok:
break
res.finished = time.time()
# уточняем реально освобождённое место по свободному объёму дисков
after = _free_space_bytes(f.path for f in items)
res_real = sum(max(0, after.get(k, 0) - v) for k, v in before.items())
if opts.dry_run:
res_real = 0
if journal and not opts.dry_run:
res.journal_path = _write_journal(journal, opts, res, res_real)
if progress is not None:
progress("Очистка завершена", 1.0)
return res
def _write_journal(entries: list[dict], opts: CleanOptions,
res: CleanResult, real_freed: int) -> str:
base = opts.journal_dir or os.path.join(
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), "SysDevCleaner", "journal")
try:
os.makedirs(base, exist_ok=True)
name = time.strftime("clean-%Y%m%d-%H%M%S.json")
path = os.path.join(base, name)
with open(path, "w", encoding="utf-8") as fh:
json.dump({
"started": res.started,
"finished": res.finished,
"to_recycle_bin": opts.to_recycle_bin,
"estimated_freed": res.freed,
"disk_delta": real_freed,
"items": entries,
}, fh, ensure_ascii=False, indent=2)
return path
except OSError:
return ""
def list_journals(limit: int = 30) -> list[tuple[str, str, int]]:
"""Последние журналы очистки: (путь, дата, освобождено)."""
base = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
"SysDevCleaner", "journal")
if not os.path.isdir(base):
return []
out: list[tuple[str, str, int]] = []
try:
names = sorted(os.listdir(base), reverse=True)[:limit]
except OSError:
return []
for n in names:
p = os.path.join(base, n)
try:
with open(p, encoding="utf-8") as fh:
data = json.load(fh)
out.append((p, time.strftime("%d.%m.%Y %H:%M",
time.localtime(data.get("started", 0))),
int(data.get("estimated_freed", 0))))
except Exception:
continue
return out