Первый выпуск 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:
1
sysdevcleaner/core/__init__.py
Normal file
1
sysdevcleaner/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Ядро SysDevCleaner."""
|
||||
336
sysdevcleaner/core/advisor.py
Normal file
336
sysdevcleaner/core/advisor.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Формирование рекомендаций по результатам сканирования."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
from .model import Category, Finding, Recommendation, Risk, ScanResult
|
||||
from .util import human
|
||||
|
||||
GB = 1024 ** 3
|
||||
MB = 1024 ** 2
|
||||
|
||||
|
||||
def _plural(n: int, one: str, few: str, many: str) -> str:
|
||||
n = abs(n) % 100
|
||||
if 11 <= n <= 14:
|
||||
return many
|
||||
n %= 10
|
||||
if n == 1:
|
||||
return one
|
||||
if 2 <= n <= 4:
|
||||
return few
|
||||
return many
|
||||
|
||||
|
||||
def build_recommendations(res: ScanResult) -> list[Recommendation]:
|
||||
recs: list[Recommendation] = []
|
||||
by_cat = res.by_category()
|
||||
|
||||
# --- 1. Свободное место на дисках --------------------------------------
|
||||
for d in res.disks:
|
||||
if not d["total"]:
|
||||
continue
|
||||
free_pct = d["free"] / d["total"] * 100
|
||||
if free_pct < 10:
|
||||
recs.append(Recommendation(
|
||||
f"Диск {d['root']} почти заполнен",
|
||||
f"Свободно {human(d['free'])} из {human(d['total'])} ({free_pct:.0f}%). "
|
||||
"Ниже 10% Windows начинает тормозить: не хватает места под файл подкачки, "
|
||||
"точки восстановления и обновления. Освободите хотя бы "
|
||||
f"{human(int(d['total'] * 0.15 - d['free']))}.",
|
||||
severity=2,
|
||||
))
|
||||
elif free_pct < 20:
|
||||
recs.append(Recommendation(
|
||||
f"На диске {d['root']} мало места",
|
||||
f"Свободно {human(d['free'])} ({free_pct:.0f}%). Запас меньше 20% — "
|
||||
"стоит почистить заранее.",
|
||||
severity=1,
|
||||
))
|
||||
|
||||
# --- 2. Быстрая победа: всё безопасное ---------------------------------
|
||||
safe = [f for f in res.findings if f.risk == Risk.SAFE]
|
||||
safe_total = sum(f.size for f in safe)
|
||||
if safe_total > 50 * MB:
|
||||
recs.append(Recommendation(
|
||||
"Безопасная очистка одним действием",
|
||||
f"{len(safe)} {_plural(len(safe), 'находка', 'находки', 'находок')} помечены как "
|
||||
f"полностью безопасные: временные файлы, кеши браузеров, дампы, эскизы. "
|
||||
f"Освободится {human(safe_total)}. Ничего из пользовательских данных "
|
||||
"не затрагивается, всё пересоздаётся системой автоматически.",
|
||||
gain=safe_total, severity=2, findings=safe,
|
||||
))
|
||||
|
||||
# --- 3. Разработка ------------------------------------------------------
|
||||
dev_cache = by_cat.get(Category.DEV_CACHE, [])
|
||||
dev_proj = by_cat.get(Category.DEV_PROJECT, [])
|
||||
dev_total = sum(f.size for f in dev_cache) + sum(f.size for f in dev_proj)
|
||||
if dev_total > 500 * MB:
|
||||
eco: dict[str, int] = defaultdict(int)
|
||||
for f in dev_proj:
|
||||
eco[f.extra.get("ecosystem") or "Прочее"] += f.size
|
||||
top = ", ".join(f"{k} — {human(v)}"
|
||||
for k, v in sorted(eco.items(), key=lambda kv: -kv[1])[:4])
|
||||
body = (f"Кеши инструментов ({human(sum(f.size for f in dev_cache))}) и артефакты "
|
||||
f"сборки внутри проектов ({human(sum(f.size for f in dev_proj))}) — "
|
||||
f"вместе {human(dev_total)}.")
|
||||
if top:
|
||||
body += f"\nПо экосистемам: {top}."
|
||||
body += ("\nВсё это восстанавливается командами установки зависимостей и сборки. "
|
||||
"Начните с проектов, которые давно не открывали.")
|
||||
recs.append(Recommendation("Разработка занимает больше всего места", body,
|
||||
gain=dev_total, severity=2,
|
||||
findings=dev_cache + dev_proj))
|
||||
|
||||
# --- 4. Заброшенные проекты --------------------------------------------
|
||||
stale = [f for f in dev_proj if f.age_days > 180 and f.size > 100 * MB]
|
||||
if stale:
|
||||
stale.sort(key=lambda f: -f.size)
|
||||
gain = sum(f.size for f in stale)
|
||||
listing = "\n".join(
|
||||
f" • {human(f.size):>9} {f.extra.get('project', f.path)} "
|
||||
f"(не менялся {int(f.age_days)} дн.)" for f in stale[:8])
|
||||
recs.append(Recommendation(
|
||||
"Артефакты в заброшенных проектах",
|
||||
f"{len(stale)} {_plural(len(stale), 'каталог', 'каталога', 'каталогов')} "
|
||||
f"артефактов в проектах, к которым не притрагивались больше полугода — "
|
||||
f"{human(gain)}.\n{listing}",
|
||||
gain=gain, severity=2, findings=stale,
|
||||
))
|
||||
|
||||
# --- 5. Дублирующиеся node_modules -------------------------------------
|
||||
nm = [f for f in dev_proj if f.extra.get("artifact") == "node_modules"]
|
||||
if len(nm) >= 5:
|
||||
gain = sum(f.size for f in nm)
|
||||
recs.append(Recommendation(
|
||||
"Много копий node_modules",
|
||||
f"Найдено {len(nm)} каталогов node_modules общим объёмом {human(gain)}. "
|
||||
"Если переключиться на pnpm, пакеты будут храниться в одном месте и "
|
||||
"переиспользоваться между проектами — экономия обычно в разы.",
|
||||
gain=gain, severity=1, findings=nm,
|
||||
))
|
||||
|
||||
# --- 6. Браузеры --------------------------------------------------------
|
||||
br = by_cat.get(Category.BROWSERS, [])
|
||||
br_total = sum(f.size for f in br)
|
||||
if br_total > 500 * MB:
|
||||
busy = sorted({p for f in br for p in f.extra.get("busy", [])})
|
||||
body = f"Кеши браузеров занимают {human(br_total)}. Удаление не трогает пароли, " \
|
||||
"закладки и историю — только временные копии страниц."
|
||||
if busy:
|
||||
body += f"\n⚠ Сейчас запущены: {', '.join(busy)}. Закройте браузер перед очисткой, " \
|
||||
"иначе часть файлов останется заблокированной."
|
||||
recs.append(Recommendation("Кеши браузеров разрослись", body,
|
||||
gain=br_total, severity=1, findings=br))
|
||||
|
||||
# --- 7. Крупные программы, которыми не пользуются -----------------------
|
||||
idle = [p for p in res.programs
|
||||
if p.best_size > 500 * MB and 0 < p.idle_days > 365]
|
||||
if idle:
|
||||
idle.sort(key=lambda p: -p.best_size)
|
||||
gain = sum(p.best_size for p in idle)
|
||||
listing = "\n".join(
|
||||
f" • {human(p.best_size):>9} {p.name} "
|
||||
f"(файлы не менялись {int(p.idle_days)} дн.)" for p in idle[:8])
|
||||
recs.append(Recommendation(
|
||||
"Тяжёлые программы без признаков использования",
|
||||
f"{len(idle)} {_plural(len(idle), 'программа', 'программы', 'программ')} "
|
||||
f"весом от 500 МБ, файлы которых не изменялись больше года — {human(gain)}.\n"
|
||||
f"{listing}\nПроверьте список и удалите ненужное через вкладку «Программы».",
|
||||
gain=gain, severity=1,
|
||||
))
|
||||
|
||||
# --- 8. Самые тяжёлые программы ----------------------------------------
|
||||
if res.programs:
|
||||
top = [p for p in res.programs if p.best_size > 0][:5]
|
||||
if top and top[0].best_size > GB:
|
||||
listing = "\n".join(f" • {human(p.best_size):>9} {p.name}" for p in top)
|
||||
recs.append(Recommendation(
|
||||
"Самые тяжёлые установленные программы",
|
||||
f"Установлено {len(res.programs)} программ. Крупнейшие:\n{listing}",
|
||||
severity=0,
|
||||
))
|
||||
|
||||
# --- 9. Windows.old и обновления ---------------------------------------
|
||||
winold = [f for f in res.findings if f.extra.get("rule") == "win.old"]
|
||||
if winold:
|
||||
gain = sum(f.size for f in winold)
|
||||
recs.append(Recommendation(
|
||||
"Осталась предыдущая версия Windows",
|
||||
f"Каталог Windows.old занимает {human(gain)}. Он нужен только для отката "
|
||||
"на прошлую сборку Windows. Если система работает стабильно несколько недель — "
|
||||
"каталог можно удалить.",
|
||||
gain=gain, severity=2, findings=winold,
|
||||
))
|
||||
|
||||
# --- 10. Дубликаты ------------------------------------------------------
|
||||
dups = by_cat.get(Category.DUPLICATES, [])
|
||||
if dups:
|
||||
gain = sum(f.size for f in dups)
|
||||
recs.append(Recommendation(
|
||||
"Найдены одинаковые файлы",
|
||||
f"{len(dups)} {_plural(len(dups), 'группа', 'группы', 'групп')} файлов с "
|
||||
f"идентичным содержимым — {human(gain)} потрачено впустую. "
|
||||
"Приложение предлагает удалить все копии кроме одной, но проверьте список: "
|
||||
"иногда копии лежат в разных проектах намеренно.",
|
||||
gain=gain, severity=1, findings=dups,
|
||||
))
|
||||
|
||||
# --- 11. Крупные файлы --------------------------------------------------
|
||||
bigs = by_cat.get(Category.BIG_FILES, [])
|
||||
if bigs:
|
||||
junk = [f for f in bigs if f.risk == Risk.MEDIUM]
|
||||
if junk:
|
||||
gain = sum(f.size for f in junk)
|
||||
listing = "\n".join(f" • {human(f.size):>9} {f.path}" for f in junk[:8])
|
||||
recs.append(Recommendation(
|
||||
"Крупные архивы, образы и установщики",
|
||||
f"{len(junk)} {_plural(len(junk), 'файл', 'файла', 'файлов')} на {human(gain)}: "
|
||||
f"ISO, архивы, установщики, дампы и бэкапы — обычно их скачивают один раз "
|
||||
f"и забывают.\n{listing}",
|
||||
gain=gain, severity=1, findings=junk,
|
||||
))
|
||||
|
||||
# --- 12. Риски ----------------------------------------------------------
|
||||
risky = [f for f in res.findings if f.risk >= Risk.MEDIUM]
|
||||
if risky:
|
||||
gain = sum(f.size for f in risky)
|
||||
recs.append(Recommendation(
|
||||
"Что требует вашего решения",
|
||||
f"{len(risky)} {_plural(len(risky), 'находка', 'находки', 'находок')} "
|
||||
f"({human(gain)}) помечены средним или высоким риском: виртуальные окружения, "
|
||||
"локальные репозитории пакетов, кеши Unity/Unreal, Windows.old. "
|
||||
"Они восстанавливаются, но на это уйдёт время, а иногда и трафик. "
|
||||
"Такие пункты никогда не отмечаются автоматически.",
|
||||
gain=gain, severity=0, findings=risky,
|
||||
))
|
||||
|
||||
# --- 13. Нет прав администратора ---------------------------------------
|
||||
need_admin = [f for f in res.findings if f.extra.get("needs_admin")]
|
||||
if need_admin:
|
||||
from .util import is_admin
|
||||
if not is_admin():
|
||||
gain = sum(f.size for f in need_admin)
|
||||
recs.append(Recommendation(
|
||||
"Часть находок требует прав администратора",
|
||||
f"{len(need_admin)} {_plural(len(need_admin), 'пункт', 'пункта', 'пунктов')} "
|
||||
f"({human(gain)}) — системные каталоги: Windows\\Temp, кеш обновлений, "
|
||||
"оптимизация доставки. Запустите приложение от имени администратора, "
|
||||
"чтобы очистить и их.",
|
||||
gain=gain, severity=1, findings=need_admin,
|
||||
))
|
||||
|
||||
recs.sort(key=lambda r: (-r.severity, -r.gain))
|
||||
return recs
|
||||
|
||||
|
||||
def default_selection(findings: list[Finding]) -> None:
|
||||
"""Отметить то, что безопасно чистить без раздумий."""
|
||||
for f in findings:
|
||||
f.selected = (
|
||||
f.risk == Risk.SAFE
|
||||
and f.category not in (Category.BIG_FILES, Category.DUPLICATES)
|
||||
and not f.extra.get("busy")
|
||||
)
|
||||
|
||||
|
||||
def plan_summary(selected: list[Finding]) -> str:
|
||||
"""Текст подтверждения перед очисткой."""
|
||||
if not selected:
|
||||
return "Ничего не выбрано."
|
||||
total = sum(f.size for f in selected)
|
||||
by_risk: dict[Risk, list[Finding]] = defaultdict(list)
|
||||
for f in selected:
|
||||
by_risk[f.risk].append(f)
|
||||
lines = [f"К удалению: {len(selected)} "
|
||||
f"{_plural(len(selected), 'объект', 'объекта', 'объектов')}, "
|
||||
f"освободится примерно {human(total)}.", ""]
|
||||
for risk in sorted(by_risk, reverse=True):
|
||||
items = by_risk[risk]
|
||||
lines.append(f"{risk.label}: {len(items)} — {human(sum(f.size for f in items))}")
|
||||
for f in sorted(items, key=lambda x: -x.size)[:6]:
|
||||
mode = "содержимое" if f.delete_contents_only else "целиком"
|
||||
lines.append(f" {human(f.size):>9} {f.title} ({mode})")
|
||||
if len(items) > 6:
|
||||
lines.append(f" … и ещё {len(items) - 6}")
|
||||
lines.append("")
|
||||
busy = sorted({p for f in selected for p in f.extra.get("busy", [])})
|
||||
if busy:
|
||||
lines.append(f"⚠ Запущены программы: {', '.join(busy)}. "
|
||||
"Часть файлов может остаться заблокированной.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def report_text(res: ScanResult) -> str:
|
||||
"""Полный текстовый отчёт — для сохранения в файл."""
|
||||
import time as _t
|
||||
L: list[str] = []
|
||||
L.append("=" * 78)
|
||||
L.append(" SysDevCleaner — отчёт об анализе системы")
|
||||
L.append(f" {_t.strftime('%d.%m.%Y %H:%M', _t.localtime(res.started))} "
|
||||
f"(сканирование заняло {res.duration:.0f} с)")
|
||||
L.append("=" * 78)
|
||||
L.append("")
|
||||
|
||||
L.append("ДИСКИ")
|
||||
for d in res.disks:
|
||||
if not d["total"]:
|
||||
continue
|
||||
bar_len = 30
|
||||
filled = int(d["percent"] / 100 * bar_len)
|
||||
bar = "█" * filled + "·" * (bar_len - filled)
|
||||
L.append(f" {d['root']:<4} [{bar}] занято {d['percent']:.0f}% "
|
||||
f"свободно {human(d['free'])} из {human(d['total'])}")
|
||||
L.append("")
|
||||
|
||||
L.append(f"ИТОГО НАЙДЕНО: {human(res.total())} в {len(res.findings)} местах")
|
||||
L.append("")
|
||||
|
||||
L.append("ПО КАТЕГОРИЯМ")
|
||||
for cat, items in sorted(res.by_category().items(),
|
||||
key=lambda kv: -sum(f.size for f in kv[1])):
|
||||
L.append(f" {cat.icon} {cat.label:<28} {human(sum(f.size for f in items)):>10} "
|
||||
f"({len(items)})")
|
||||
L.append("")
|
||||
|
||||
if res.recommendations:
|
||||
L.append("РЕКОМЕНДАЦИИ")
|
||||
for i, r in enumerate(res.recommendations, 1):
|
||||
mark = {0: "i", 1: "•", 2: "!"}[r.severity]
|
||||
head = f" [{mark}] {i}. {r.title}"
|
||||
if r.gain:
|
||||
head += f" — {human(r.gain)}"
|
||||
L.append(head)
|
||||
for line in r.body.splitlines():
|
||||
L.append(f" {line}")
|
||||
L.append("")
|
||||
|
||||
L.append("ПОДРОБНО")
|
||||
for cat, items in sorted(res.by_category().items(),
|
||||
key=lambda kv: -sum(f.size for f in kv[1])):
|
||||
L.append("")
|
||||
L.append(f" {cat.icon} {cat.label} — {human(sum(f.size for f in items))}")
|
||||
L.append(" " + "-" * 74)
|
||||
for f in items[:60]:
|
||||
L.append(f" {human(f.size):>10} [{f.risk.label:<14}] {f.title}")
|
||||
L.append(f" {f.path}")
|
||||
if len(items) > 60:
|
||||
L.append(f" … и ещё {len(items) - 60} пунктов")
|
||||
|
||||
if res.programs:
|
||||
L.append("")
|
||||
L.append("УСТАНОВЛЕННЫЕ ПРОГРАММЫ (топ-40 по размеру)")
|
||||
L.append(" " + "-" * 74)
|
||||
for p in res.programs[:40]:
|
||||
idle = f"{int(p.idle_days)} дн." if p.idle_days >= 0 else "—"
|
||||
L.append(f" {human(p.best_size):>10} {p.name[:46]:<46} простой: {idle}")
|
||||
|
||||
if res.errors:
|
||||
L.append("")
|
||||
L.append(f"ОШИБКИ ДОСТУПА: {len(res.errors)} (обычно это занятые или "
|
||||
"защищённые системой файлы)")
|
||||
for e in res.errors[:20]:
|
||||
L.append(f" {e}")
|
||||
|
||||
return "\n".join(L)
|
||||
251
sysdevcleaner/core/cleaner.py
Normal file
251
sysdevcleaner/core/cleaner.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Выполнение очистки: удаление выбранных находок с журналом и защитой."""
|
||||
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
|
||||
188
sysdevcleaner/core/devscan.py
Normal file
188
sysdevcleaner/core/devscan.py
Normal 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]))
|
||||
220
sysdevcleaner/core/heavy.py
Normal file
220
sysdevcleaner/core/heavy.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Поиск крупных файлов и дубликатов."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from .model import Category, Finding, Risk
|
||||
from .rules import SKIP_DIRS
|
||||
from .util import Cancel, human
|
||||
|
||||
# Расширения, которые почти всегда — «тяжёлый мусор, о котором забыли»
|
||||
JUNKY_EXT = {
|
||||
".iso": "образ диска", ".img": "образ диска", ".vhd": "виртуальный диск",
|
||||
".vhdx": "виртуальный диск", ".vmdk": "виртуальный диск",
|
||||
".zip": "архив", ".rar": "архив", ".7z": "архив", ".tar": "архив", ".gz": "архив",
|
||||
".msi": "установщик", ".exe": "установщик/программа", ".dmg": "образ",
|
||||
".bak": "резервная копия", ".old": "старая версия", ".tmp": "временный файл",
|
||||
".dmp": "дамп памяти", ".log": "журнал", ".pkg": "пакет",
|
||||
}
|
||||
|
||||
MEDIA_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".psd", ".ai", ".raw",
|
||||
".cr2", ".nef", ".wav", ".flac", ".prproj", ".aep"}
|
||||
|
||||
|
||||
def scan_big_files(
|
||||
roots: Iterable[str],
|
||||
min_size: int = 100 * 1024 * 1024,
|
||||
limit: int = 400,
|
||||
cancel: Cancel | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
max_depth: int = 12,
|
||||
) -> list[Finding]:
|
||||
"""Найти файлы крупнее ``min_size``. Ничего не предлагает удалять автоматически."""
|
||||
out: list[Finding] = []
|
||||
scanned = 0
|
||||
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 _trim(out, limit)
|
||||
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 % 300 == 0:
|
||||
progress(cur)
|
||||
for e in entries:
|
||||
try:
|
||||
if e.is_dir(follow_symlinks=False):
|
||||
if e.name.lower() not in SKIP_DIRS:
|
||||
stack.append((e.path, depth + 1))
|
||||
continue
|
||||
if not e.is_file(follow_symlinks=False):
|
||||
continue
|
||||
st = e.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if st.st_size < min_size:
|
||||
continue
|
||||
ext = os.path.splitext(e.name)[1].lower()
|
||||
kind = JUNKY_EXT.get(ext)
|
||||
risk = Risk.MEDIUM if kind else Risk.HIGH
|
||||
note = f"Тип: {kind}. " if kind else ""
|
||||
if ext in MEDIA_EXT:
|
||||
note = "Медиафайл или исходник проекта. "
|
||||
out.append(Finding(
|
||||
path=e.path,
|
||||
title=e.name,
|
||||
category=Category.BIG_FILES,
|
||||
risk=risk,
|
||||
size=st.st_size, files=1, mtime=st.st_mtime,
|
||||
detail=(f"{note}{human(st.st_size)}\nПапка: {cur}\n"
|
||||
"Решение об удалении принимаете вы — приложение ничего "
|
||||
"не отмечает здесь автоматически."),
|
||||
group=kind or "файл",
|
||||
is_dir=False,
|
||||
))
|
||||
return _trim(out, limit)
|
||||
|
||||
|
||||
def _trim(items: list[Finding], limit: int) -> list[Finding]:
|
||||
items.sort(key=lambda f: -f.size)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- дубликаты ----
|
||||
|
||||
|
||||
def _quick_hash(path: str, size: int, chunk: int = 65536) -> str | None:
|
||||
"""Хеш по началу, середине и концу файла — быстрый предварительный фильтр."""
|
||||
try:
|
||||
h = hashlib.blake2b(digest_size=16)
|
||||
with open(path, "rb") as fh:
|
||||
h.update(fh.read(chunk))
|
||||
if size > chunk * 3:
|
||||
fh.seek(size // 2)
|
||||
h.update(fh.read(chunk))
|
||||
fh.seek(max(0, size - chunk))
|
||||
h.update(fh.read(chunk))
|
||||
h.update(str(size).encode())
|
||||
return h.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _full_hash(path: str) -> str | None:
|
||||
try:
|
||||
h = hashlib.blake2b(digest_size=16)
|
||||
with open(path, "rb") as fh:
|
||||
for block in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def scan_duplicates(
|
||||
roots: Iterable[str],
|
||||
min_size: int = 5 * 1024 * 1024,
|
||||
cancel: Cancel | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
max_files: int = 200_000,
|
||||
verify_full: bool = True,
|
||||
max_depth: int = 12,
|
||||
) -> list[Finding]:
|
||||
"""Найти одинаковые файлы: группировка по размеру → быстрый хеш → полный хеш."""
|
||||
by_size: dict[int, list[str]] = defaultdict(list)
|
||||
count = 0
|
||||
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 []
|
||||
cur, depth = stack.pop()
|
||||
if depth > max_depth:
|
||||
continue
|
||||
try:
|
||||
entries = list(os.scandir(cur))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for e in entries:
|
||||
try:
|
||||
if e.is_dir(follow_symlinks=False):
|
||||
if e.name.lower() not in SKIP_DIRS:
|
||||
stack.append((e.path, depth + 1))
|
||||
continue
|
||||
if not e.is_file(follow_symlinks=False):
|
||||
continue
|
||||
st = e.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if st.st_size < min_size:
|
||||
continue
|
||||
by_size[st.st_size].append(e.path)
|
||||
count += 1
|
||||
if count >= max_files:
|
||||
stack.clear()
|
||||
break
|
||||
if progress is not None and count % 500 < 2:
|
||||
progress(cur)
|
||||
|
||||
groups: list[Finding] = []
|
||||
for size, paths in by_size.items():
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
if cancel is not None and cancel.cancelled:
|
||||
break
|
||||
buckets: dict[str, list[str]] = defaultdict(list)
|
||||
for p in paths:
|
||||
qh = _quick_hash(p, size)
|
||||
if qh:
|
||||
buckets[qh].append(p)
|
||||
for qh, group in buckets.items():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
if verify_full and size <= 512 * 1024 * 1024:
|
||||
confirmed: dict[str, list[str]] = defaultdict(list)
|
||||
for p in group:
|
||||
fh = _full_hash(p)
|
||||
if fh:
|
||||
confirmed[fh].append(p)
|
||||
final_groups = [g for g in confirmed.values() if len(g) > 1]
|
||||
else:
|
||||
final_groups = [group]
|
||||
|
||||
for g in final_groups:
|
||||
g.sort(key=lambda p: (len(p), p))
|
||||
wasted = size * (len(g) - 1)
|
||||
try:
|
||||
mtime = os.stat(g[0]).st_mtime
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
groups.append(Finding(
|
||||
path=g[1] if len(g) > 1 else g[0], # по умолчанию «лишняя» копия
|
||||
title=f"{os.path.basename(g[0])} × {len(g)}",
|
||||
category=Category.DUPLICATES,
|
||||
risk=Risk.MEDIUM,
|
||||
size=wasted, files=len(g) - 1, mtime=mtime,
|
||||
detail=("Одинаковые по содержимому файлы ({} копий по {}).\n"
|
||||
"Оригинал (оставить): {}\nЛишние копии:\n {}".format(
|
||||
len(g), human(size), g[0], "\n ".join(g[1:]))),
|
||||
group="дубликаты",
|
||||
is_dir=False,
|
||||
members=g[1:],
|
||||
extra={"original": g[0], "all": g, "unit": size},
|
||||
))
|
||||
groups.sort(key=lambda f: -f.size)
|
||||
return groups[:300]
|
||||
172
sysdevcleaner/core/model.py
Normal file
172
sysdevcleaner/core/model.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Базовые структуры данных SysDevCleaner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class Risk(IntEnum):
|
||||
"""Насколько опасно удалять находку."""
|
||||
|
||||
SAFE = 0 # мусор в чистом виде, пересоздаётся сам
|
||||
LOW = 1 # кеш: удаление вызовет лишь замедление первого запуска
|
||||
MEDIUM = 2 # потеря состояния/настроек/сессий, нужна пересборка
|
||||
HIGH = 3 # можно сломать проект или систему — только вручную
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return {
|
||||
Risk.SAFE: "Безопасно",
|
||||
Risk.LOW: "Низкий риск",
|
||||
Risk.MEDIUM: "Средний риск",
|
||||
Risk.HIGH: "Высокий риск",
|
||||
}[self]
|
||||
|
||||
@property
|
||||
def color(self) -> str:
|
||||
return {
|
||||
Risk.SAFE: "#2e9e5b",
|
||||
Risk.LOW: "#4a90d9",
|
||||
Risk.MEDIUM: "#d99a2b",
|
||||
Risk.HIGH: "#d9534f",
|
||||
}[self]
|
||||
|
||||
|
||||
class Category(IntEnum):
|
||||
WINDOWS = 0
|
||||
BROWSERS = 1
|
||||
DEV_CACHE = 2
|
||||
DEV_PROJECT = 3
|
||||
APPS = 4
|
||||
LOGS = 5
|
||||
PACKAGE_MANAGERS = 6
|
||||
BIG_FILES = 7
|
||||
DUPLICATES = 8
|
||||
PROGRAMS = 9
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return {
|
||||
Category.WINDOWS: "Система Windows",
|
||||
Category.BROWSERS: "Браузеры",
|
||||
Category.DEV_CACHE: "Кеши разработки",
|
||||
Category.DEV_PROJECT: "Артефакты проектов",
|
||||
Category.APPS: "Приложения",
|
||||
Category.LOGS: "Логи и отчёты",
|
||||
Category.PACKAGE_MANAGERS: "Пакетные менеджеры",
|
||||
Category.BIG_FILES: "Крупные файлы",
|
||||
Category.DUPLICATES: "Дубликаты",
|
||||
Category.PROGRAMS: "Установленные программы",
|
||||
}[self]
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return {
|
||||
Category.WINDOWS: "🪟",
|
||||
Category.BROWSERS: "🌐",
|
||||
Category.DEV_CACHE: "⚙",
|
||||
Category.DEV_PROJECT: "📦",
|
||||
Category.APPS: "🎮",
|
||||
Category.LOGS: "📄",
|
||||
Category.PACKAGE_MANAGERS: "🧰",
|
||||
Category.BIG_FILES: "🐘",
|
||||
Category.DUPLICATES: "👯",
|
||||
Category.PROGRAMS: "🗂",
|
||||
}[self]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
"""Одна находка: путь (или группа путей), который можно удалить."""
|
||||
|
||||
path: str
|
||||
title: str # человекочитаемое имя
|
||||
category: Category
|
||||
risk: Risk
|
||||
size: int = 0 # байты
|
||||
files: int = 0
|
||||
dirs: int = 0
|
||||
mtime: float = 0.0 # последнее изменение (unix)
|
||||
detail: str = "" # подсказка что это и чем грозит удаление
|
||||
group: str = "" # имя правила/группы для сводки
|
||||
is_dir: bool = True
|
||||
delete_contents_only: bool = False # чистим содержимое, сам каталог оставляем
|
||||
members: list[str] = field(default_factory=list) # для дубликатов/мульти-путей
|
||||
selected: bool = False
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def age_days(self) -> float:
|
||||
if not self.mtime:
|
||||
return 0.0
|
||||
return max(0.0, (time.time() - self.mtime) / 86400.0)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return os.path.normcase(os.path.abspath(self.path))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Program:
|
||||
"""Установленная программа из реестра."""
|
||||
|
||||
name: str
|
||||
publisher: str = ""
|
||||
version: str = ""
|
||||
install_date: str = ""
|
||||
install_location: str = ""
|
||||
uninstall_cmd: str = ""
|
||||
registry_size: int = 0 # EstimatedSize из реестра
|
||||
disk_size: int = 0 # реально посчитанный размер
|
||||
last_used: float = 0.0 # mtime самого свежего файла
|
||||
source: str = "" # HKLM / HKCU / WOW64
|
||||
|
||||
@property
|
||||
def best_size(self) -> int:
|
||||
return self.disk_size or self.registry_size
|
||||
|
||||
@property
|
||||
def idle_days(self) -> float:
|
||||
if not self.last_used:
|
||||
return -1.0
|
||||
return max(0.0, (time.time() - self.last_used) / 86400.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recommendation:
|
||||
title: str
|
||||
body: str
|
||||
gain: int = 0 # сколько освободится, байт
|
||||
severity: int = 1 # 0 инфо, 1 совет, 2 важно
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
programs: list[Program] = field(default_factory=list)
|
||||
recommendations: list[Recommendation] = field(default_factory=list)
|
||||
disks: list[dict] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
started: float = 0.0
|
||||
finished: float = 0.0
|
||||
scanned_paths: int = 0
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, (self.finished or time.time()) - self.started)
|
||||
|
||||
def total(self, cats: Iterable[Category] | None = None) -> int:
|
||||
cs = set(cats) if cats else None
|
||||
return sum(f.size for f in self.findings if cs is None or f.category in cs)
|
||||
|
||||
def by_category(self) -> dict[Category, list[Finding]]:
|
||||
out: dict[Category, list[Finding]] = {}
|
||||
for f in self.findings:
|
||||
out.setdefault(f.category, []).append(f)
|
||||
for lst in out.values():
|
||||
lst.sort(key=lambda x: -x.size)
|
||||
return out
|
||||
185
sysdevcleaner/core/programs.py
Normal file
185
sysdevcleaner/core/programs.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Инвентаризация установленных программ: размер на диске, давность использования."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
from .model import Program
|
||||
from .util import Cancel, dir_stats, known_paths
|
||||
|
||||
_UNINSTALL_KEYS = (
|
||||
("HKLM", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM"),
|
||||
("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM-32"),
|
||||
("HKCU", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU"),
|
||||
)
|
||||
|
||||
|
||||
def _read_registry() -> list[Program]:
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
import winreg
|
||||
|
||||
roots = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER}
|
||||
out: list[Program] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
for root_name, subkey, label in _UNINSTALL_KEYS:
|
||||
try:
|
||||
key = winreg.OpenKey(roots[root_name], subkey, 0, winreg.KEY_READ)
|
||||
except OSError:
|
||||
continue
|
||||
with key:
|
||||
try:
|
||||
count = winreg.QueryInfoKey(key)[0]
|
||||
except OSError:
|
||||
continue
|
||||
for i in range(count):
|
||||
try:
|
||||
name = winreg.EnumKey(key, i)
|
||||
sub = winreg.OpenKey(key, name, 0, winreg.KEY_READ)
|
||||
except OSError:
|
||||
continue
|
||||
with sub:
|
||||
def val(field: str, default=""):
|
||||
try:
|
||||
return winreg.QueryValueEx(sub, field)[0]
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
display = str(val("DisplayName") or "").strip()
|
||||
if not display:
|
||||
continue
|
||||
if val("SystemComponent", 0) == 1:
|
||||
continue
|
||||
if val("ParentKeyName") or val("ParentDisplayName"):
|
||||
# обновления и компоненты внутри бандлов
|
||||
if not val("UninstallString"):
|
||||
continue
|
||||
version = str(val("DisplayVersion") or "")
|
||||
dedup = (display.lower(), version)
|
||||
if dedup in seen:
|
||||
continue
|
||||
seen.add(dedup)
|
||||
try:
|
||||
est = int(val("EstimatedSize", 0) or 0) * 1024
|
||||
except (TypeError, ValueError):
|
||||
est = 0
|
||||
out.append(Program(
|
||||
name=display,
|
||||
publisher=str(val("Publisher") or ""),
|
||||
version=version,
|
||||
install_date=str(val("InstallDate") or ""),
|
||||
install_location=str(val("InstallLocation") or "").strip('"'),
|
||||
uninstall_cmd=str(val("QuietUninstallString")
|
||||
or val("UninstallString") or ""),
|
||||
registry_size=est,
|
||||
source=label,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _guess_location(prog: Program) -> str:
|
||||
"""Если InstallLocation пуст — пробуем вытащить путь из строки удаления."""
|
||||
if prog.install_location and os.path.isdir(prog.install_location):
|
||||
return prog.install_location
|
||||
cmd = prog.uninstall_cmd
|
||||
if not cmd:
|
||||
return ""
|
||||
candidate = ""
|
||||
if cmd.startswith('"'):
|
||||
candidate = cmd[1:].split('"', 1)[0]
|
||||
else:
|
||||
parts = cmd.split(" ")
|
||||
for i in range(len(parts), 0, -1):
|
||||
guess = " ".join(parts[:i])
|
||||
if os.path.exists(guess):
|
||||
candidate = guess
|
||||
break
|
||||
if not candidate:
|
||||
return ""
|
||||
folder = os.path.dirname(candidate)
|
||||
k = known_paths()
|
||||
# не отдаём общие корни вроде C:\Windows\System32
|
||||
bad = {os.path.normcase(k["WINDIR"]), os.path.normcase(os.path.join(k["WINDIR"], "System32")),
|
||||
os.path.normcase(k["PROGRAMFILES"]), os.path.normcase(k["PROGRAMFILES86"]),
|
||||
os.path.normcase(k["TEMP"])}
|
||||
if not folder or os.path.normcase(folder) in bad or len(folder) <= 3:
|
||||
return ""
|
||||
return folder if os.path.isdir(folder) else ""
|
||||
|
||||
|
||||
def scan_programs(
|
||||
measure_disk: bool = True,
|
||||
cancel: Cancel | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> list[Program]:
|
||||
"""Список установленных программ, отсортированный по занимаемому месту."""
|
||||
progs = _read_registry()
|
||||
if measure_disk:
|
||||
for i, p in enumerate(progs):
|
||||
if cancel is not None and cancel.cancelled:
|
||||
break
|
||||
loc = _guess_location(p)
|
||||
if not loc:
|
||||
continue
|
||||
p.install_location = loc
|
||||
size, _files, _dirs, mtime = dir_stats(loc, cancel, max_entries=60_000)
|
||||
p.disk_size = size
|
||||
p.last_used = mtime
|
||||
if progress is not None and i % 10 == 0:
|
||||
progress(p.name)
|
||||
progs.sort(key=lambda p: -p.best_size)
|
||||
return progs
|
||||
|
||||
|
||||
def store_apps() -> list[Program]:
|
||||
"""Приложения Microsoft Store (через PowerShell, может быть медленно)."""
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
ps = (
|
||||
"Get-AppxPackage | Select-Object Name,Publisher,Version,InstallLocation | "
|
||||
"ConvertTo-Csv -NoTypeInformation"
|
||||
)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
||||
capture_output=True, text=True, timeout=90, errors="ignore",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
import csv
|
||||
import io
|
||||
out: list[Program] = []
|
||||
try:
|
||||
for row in csv.DictReader(io.StringIO(res.stdout)):
|
||||
loc = (row.get("InstallLocation") or "").strip()
|
||||
if not loc or not os.path.isdir(loc):
|
||||
continue
|
||||
size, _f, _d, mtime = dir_stats(loc, max_entries=30_000)
|
||||
out.append(Program(
|
||||
name=row.get("Name") or "?",
|
||||
publisher=(row.get("Publisher") or "").split(",")[0].replace("CN=", ""),
|
||||
version=row.get("Version") or "",
|
||||
install_location=loc,
|
||||
disk_size=size,
|
||||
last_used=mtime,
|
||||
source="Store",
|
||||
))
|
||||
except Exception:
|
||||
return out
|
||||
out.sort(key=lambda p: -p.best_size)
|
||||
return out
|
||||
|
||||
|
||||
def uninstall(prog: Program) -> tuple[bool, str]:
|
||||
"""Запустить штатный деинсталлятор программы (интерактивно)."""
|
||||
if not prog.uninstall_cmd:
|
||||
return False, "у программы нет команды удаления в реестре"
|
||||
try:
|
||||
subprocess.Popen(prog.uninstall_cmd, shell=True)
|
||||
return True, ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
645
sysdevcleaner/core/rules.py
Normal file
645
sysdevcleaner/core/rules.py
Normal file
@@ -0,0 +1,645 @@
|
||||
"""Каталог правил: где в Windows копится кеш, мусор и остатки разработки.
|
||||
|
||||
Пути записаны шаблонами с плейсхолдерами ``{LOCAL}``, ``{ROAMING}``, ``{HOME}``,
|
||||
``{WINDIR}``, ``{PROGRAMDATA}``, ``{TEMP}``, ``{LOCALLOW}``, ``{PROGRAMFILES}``,
|
||||
``{PROGRAMFILES86}``, ``{PUBLIC}``, ``{SYSTEMDRIVE}``.
|
||||
Поддерживаются glob-маски (``*``), поэтому одно правило покрывает все профили
|
||||
браузеров и все версии IDE сразу.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .model import Category, Risk
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
id: str
|
||||
title: str
|
||||
category: Category
|
||||
risk: Risk
|
||||
detail: str
|
||||
paths: tuple[str, ...] = ()
|
||||
contents_only: bool = False # чистим внутренности, каталог оставляем
|
||||
needs_admin: bool = False
|
||||
processes: tuple[str, ...] = () # предупредить, если запущены
|
||||
min_size: int = 0 # не показывать мелочь
|
||||
files_only: tuple[str, ...] = () # маски файлов внутри paths
|
||||
|
||||
|
||||
C = Category
|
||||
R = Risk
|
||||
|
||||
# --------------------------------------------------------------- Windows ----
|
||||
|
||||
WINDOWS_RULES: list[Rule] = [
|
||||
Rule("win.temp.user", "Временные файлы пользователя", C.WINDOWS, R.SAFE,
|
||||
"Рабочая свалка установщиков и приложений. Занятые файлы пропускаются автоматически.",
|
||||
("{TEMP}",), contents_only=True),
|
||||
Rule("win.temp.system", "Временные файлы системы", C.WINDOWS, R.SAFE,
|
||||
"C:\\Windows\\Temp — то же самое, но для служб. Нужны права администратора.",
|
||||
("{WINDIR}\\Temp",), contents_only=True, needs_admin=True),
|
||||
Rule("win.update.download", "Кеш загрузок Windows Update", C.WINDOWS, R.LOW,
|
||||
"Скачанные пакеты обновлений. Уже установленные не нужны; система скачает заново, если потребуется.",
|
||||
("{WINDIR}\\SoftwareDistribution\\Download",), contents_only=True, needs_admin=True),
|
||||
Rule("win.delivery", "Оптимизация доставки", C.WINDOWS, R.LOW,
|
||||
"Кеш P2P-раздачи обновлений другим ПК в сети. Часто вырастает до нескольких ГБ.",
|
||||
("{WINDIR}\\ServiceProfiles\\NetworkService\\AppData\\Local\\Microsoft\\Windows\\DeliveryOptimization",),
|
||||
contents_only=True, needs_admin=True),
|
||||
Rule("win.old", "Windows.old (прошлая версия Windows)", C.WINDOWS, R.MEDIUM,
|
||||
"Полная копия старой системы после обновления. Удаление лишает возможности откатиться назад.",
|
||||
("{SYSTEMDRIVE}Windows.old",), needs_admin=True),
|
||||
Rule("win.prefetch", "Prefetch", C.WINDOWS, R.LOW,
|
||||
"Данные предзагрузки приложений. После очистки первые запуски программ будут чуть медленнее.",
|
||||
("{WINDIR}\\Prefetch",), contents_only=True, needs_admin=True),
|
||||
Rule("win.crashdumps", "Дампы аварийных завершений", C.WINDOWS, R.SAFE,
|
||||
"Снимки памяти упавших программ. Нужны только для разбора конкретного сбоя.",
|
||||
("{LOCAL}\\CrashDumps", "{WINDIR}\\Minidump",), contents_only=True),
|
||||
Rule("win.memorydmp", "Полный дамп памяти (MEMORY.DMP)", C.WINDOWS, R.LOW,
|
||||
"Снимок всей оперативной памяти после BSOD — обычно равен объёму ОЗУ.",
|
||||
("{WINDIR}\\MEMORY.DMP",), needs_admin=True),
|
||||
Rule("win.wer", "Отчёты об ошибках (WER)", C.WINDOWS, R.SAFE,
|
||||
"Очереди отчётов Windows Error Reporting.",
|
||||
("{LOCAL}\\Microsoft\\Windows\\WER", "{PROGRAMDATA}\\Microsoft\\Windows\\WER"),
|
||||
contents_only=True),
|
||||
Rule("win.thumbcache", "Кеш эскизов и значков", C.WINDOWS, R.SAFE,
|
||||
"thumbcache_*.db / iconcache_*.db. Пересоздаётся проводником на лету.",
|
||||
("{LOCAL}\\Microsoft\\Windows\\Explorer",), contents_only=True,
|
||||
files_only=("thumbcache_*.db", "iconcache_*.db")),
|
||||
Rule("win.fontcache", "Кеш шрифтов", C.WINDOWS, R.LOW,
|
||||
"Служебный кеш подсистемы шрифтов.",
|
||||
("{WINDIR}\\ServiceProfiles\\LocalService\\AppData\\Local\\FontCache",),
|
||||
contents_only=True, needs_admin=True),
|
||||
Rule("win.inetcache", "Кеш Internet Explorer / WinINet", C.WINDOWS, R.SAFE,
|
||||
"Общесистемный HTTP-кеш, которым пользуются и многие desktop-приложения.",
|
||||
("{LOCAL}\\Microsoft\\Windows\\INetCache",), contents_only=True),
|
||||
Rule("win.d3dcache", "Кеш шейдеров DirectX", C.WINDOWS, R.SAFE,
|
||||
"Скомпилированные шейдеры. Пересобираются при следующем запуске игры.",
|
||||
("{LOCAL}\\D3DSCache", "{LOCAL}\\Microsoft\\DirectX Shader Cache"), contents_only=True),
|
||||
Rule("win.recycle", "Корзина", C.WINDOWS, R.MEDIUM,
|
||||
"Файлы, удалённые ранее. После очистки восстановить их уже нельзя.",
|
||||
("{SYSTEMDRIVE}$Recycle.Bin",), contents_only=True),
|
||||
Rule("win.installer.patch", "Кеш патчей установщика Windows", C.WINDOWS, R.MEDIUM,
|
||||
"$PatchCache$ нужен для отката и восстановления MSI-программ.",
|
||||
("{WINDIR}\\Installer\\$PatchCache$",), contents_only=True, needs_admin=True),
|
||||
Rule("win.panther", "Логи установки системы (Panther)", C.WINDOWS, R.SAFE,
|
||||
"Отчёты установщика Windows и драйверов.",
|
||||
("{WINDIR}\\Panther", "{WINDIR}\\inf\\setupapi.dev.log"),
|
||||
contents_only=True, needs_admin=True),
|
||||
Rule("win.defender.history", "История проверок Windows Defender", C.WINDOWS, R.LOW,
|
||||
"Журнал сканирований антивируса. На саму защиту не влияет.",
|
||||
("{PROGRAMDATA}\\Microsoft\\Windows Defender\\Scans\\History\\Service",
|
||||
"{PROGRAMDATA}\\Microsoft\\Windows Defender\\Scans\\History\\Results"),
|
||||
contents_only=True, needs_admin=True),
|
||||
Rule("win.clipboard", "Кеш перьевого/облачного ввода", C.WINDOWS, R.LOW,
|
||||
"Служебные данные InputPersonalization и истории буфера.",
|
||||
("{LOCAL}\\Microsoft\\InputPersonalization\\TextHarvester",), contents_only=True),
|
||||
Rule("win.livekernel", "LiveKernelReports", C.WINDOWS, R.SAFE,
|
||||
"Дампы сбоев драйверов без полного BSOD.",
|
||||
("{WINDIR}\\LiveKernelReports",), contents_only=True, needs_admin=True),
|
||||
Rule("win.downloadedinstall", "Downloaded Installations", C.WINDOWS, R.LOW,
|
||||
"Распакованные дистрибутивы старых установщиков.",
|
||||
("{PROGRAMDATA}\\Downloaded Installations",), contents_only=True),
|
||||
Rule("win.hyperv.cache", "Кеш Microsoft Store / AppX", C.WINDOWS, R.SAFE,
|
||||
"Временные файлы доставки приложений из Store.",
|
||||
("{LOCAL}\\Packages\\Microsoft.Windows.Search_cw5n1h2txyewy\\LocalState\\AppIconCache",
|
||||
"{LOCAL}\\Microsoft\\Windows\\Caches"), contents_only=True),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------- Логи ---------
|
||||
|
||||
LOG_RULES: list[Rule] = [
|
||||
Rule("log.windows", "Системные журналы Windows", C.LOGS, R.LOW,
|
||||
"C:\\Windows\\Logs — логи CBS, DISM, обслуживания компонентов.",
|
||||
("{WINDIR}\\Logs\\CBS", "{WINDIR}\\Logs\\DISM", "{WINDIR}\\Logs\\MoSetup",
|
||||
"{WINDIR}\\Logs\\WindowsUpdate"), contents_only=True, needs_admin=True),
|
||||
Rule("log.perflogs", "PerfLogs", C.LOGS, R.SAFE,
|
||||
"Отчёты монитора производительности.",
|
||||
("{SYSTEMDRIVE}PerfLogs",), contents_only=True, needs_admin=True),
|
||||
Rule("log.appdata", "Логи приложений в AppData", C.LOGS, R.SAFE,
|
||||
"Каталоги logs/ внутри данных установленных программ.",
|
||||
("{ROAMING}\\*\\logs", "{LOCAL}\\*\\logs", "{LOCAL}\\*\\log"),
|
||||
contents_only=True, min_size=2 * 1024 * 1024),
|
||||
Rule("log.npm", "Логи npm/yarn", C.LOGS, R.SAFE,
|
||||
"_logs со stack trace неудачных установок пакетов.",
|
||||
("{LOCAL}\\npm-cache\\_logs", "{ROAMING}\\npm-cache\\_logs"), contents_only=True),
|
||||
Rule("log.nvidia", "Логи и кеш NVIDIA", C.LOGS, R.SAFE,
|
||||
"Отчёты драйвера и телеметрии GeForce Experience.",
|
||||
("{PROGRAMDATA}\\NVIDIA Corporation\\NvTelemetry",
|
||||
"{LOCAL}\\NVIDIA Corporation\\NvTelemetry",
|
||||
"{PROGRAMDATA}\\NVIDIA Corporation\\Downloader"), contents_only=True),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------- Браузеры --------
|
||||
|
||||
_CHROMIUM = [
|
||||
("Google Chrome", "{LOCAL}\\Google\\Chrome\\User Data", "chrome.exe"),
|
||||
("Google Chrome Beta", "{LOCAL}\\Google\\Chrome Beta\\User Data", "chrome.exe"),
|
||||
("Microsoft Edge", "{LOCAL}\\Microsoft\\Edge\\User Data", "msedge.exe"),
|
||||
("Brave", "{LOCAL}\\BraveSoftware\\Brave-Browser\\User Data", "brave.exe"),
|
||||
("Opera", "{ROAMING}\\Opera Software\\Opera Stable", "opera.exe"),
|
||||
("Opera GX", "{ROAMING}\\Opera Software\\Opera GX Stable", "opera.exe"),
|
||||
("Vivaldi", "{LOCAL}\\Vivaldi\\User Data", "vivaldi.exe"),
|
||||
("Yandex", "{LOCAL}\\Yandex\\YandexBrowser\\User Data", "browser.exe"),
|
||||
("Chromium", "{LOCAL}\\Chromium\\User Data", "chromium.exe"),
|
||||
]
|
||||
|
||||
_CHROMIUM_CACHES = [
|
||||
("Cache\\Cache_Data", "HTTP-кеш страниц", R.SAFE),
|
||||
("Code Cache", "Скомпилированный JavaScript", R.SAFE),
|
||||
("GPUCache", "Кеш GPU-шейдеров", R.SAFE),
|
||||
("ShaderCache", "Кеш шейдеров", R.SAFE),
|
||||
("GrShaderCache", "Кеш шейдеров Skia", R.SAFE),
|
||||
("DawnCache", "Кеш WebGPU", R.SAFE),
|
||||
("DawnGraphiteCache", "Кеш WebGPU (Graphite)", R.SAFE),
|
||||
("DawnWebGPUCache", "Кеш WebGPU", R.SAFE),
|
||||
("Service Worker\\CacheStorage", "Кеш service worker'ов сайтов", R.LOW),
|
||||
("Service Worker\\ScriptCache", "Скрипты service worker'ов", R.LOW),
|
||||
("Application Cache", "Устаревший AppCache", R.SAFE),
|
||||
("Media Cache", "Кеш видео и аудио", R.SAFE),
|
||||
("File System", "Sandboxed FileSystem API сайтов", R.MEDIUM),
|
||||
("IndexedDB", "Локальные базы сайтов (офлайн-данные)", R.MEDIUM),
|
||||
]
|
||||
|
||||
|
||||
def _browser_rules() -> list[Rule]:
|
||||
out: list[Rule] = []
|
||||
for name, root, proc in _CHROMIUM:
|
||||
for sub, desc, risk in _CHROMIUM_CACHES:
|
||||
out.append(Rule(
|
||||
f"br.{name.lower().replace(' ', '')}.{sub.split(chr(92))[0].lower().replace(' ', '')}",
|
||||
f"{name} — {desc}",
|
||||
C.BROWSERS, risk,
|
||||
f"{desc}. Профили: все найденные. Браузер лучше закрыть перед очисткой.",
|
||||
(f"{root}\\*\\{sub}", f"{root}\\{sub}"),
|
||||
contents_only=True, processes=(proc,), min_size=256 * 1024,
|
||||
))
|
||||
out.append(Rule(
|
||||
f"br.{name.lower().replace(' ', '')}.crashpad",
|
||||
f"{name} — отчёты о падениях",
|
||||
C.BROWSERS, R.SAFE, "Crashpad-дампы браузера.",
|
||||
(f"{root}\\Crashpad\\reports", f"{root}\\Crashpad\\completed"),
|
||||
contents_only=True, processes=(proc,),
|
||||
))
|
||||
out += [
|
||||
Rule("br.firefox.cache", "Firefox — кеш", C.BROWSERS, R.SAFE,
|
||||
"cache2 всех профилей Firefox.",
|
||||
("{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\cache2",
|
||||
"{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\startupCache",
|
||||
"{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\shader-cache",
|
||||
"{LOCAL}\\Mozilla\\Firefox\\Profiles\\*\\thumbnails"),
|
||||
contents_only=True, processes=("firefox.exe",)),
|
||||
Rule("br.firefox.crash", "Firefox — отчёты о падениях", C.BROWSERS, R.SAFE,
|
||||
"Minidump'ы Mozilla Crash Reporter.",
|
||||
("{ROAMING}\\Mozilla\\Firefox\\Crash Reports",), contents_only=True),
|
||||
Rule("br.tor.cache", "Tor Browser — кеш", C.BROWSERS, R.SAFE,
|
||||
"Кеш профиля Tor Browser.",
|
||||
("{ROAMING}\\tor-browser\\*\\cache2",), contents_only=True),
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
BROWSER_RULES = _browser_rules()
|
||||
|
||||
# ----------------------------------------------- Кеши инструментов разработки -
|
||||
|
||||
DEV_CACHE_RULES: list[Rule] = [
|
||||
# --- JavaScript ---
|
||||
Rule("dev.npm", "npm — глобальный кеш пакетов", C.DEV_CACHE, R.LOW,
|
||||
"_cacache. npm скачает пакеты заново при следующей установке.",
|
||||
("{LOCAL}\\npm-cache\\_cacache", "{ROAMING}\\npm-cache\\_cacache"), contents_only=True),
|
||||
Rule("dev.yarn", "Yarn — кеш пакетов", C.DEV_CACHE, R.LOW,
|
||||
"Глобальный кеш Yarn 1.x и Berry.",
|
||||
("{LOCAL}\\Yarn\\Cache", "{LOCAL}\\Yarn\\Berry\\cache", "{HOME}\\.yarn\\berry\\cache"),
|
||||
contents_only=True),
|
||||
Rule("dev.pnpm", "pnpm — content-addressable store", C.DEV_CACHE, R.MEDIUM,
|
||||
"Общее хранилище пакетов pnpm. После удаления все проекты потребуют pnpm install.",
|
||||
("{LOCAL}\\pnpm\\store", "{LOCAL}\\pnpm-store", "{HOME}\\.pnpm-store"), contents_only=True),
|
||||
Rule("dev.bun", "Bun — кеш установок", C.DEV_CACHE, R.LOW,
|
||||
"Кеш менеджера пакетов Bun.",
|
||||
("{LOCAL}\\bun\\install\\cache", "{HOME}\\.bun\\install\\cache"), contents_only=True),
|
||||
Rule("dev.deno", "Deno — кеш модулей", C.DEV_CACHE, R.LOW,
|
||||
"Скачанные удалённые модули и скомпилированный код.",
|
||||
("{LOCAL}\\deno", "{HOME}\\.deno"), contents_only=True),
|
||||
Rule("dev.node.gyp", "node-gyp — заголовки Node", C.DEV_CACHE, R.SAFE,
|
||||
"Исходники Node для сборки нативных модулей.",
|
||||
("{LOCAL}\\node-gyp\\Cache", "{HOME}\\.node-gyp"), contents_only=True),
|
||||
Rule("dev.electron", "Electron / electron-builder — кеш", C.DEV_CACHE, R.LOW,
|
||||
"Скачанные бинарники Electron и распакованные зависимости сборщика.",
|
||||
("{LOCAL}\\electron\\Cache", "{LOCAL}\\electron-builder\\Cache",
|
||||
"{LOCAL}\\electron-builder\\cache"), contents_only=True),
|
||||
Rule("dev.puppeteer", "Puppeteer / Playwright — браузеры", C.DEV_CACHE, R.MEDIUM,
|
||||
"Скачанные сборки Chromium/Firefox/WebKit для e2e-тестов. Обычно 1–3 ГБ.",
|
||||
("{LOCAL}\\ms-playwright", "{HOME}\\.cache\\puppeteer", "{LOCAL}\\puppeteer"),
|
||||
contents_only=True),
|
||||
Rule("dev.cypress", "Cypress — кеш бинарников", C.DEV_CACHE, R.LOW,
|
||||
"Каждая версия Cypress весит ~500 МБ и остаётся навсегда.",
|
||||
("{LOCAL}\\Cypress\\Cache",), contents_only=True),
|
||||
|
||||
# --- Python ---
|
||||
Rule("dev.pip", "pip — кеш колёс и загрузок", C.DEV_CACHE, R.SAFE,
|
||||
"Скачанные wheel/sdist. Восстанавливается при следующей установке.",
|
||||
("{LOCAL}\\pip\\Cache", "{HOME}\\.cache\\pip"), contents_only=True),
|
||||
Rule("dev.poetry", "Poetry — кеш", C.DEV_CACHE, R.LOW,
|
||||
"Артефакты и виртуальные окружения, созданные Poetry.",
|
||||
("{LOCAL}\\pypoetry\\Cache\\cache", "{LOCAL}\\pypoetry\\Cache\\artifacts"),
|
||||
contents_only=True),
|
||||
Rule("dev.uv", "uv — кеш пакетов", C.DEV_CACHE, R.SAFE,
|
||||
"Кеш быстрого установщика uv.",
|
||||
("{LOCAL}\\uv\\cache", "{HOME}\\.cache\\uv"), contents_only=True),
|
||||
Rule("dev.conda", "Conda — распакованные пакеты", C.DEV_CACHE, R.MEDIUM,
|
||||
"pkgs/ занимает десятки ГБ. Безопаснее `conda clean -a`, но каталог можно чистить и вручную.",
|
||||
("{HOME}\\anaconda3\\pkgs", "{HOME}\\miniconda3\\pkgs", "{HOME}\\.conda\\pkgs"),
|
||||
contents_only=True),
|
||||
Rule("dev.pyinstaller", "PyInstaller — кеш сборок", C.DEV_CACHE, R.SAFE,
|
||||
"Промежуточные бинарники упаковщика.",
|
||||
("{ROAMING}\\pyinstaller",), contents_only=True),
|
||||
|
||||
# --- .NET / Java / Rust / Go ---
|
||||
Rule("dev.nuget", "NuGet — глобальные пакеты", C.DEV_CACHE, R.MEDIUM,
|
||||
"~/.nuget/packages. Все .NET-проекты потребуют restore после очистки.",
|
||||
("{HOME}\\.nuget\\packages",), contents_only=True),
|
||||
Rule("dev.nuget.http", "NuGet — HTTP-кеш и temp", C.DEV_CACHE, R.SAFE,
|
||||
"Промежуточный кеш скачивания, восстанавливается сам.",
|
||||
("{LOCAL}\\NuGet\\v3-cache", "{LOCAL}\\NuGet\\plugins-cache", "{LOCAL}\\Temp\\NuGetScratch"),
|
||||
contents_only=True),
|
||||
Rule("dev.maven", "Maven — локальный репозиторий", C.DEV_CACHE, R.MEDIUM,
|
||||
"~/.m2/repository. Часто десятки ГБ; при сборке скачается заново.",
|
||||
("{HOME}\\.m2\\repository",), contents_only=True),
|
||||
Rule("dev.gradle", "Gradle — кеши и дистрибутивы", C.DEV_CACHE, R.MEDIUM,
|
||||
"caches/ и wrapper/dists — один из самых крупных пожирателей места у Android-разработчиков.",
|
||||
("{HOME}\\.gradle\\caches", "{HOME}\\.gradle\\wrapper\\dists",
|
||||
"{HOME}\\.gradle\\daemon", "{HOME}\\.gradle\\native"), contents_only=True),
|
||||
Rule("dev.cargo", "Cargo — реестр и исходники крейтов", C.DEV_CACHE, R.LOW,
|
||||
"registry/cache, registry/src и git/checkouts. Восстанавливаются при сборке.",
|
||||
("{HOME}\\.cargo\\registry\\cache", "{HOME}\\.cargo\\registry\\src",
|
||||
"{HOME}\\.cargo\\git\\checkouts", "{HOME}\\.cargo\\git\\db"), contents_only=True),
|
||||
Rule("dev.gomod", "Go — кеш модулей и сборки", C.DEV_CACHE, R.LOW,
|
||||
"GOMODCACHE/download и GOCACHE. Модули read-only — потребуется `go clean -modcache`, "
|
||||
"если обычное удаление не пройдёт.",
|
||||
("{HOME}\\go\\pkg\\mod\\cache\\download", "{LOCAL}\\go-build"), contents_only=True),
|
||||
Rule("dev.sdkman", "Скачанные JDK / SDK", C.DEV_CACHE, R.MEDIUM,
|
||||
"Архивы и распакованные SDK, которые обычно уже не используются.",
|
||||
("{LOCAL}\\Temurin", "{HOME}\\.jdks", "{HOME}\\.sdkman\\archives"), contents_only=True),
|
||||
|
||||
# --- Mobile / Game / прочее ---
|
||||
Rule("dev.android.sdk", "Android SDK — временные каталоги", C.DEV_CACHE, R.LOW,
|
||||
"Кеш SDK-менеджера и загрузок.",
|
||||
("{LOCAL}\\Android\\Sdk\\.temp", "{LOCAL}\\Android\\Sdk\\.downloadIntermediates",
|
||||
"{HOME}\\.android\\cache", "{HOME}\\.android\\build-cache"), contents_only=True),
|
||||
Rule("dev.android.emulator", "Образы эмулятора Android", C.DEV_CACHE, R.HIGH,
|
||||
"system-images и созданные AVD. Каждое устройство — несколько ГБ. "
|
||||
"Удаляйте только те, которыми точно не пользуетесь.",
|
||||
("{LOCAL}\\Android\\Sdk\\system-images\\*", "{HOME}\\.android\\avd\\*"),
|
||||
min_size=200 * 1024 * 1024),
|
||||
Rule("dev.flutter", "Flutter / Dart — pub cache", C.DEV_CACHE, R.MEDIUM,
|
||||
"Скачанные пакеты Dart и артефакты движка Flutter.",
|
||||
("{LOCAL}\\Pub\\Cache\\hosted", "{LOCAL}\\Pub\\Cache\\git", "{HOME}\\.pub-cache\\hosted"),
|
||||
contents_only=True),
|
||||
Rule("dev.composer", "Composer — кеш PHP-пакетов", C.DEV_CACHE, R.SAFE,
|
||||
"Скачанные zip-архивы и метаданные packagist.",
|
||||
("{LOCAL}\\Composer\\files", "{LOCAL}\\Composer\\repo", "{ROAMING}\\Composer\\cache"),
|
||||
contents_only=True),
|
||||
Rule("dev.ruby", "RubyGems / Bundler — кеш", C.DEV_CACHE, R.LOW,
|
||||
"Скачанные .gem-архивы.",
|
||||
("{LOCAL}\\gem\\cache", "{HOME}\\.bundle\\cache", "{HOME}\\.gem\\cache"),
|
||||
contents_only=True),
|
||||
Rule("dev.terraform", "Terraform — кеш плагинов", C.DEV_CACHE, R.LOW,
|
||||
"Скачанные провайдеры (каждый — сотни МБ).",
|
||||
("{ROAMING}\\terraform.d\\plugin-cache", "{LOCAL}\\terraform.d"), contents_only=True),
|
||||
Rule("dev.helm", "Helm / Kubernetes — кеш", C.DEV_CACHE, R.SAFE,
|
||||
"Кеш репозиториев чартов.",
|
||||
("{LOCAL}\\helm\\repository\\cache", "{LOCAL}\\Temp\\helm"), contents_only=True),
|
||||
Rule("dev.vcpkg", "vcpkg / Conan — кеш сборок", C.DEV_CACHE, R.MEDIUM,
|
||||
"Бинарные кеши C++-зависимостей. Пересборка может занять часы.",
|
||||
("{LOCAL}\\vcpkg\\archives", "{LOCAL}\\vcpkg\\downloads", "{HOME}\\.conan\\data",
|
||||
"{HOME}\\.conan2\\p"), contents_only=True),
|
||||
Rule("dev.ccache", "ccache / sccache", C.DEV_CACHE, R.LOW,
|
||||
"Кеш компилятора C/C++. Ускоряет пересборку, но безопасно удаляется.",
|
||||
("{LOCAL}\\ccache", "{LOCAL}\\Mozilla\\sccache", "{HOME}\\.ccache"), contents_only=True),
|
||||
Rule("dev.unity.global", "Unity — глобальные кеши", C.DEV_CACHE, R.MEDIUM,
|
||||
"Asset Pipeline cache, кеш пакетов и загрузок Unity Hub.",
|
||||
("{LOCAL}\\Unity\\cache", "{ROAMING}\\Unity\\cache", "{LOCAL}\\Unity\\Caches",
|
||||
"{ROAMING}\\UnityHub\\Caches"), contents_only=True),
|
||||
Rule("dev.unreal", "Unreal Engine — DDC", C.DEV_CACHE, R.MEDIUM,
|
||||
"Derived Data Cache. Легко занимает 50+ ГБ; пересобирается долго.",
|
||||
("{LOCAL}\\UnrealEngine\\Common\\DerivedDataCache",
|
||||
"{LOCAL}\\UnrealEngine\\*\\DerivedDataCache"), contents_only=True),
|
||||
Rule("dev.docker", "Docker Desktop — логи и кеш", C.DEV_CACHE, R.LOW,
|
||||
"Логи и служебный кеш. Образы хранятся в WSL-диске — их чистят через `docker system prune`.",
|
||||
("{LOCAL}\\Docker\\log", "{ROAMING}\\Docker\\log", "{ROAMING}\\Docker Desktop\\logs"),
|
||||
contents_only=True),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------ IDE / редакторы -
|
||||
|
||||
IDE_RULES: list[Rule] = [
|
||||
Rule("ide.vscode", "VS Code — кеш и временные данные", C.DEV_CACHE, R.LOW,
|
||||
"Cache, CachedData, GPUCache, логи. Настройки и расширения не затрагиваются.",
|
||||
("{ROAMING}\\Code\\Cache", "{ROAMING}\\Code\\CachedData",
|
||||
"{ROAMING}\\Code\\CachedExtensionVSIXs", "{ROAMING}\\Code\\Code Cache",
|
||||
"{ROAMING}\\Code\\GPUCache", "{ROAMING}\\Code\\logs",
|
||||
"{ROAMING}\\Code\\User\\workspaceStorage"),
|
||||
contents_only=True, processes=("code.exe",)),
|
||||
Rule("ide.vscode.insiders", "VS Code Insiders / VSCodium — кеш", C.DEV_CACHE, R.LOW,
|
||||
"То же самое для альтернативных сборок редактора.",
|
||||
("{ROAMING}\\Code - Insiders\\Cache", "{ROAMING}\\Code - Insiders\\CachedData",
|
||||
"{ROAMING}\\VSCodium\\Cache", "{ROAMING}\\VSCodium\\CachedData"), contents_only=True),
|
||||
Rule("ide.cursor", "Cursor / Windsurf — кеш", C.DEV_CACHE, R.LOW,
|
||||
"Кеши AI-редакторов на базе VS Code.",
|
||||
("{ROAMING}\\Cursor\\Cache", "{ROAMING}\\Cursor\\CachedData",
|
||||
"{ROAMING}\\Cursor\\Code Cache", "{ROAMING}\\Cursor\\GPUCache",
|
||||
"{ROAMING}\\Windsurf\\Cache", "{ROAMING}\\Windsurf\\CachedData"),
|
||||
contents_only=True, processes=("cursor.exe",)),
|
||||
Rule("ide.jetbrains.caches", "JetBrains — индексы и кеши", C.DEV_CACHE, R.LOW,
|
||||
"Индексы IDEA/PyCharm/WebStorm и др. Перестраиваются при открытии проекта.",
|
||||
("{LOCAL}\\JetBrains\\*\\caches", "{LOCAL}\\JetBrains\\*\\index",
|
||||
"{LOCAL}\\JetBrains\\*\\tmp", "{LOCAL}\\JetBrains\\*\\log"),
|
||||
contents_only=True, processes=("idea64.exe", "pycharm64.exe", "webstorm64.exe")),
|
||||
Rule("ide.jetbrains.old", "JetBrains — данные старых версий IDE", C.DEV_CACHE, R.MEDIUM,
|
||||
"Конфигурация и кеши версий, которые давно не запускались.",
|
||||
("{ROAMING}\\JetBrains\\*",), min_size=100 * 1024 * 1024),
|
||||
Rule("ide.visualstudio", "Visual Studio — кеши", C.DEV_CACHE, R.LOW,
|
||||
"ComponentModelCache, кеш телеметрии и отладчика.",
|
||||
("{LOCAL}\\Microsoft\\VisualStudio\\*\\ComponentModelCache",
|
||||
"{LOCAL}\\Microsoft\\VisualStudio\\*\\Designer\\ShadowCache",
|
||||
"{LOCAL}\\Microsoft\\VisualStudio\\Packages\\_Instances",
|
||||
"{LOCAL}\\Microsoft\\VSCommon\\*\\SQM"),
|
||||
contents_only=True, processes=("devenv.exe",)),
|
||||
Rule("ide.vs.installer", "Visual Studio Installer — кеш дистрибутивов", C.DEV_CACHE, R.MEDIUM,
|
||||
"Скачанные пакеты установщика VS. Часто 10–20 ГБ, нужны только при изменении состава.",
|
||||
("{PROGRAMDATA}\\Microsoft\\VisualStudio\\Packages",), contents_only=True,
|
||||
needs_admin=True, min_size=200 * 1024 * 1024),
|
||||
Rule("ide.vs.backup", "Visual Studio — автосохранения и трассировки", C.DEV_CACHE, R.LOW,
|
||||
"Backup Files и ActivityLog.",
|
||||
("{LOCAL}\\Microsoft\\VisualStudio\\*\\ActivityLog.xml",
|
||||
"{HOME}\\Documents\\Visual Studio *\\Backup Files"), contents_only=True),
|
||||
Rule("ide.xamarin", "Xamarin / .NET Android — кеш", C.DEV_CACHE, R.LOW,
|
||||
"Скачанные компоненты Xamarin.",
|
||||
("{LOCAL}\\Xamarin\\zips", "{LOCAL}\\Xamarin\\Cache"), contents_only=True),
|
||||
Rule("ide.eclipse", "Eclipse / NetBeans — кеш", C.DEV_CACHE, R.LOW,
|
||||
"Метаданные рабочих областей и индексы.",
|
||||
("{ROAMING}\\NetBeans\\*\\var\\cache", "{HOME}\\.eclipse\\*\\configuration\\org.eclipse.osgi"),
|
||||
contents_only=True),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------ Приложения ----
|
||||
|
||||
APP_RULES: list[Rule] = [
|
||||
Rule("app.discord", "Discord — кеш", C.APPS, R.SAFE,
|
||||
"Cache, Code Cache, GPUCache клиента Discord.",
|
||||
("{ROAMING}\\discord\\Cache", "{ROAMING}\\discord\\Code Cache",
|
||||
"{ROAMING}\\discord\\GPUCache", "{ROAMING}\\discordptb\\Cache",
|
||||
"{ROAMING}\\discordcanary\\Cache"),
|
||||
contents_only=True, processes=("discord.exe",)),
|
||||
Rule("app.slack", "Slack — кеш", C.APPS, R.SAFE,
|
||||
"Кеш сообщений и вложений Slack.",
|
||||
("{ROAMING}\\Slack\\Cache", "{ROAMING}\\Slack\\Code Cache",
|
||||
"{ROAMING}\\Slack\\GPUCache", "{ROAMING}\\Slack\\Service Worker\\CacheStorage"),
|
||||
contents_only=True, processes=("slack.exe",)),
|
||||
Rule("app.teams", "Microsoft Teams — кеш", C.APPS, R.LOW,
|
||||
"Кеш классического и нового клиента Teams.",
|
||||
("{ROAMING}\\Microsoft\\Teams\\Cache",
|
||||
"{ROAMING}\\Microsoft\\Teams\\Code Cache",
|
||||
"{ROAMING}\\Microsoft\\Teams\\GPUCache",
|
||||
"{ROAMING}\\Microsoft\\Teams\\Service Worker\\CacheStorage",
|
||||
"{LOCAL}\\Packages\\MSTeams_*\\LocalCache"),
|
||||
contents_only=True, processes=("ms-teams.exe", "teams.exe")),
|
||||
Rule("app.telegram", "Telegram Desktop — кеш медиа", C.APPS, R.LOW,
|
||||
"Скачанные фото и видео. При необходимости подтянутся с сервера заново.",
|
||||
("{ROAMING}\\Telegram Desktop\\tdata\\user_data\\cache",
|
||||
"{ROAMING}\\Telegram Desktop\\tdata\\user_data\\media_cache",
|
||||
"{ROAMING}\\Telegram Desktop\\tdata\\emoji"),
|
||||
contents_only=True, processes=("telegram.exe",)),
|
||||
Rule("app.spotify", "Spotify — кеш треков", C.APPS, R.SAFE,
|
||||
"Локальный кеш прослушанной музыки.",
|
||||
("{LOCAL}\\Spotify\\Data", "{LOCAL}\\Spotify\\Storage",
|
||||
"{ROAMING}\\Spotify\\Data"), contents_only=True, processes=("spotify.exe",)),
|
||||
Rule("app.steam", "Steam — кеш и логи", C.APPS, R.LOW,
|
||||
"htmlcache, shadercache, логи и незавершённые загрузки.",
|
||||
("{LOCAL}\\Steam\\htmlcache", "{PROGRAMFILES86}\\Steam\\logs",
|
||||
"{PROGRAMFILES86}\\Steam\\appcache\\httpcache",
|
||||
"{PROGRAMFILES86}\\Steam\\steamapps\\shadercache",
|
||||
"{PROGRAMFILES86}\\Steam\\steamapps\\temp",
|
||||
"{PROGRAMFILES86}\\Steam\\steamapps\\downloading"),
|
||||
contents_only=True, processes=("steam.exe",)),
|
||||
Rule("app.epic", "Epic Games — webcache", C.APPS, R.SAFE,
|
||||
"Кеш встроенного браузера лаунчера.",
|
||||
("{LOCAL}\\EpicGamesLauncher\\Saved\\webcache",
|
||||
"{LOCAL}\\EpicGamesLauncher\\Saved\\webcache_*",
|
||||
"{LOCAL}\\EpicGamesLauncher\\Saved\\Logs"), contents_only=True),
|
||||
Rule("app.battlenet", "Battle.net — кеш", C.APPS, R.SAFE,
|
||||
"Кеш агента обновлений Blizzard.",
|
||||
("{ROAMING}\\Battle.net\\Cache", "{PROGRAMDATA}\\Battle.net\\Agent\\data"),
|
||||
contents_only=True),
|
||||
Rule("app.origin", "EA / Origin — кеш", C.APPS, R.SAFE,
|
||||
"Кеш лаунчеров EA.",
|
||||
("{ROAMING}\\Origin\\Cache", "{LOCAL}\\Origin", "{LOCAL}\\Electronic Arts\\EA Desktop\\cache"),
|
||||
contents_only=True),
|
||||
Rule("app.adobe", "Adobe — медиакеш", C.APPS, R.LOW,
|
||||
"Media Cache Files Premiere/After Effects. Пересоздаётся при открытии проекта, "
|
||||
"но первый предпросмотр будет медленнее.",
|
||||
("{ROAMING}\\Adobe\\Common\\Media Cache Files",
|
||||
"{ROAMING}\\Adobe\\Common\\Media Cache",
|
||||
"{ROAMING}\\Adobe\\Common\\Peak Files",
|
||||
"{LOCAL}\\Adobe\\*\\Cache"), contents_only=True),
|
||||
Rule("app.obs", "OBS Studio — логи и профили сбоев", C.APPS, R.SAFE,
|
||||
"Логи трансляций и crash-репорты.",
|
||||
("{ROAMING}\\obs-studio\\logs", "{ROAMING}\\obs-studio\\crashes"), contents_only=True),
|
||||
Rule("app.zoom", "Zoom — данные и логи", C.APPS, R.LOW,
|
||||
"Кеш конференций и логи клиента.",
|
||||
("{ROAMING}\\Zoom\\logs", "{ROAMING}\\Zoom\\data"), contents_only=True),
|
||||
Rule("app.office", "Microsoft Office — кеш документов", C.APPS, R.LOW,
|
||||
"Кеш облачных документов Office. Несинхронизированные правки будут потеряны.",
|
||||
("{LOCAL}\\Microsoft\\Office\\*\\OfficeFileCache",
|
||||
"{LOCAL}\\Microsoft\\Office\\*\\WebServiceCache"), contents_only=True,
|
||||
processes=("winword.exe", "excel.exe")),
|
||||
Rule("app.onedrive", "OneDrive — логи", C.APPS, R.SAFE,
|
||||
"Телеметрия и логи синхронизации.",
|
||||
("{LOCAL}\\Microsoft\\OneDrive\\logs",), contents_only=True),
|
||||
Rule("app.nvidia.cache", "NVIDIA — кеш шейдеров", C.APPS, R.SAFE,
|
||||
"DXCache / GLCache / NV_Cache. Пересобирается играми автоматически.",
|
||||
("{LOCAL}\\NVIDIA\\DXCache", "{LOCAL}\\NVIDIA\\GLCache",
|
||||
"{LOCAL}\\NVIDIA Corporation\\NV_Cache", "{LOCAL}\\NVIDIA\\ComputeCache"),
|
||||
contents_only=True),
|
||||
Rule("app.amd.cache", "AMD — кеш шейдеров", C.APPS, R.SAFE,
|
||||
"DxCache и GLCache драйвера AMD.",
|
||||
("{LOCAL}\\AMD\\DxCache", "{LOCAL}\\AMD\\GLCache", "{LOCAL}\\AMD\\DxcCache"),
|
||||
contents_only=True),
|
||||
Rule("app.electron.generic", "Прочие Electron-приложения — кеш", C.APPS, R.LOW,
|
||||
"Автоматически найденные каталоги Cache/GPUCache в данных установленных приложений.",
|
||||
("{ROAMING}\\*\\Cache\\Cache_Data", "{ROAMING}\\*\\GPUCache",
|
||||
"{ROAMING}\\*\\Code Cache\\js", "{LOCAL}\\*\\GPUCache"),
|
||||
contents_only=True, min_size=10 * 1024 * 1024),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------ Пакетные менеджеры --
|
||||
|
||||
PKG_RULES: list[Rule] = [
|
||||
Rule("pkg.chocolatey", "Chocolatey — кеш загрузок", C.PACKAGE_MANAGERS, R.SAFE,
|
||||
"Скачанные установщики пакетов choco.",
|
||||
("{LOCAL}\\Temp\\chocolatey", "{PROGRAMDATA}\\chocolatey\\lib-bkp"), contents_only=True),
|
||||
Rule("pkg.scoop", "Scoop — кеш и старые версии", C.PACKAGE_MANAGERS, R.LOW,
|
||||
"cache/ с архивами и apps/*/старые версии приложений.",
|
||||
("{HOME}\\scoop\\cache", "{PROGRAMDATA}\\scoop\\cache"), contents_only=True),
|
||||
Rule("pkg.winget", "winget — кеш установщиков", C.PACKAGE_MANAGERS, R.SAFE,
|
||||
"Временные файлы Windows Package Manager.",
|
||||
("{LOCAL}\\Packages\\Microsoft.DesktopAppInstaller_*\\LocalState\\TempState",
|
||||
"{LOCAL}\\Temp\\WinGet"), contents_only=True),
|
||||
Rule("pkg.msi.cache", "Кеш загруженных установщиков", C.PACKAGE_MANAGERS, R.LOW,
|
||||
"Package Cache — дистрибутивы Visual C++, .NET и подобных бандлов.",
|
||||
("{PROGRAMDATA}\\Package Cache",), contents_only=True, needs_admin=True,
|
||||
min_size=100 * 1024 * 1024),
|
||||
]
|
||||
|
||||
# ------------------------------------------- Артефакты внутри проектов ------
|
||||
#
|
||||
# Их ищет devscan.py: рекурсивный обход выбранных каталогов.
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectRule:
|
||||
name: str # имя каталога/файла-артефакта
|
||||
title: str
|
||||
risk: Risk
|
||||
detail: str
|
||||
marker: tuple[str, ...] = () # файлы рядом, подтверждающие тип проекта
|
||||
inner: tuple[str, ...] = () # файлы внутри каталога, подтверждающие находку
|
||||
stop: bool = True # не заходить внутрь найденного каталога
|
||||
ecosystem: str = ""
|
||||
|
||||
|
||||
PROJECT_RULES: tuple[ProjectRule, ...] = (
|
||||
ProjectRule("node_modules", "node_modules", R.LOW,
|
||||
"Зависимости Node. Восстанавливаются командой npm/yarn/pnpm install.",
|
||||
("package.json",), stop=True, ecosystem="JavaScript"),
|
||||
ProjectRule(".next", "Сборка Next.js", R.SAFE,
|
||||
"Кеш и артефакты сборки Next.js.", ("package.json",), ecosystem="JavaScript"),
|
||||
ProjectRule(".nuxt", "Сборка Nuxt", R.SAFE, "Артефакты Nuxt.", ("package.json",),
|
||||
ecosystem="JavaScript"),
|
||||
ProjectRule(".output", "Вывод Nitro/Nuxt", R.SAFE, "Собранный сервер Nitro.",
|
||||
("package.json",), ecosystem="JavaScript"),
|
||||
ProjectRule(".turbo", "Кеш Turborepo", R.SAFE, "Кеш задач Turborepo.", ("package.json",),
|
||||
ecosystem="JavaScript"),
|
||||
ProjectRule(".parcel-cache", "Кеш Parcel", R.SAFE, "Кеш бандлера Parcel.", ("package.json",),
|
||||
ecosystem="JavaScript"),
|
||||
ProjectRule(".angular", "Кеш Angular CLI", R.SAFE, "Кеш сборки Angular.", ("angular.json",),
|
||||
ecosystem="JavaScript"),
|
||||
ProjectRule(".svelte-kit", "Сборка SvelteKit", R.SAFE, "Артефакты SvelteKit.",
|
||||
("package.json",), ecosystem="JavaScript"),
|
||||
ProjectRule(".astro", "Кеш Astro", R.SAFE, "Служебные типы и кеш Astro.", ("package.json",),
|
||||
ecosystem="JavaScript"),
|
||||
ProjectRule(".docusaurus", "Кеш Docusaurus", R.SAFE, "Кеш генератора документации.",
|
||||
("package.json",), ecosystem="JavaScript"),
|
||||
ProjectRule("storybook-static", "Сборка Storybook", R.SAFE, "Статическая сборка Storybook.",
|
||||
("package.json",), ecosystem="JavaScript"),
|
||||
ProjectRule("coverage", "Отчёты о покрытии", R.SAFE, "Результаты прогона тестов.",
|
||||
(), ecosystem="Тесты"),
|
||||
ProjectRule(".nyc_output", "Сырые данные покрытия", R.SAFE, "Промежуточные данные nyc.",
|
||||
(), ecosystem="Тесты"),
|
||||
ProjectRule("__pycache__", "Байт-код Python", R.SAFE,
|
||||
"Скомпилированные .pyc. Пересоздаются при запуске.", (), ecosystem="Python"),
|
||||
ProjectRule(".pytest_cache", "Кеш pytest", R.SAFE, "Кеш последнего прогона тестов.",
|
||||
(), ecosystem="Python"),
|
||||
ProjectRule(".mypy_cache", "Кеш mypy", R.SAFE, "Инкрементальный кеш проверки типов.",
|
||||
(), ecosystem="Python"),
|
||||
ProjectRule(".ruff_cache", "Кеш ruff", R.SAFE, "Кеш линтера ruff.", (), ecosystem="Python"),
|
||||
ProjectRule(".tox", "Окружения tox", R.LOW, "Виртуальные окружения матрицы тестов.",
|
||||
(), ecosystem="Python"),
|
||||
ProjectRule(".venv", "Виртуальное окружение", R.MEDIUM,
|
||||
"Пересоздаётся из requirements.txt / pyproject.toml, но не мгновенно.",
|
||||
(), inner=("pyvenv.cfg",), ecosystem="Python"),
|
||||
ProjectRule("venv", "Виртуальное окружение", R.MEDIUM,
|
||||
"Пересоздаётся из requirements.txt / pyproject.toml.",
|
||||
(), inner=("pyvenv.cfg",), ecosystem="Python"),
|
||||
ProjectRule("env", "Виртуальное окружение", R.MEDIUM,
|
||||
"Пересоздаётся из requirements.txt / pyproject.toml.",
|
||||
(), inner=("pyvenv.cfg",), ecosystem="Python"),
|
||||
ProjectRule(".conda", "Окружение conda в проекте", R.MEDIUM,
|
||||
"Локальное окружение conda.", (), inner=("conda-meta",), ecosystem="Python"),
|
||||
ProjectRule(".ipynb_checkpoints", "Чекпоинты Jupyter", R.SAFE, "Автосохранения ноутбуков.",
|
||||
(), ecosystem="Python"),
|
||||
ProjectRule("target", "Сборка Rust / Maven", R.LOW,
|
||||
"Артефакты cargo build или Maven. Пересборка может занять минуты.",
|
||||
("Cargo.toml", "pom.xml"), ecosystem="Rust/Java"),
|
||||
ProjectRule("bin", "Сборка .NET (bin)", R.LOW, "Выходные файлы сборки .NET.",
|
||||
("*.csproj", "*.sln", "*.fsproj", "*.vbproj"), ecosystem=".NET"),
|
||||
ProjectRule("obj", "Промежуточные файлы .NET", R.SAFE, "Объектные файлы сборки .NET.",
|
||||
("*.csproj", "*.sln", "*.fsproj", "*.vbproj"), ecosystem=".NET"),
|
||||
ProjectRule(".vs", "Служебный каталог Visual Studio", R.SAFE,
|
||||
"Кеш IntelliSense и состояние решения.", (), ecosystem=".NET"),
|
||||
ProjectRule("build", "Каталог сборки", R.LOW,
|
||||
"Выход CMake/Gradle/Flutter. Убедитесь, что это не исходники.",
|
||||
("CMakeLists.txt", "build.gradle", "build.gradle.kts", "pubspec.yaml",
|
||||
"package.json", "setup.py", "pyproject.toml"), ecosystem="Сборка"),
|
||||
ProjectRule("cmake-build-debug", "Сборка CMake (Debug)", R.SAFE, "Каталог сборки CLion.",
|
||||
(), ecosystem="C/C++"),
|
||||
ProjectRule("cmake-build-release", "Сборка CMake (Release)", R.SAFE, "Каталог сборки CLion.",
|
||||
(), ecosystem="C/C++"),
|
||||
ProjectRule("CMakeFiles", "Служебные файлы CMake", R.SAFE, "Промежуточные файлы генератора.",
|
||||
(), ecosystem="C/C++"),
|
||||
ProjectRule(".gradle", "Кеш Gradle в проекте", R.SAFE, "Локальный кеш задач Gradle.",
|
||||
(), ecosystem="Java/Android"),
|
||||
ProjectRule(".dart_tool", "Служебные файлы Dart", R.SAFE, "Кеш пакетов и сборки Dart.",
|
||||
("pubspec.yaml",), ecosystem="Flutter"),
|
||||
ProjectRule("Pods", "CocoaPods", R.LOW, "Зависимости iOS-проекта.", (), ecosystem="Mobile"),
|
||||
ProjectRule("vendor", "Зависимости Composer/Go/Bundler", R.LOW,
|
||||
"Восстанавливается composer install / bundle install.",
|
||||
("composer.json", "Gemfile", "go.mod"), ecosystem="PHP/Ruby/Go"),
|
||||
ProjectRule("Library", "Кеш проекта Unity", R.LOW,
|
||||
"Импортированные ассеты Unity. Перегенерируется при открытии проекта (долго).",
|
||||
("Assets", "ProjectSettings"), inner=("ArtifactDB", "ScriptAssemblies",
|
||||
"PackageCache"), ecosystem="Unity"),
|
||||
ProjectRule("Intermediate", "Промежуточные файлы Unreal", R.SAFE,
|
||||
"Артефакты компиляции Unreal Engine.", (), ecosystem="Unreal"),
|
||||
ProjectRule("DerivedDataCache", "DDC проекта Unreal", R.LOW,
|
||||
"Производные данные ассетов Unreal.", (), ecosystem="Unreal"),
|
||||
ProjectRule(".terraform", "Плагины Terraform в проекте", R.LOW,
|
||||
"Скачанные провайдеры. Восстанавливается terraform init.", (), ecosystem="DevOps"),
|
||||
ProjectRule(".serverless", "Артефакты Serverless Framework", R.SAFE,
|
||||
"Сгенерированные пакеты деплоя.", (), ecosystem="DevOps"),
|
||||
ProjectRule(".cache", "Обобщённый кеш инструментов", R.SAFE,
|
||||
"Кеш сборщиков и линтеров внутри проекта.", (), ecosystem="Сборка"),
|
||||
ProjectRule("dist", "Каталог сборки dist", R.LOW,
|
||||
"Собранные файлы. Убедитесь, что это не исходники, которые вы коммитите.",
|
||||
("package.json", "setup.py", "pyproject.toml", "rollup.config.js", "vite.config.js",
|
||||
"vite.config.ts"), ecosystem="Сборка"),
|
||||
ProjectRule("out", "Каталог сборки out", R.LOW, "Выход сборщика.",
|
||||
("package.json", "tsconfig.json"), ecosystem="Сборка"),
|
||||
)
|
||||
|
||||
# Файлы-артефакты (не каталоги), которые ищем в проектах
|
||||
PROJECT_FILES: tuple[tuple[str, str, Risk, str], ...] = (
|
||||
("*.pyc", "Скомпилированный Python вне __pycache__", R.SAFE, "Python"),
|
||||
("npm-debug.log*", "Лог сбоя npm", R.SAFE, "JavaScript"),
|
||||
("yarn-error.log", "Лог сбоя yarn", R.SAFE, "JavaScript"),
|
||||
(".eslintcache", "Кеш ESLint", R.SAFE, "JavaScript"),
|
||||
("tsconfig.tsbuildinfo", "Инкрементальный кеш TypeScript", R.SAFE, "JavaScript"),
|
||||
("*.stackdump", "Дамп аварийного завершения", R.SAFE, "Прочее"),
|
||||
("Thumbs.db", "Кеш эскизов Windows", R.SAFE, "Прочее"),
|
||||
("desktop.ini", "", R.HIGH, ""), # намеренно не предлагаем
|
||||
)
|
||||
|
||||
# Каталоги, внутрь которых не спускаемся при поиске артефактов, даже если сам
|
||||
# каталог не подошёл ни под одно правило: внутри всё равно нет «своих» проектов.
|
||||
NEVER_DESCEND = {
|
||||
"node_modules", "__pycache__", ".git", ".svn", ".hg", "site-packages",
|
||||
"dist-packages", ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
|
||||
".ruff_cache", ".gradle", ".vs", ".idea", "pods", "bower_components",
|
||||
}
|
||||
|
||||
# Каталоги, в которые никогда не заходим при рекурсивном поиске
|
||||
SKIP_DIRS = {
|
||||
"$recycle.bin", "system volume information", "windows", "winsxs", "program files",
|
||||
"program files (x86)", "programdata", "$windows.~bt", "$windows.~ws", "appdata",
|
||||
"onedrive", "recovery", "boot", "perflogs", "msocache", "config.msi",
|
||||
}
|
||||
|
||||
|
||||
def all_rules() -> list[Rule]:
|
||||
return (WINDOWS_RULES + LOG_RULES + BROWSER_RULES + DEV_CACHE_RULES
|
||||
+ IDE_RULES + APP_RULES + PKG_RULES)
|
||||
|
||||
|
||||
def rules_for(categories: set[Category]) -> list[Rule]:
|
||||
return [r for r in all_rules() if r.category in categories]
|
||||
328
sysdevcleaner/core/scanner.py
Normal file
328
sysdevcleaner/core/scanner.py
Normal 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):]
|
||||
396
sysdevcleaner/core/util.py
Normal file
396
sysdevcleaner/core/util.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Низкоуровневые утилиты: размеры, пути, защита, корзина."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable, Iterable
|
||||
|
||||
IS_WIN = sys.platform == "win32"
|
||||
|
||||
# ---------------------------------------------------------------- размеры ----
|
||||
|
||||
_UNITS = ("Б", "КБ", "МБ", "ГБ", "ТБ", "ПБ")
|
||||
|
||||
|
||||
def human(size: float) -> str:
|
||||
"""1536 -> '1.5 КБ'."""
|
||||
size = float(size or 0)
|
||||
neg = size < 0
|
||||
size = abs(size)
|
||||
i = 0
|
||||
while size >= 1024 and i < len(_UNITS) - 1:
|
||||
size /= 1024.0
|
||||
i += 1
|
||||
txt = f"{size:.0f} {_UNITS[i]}" if (i == 0 or size >= 100) else f"{size:.1f} {_UNITS[i]}"
|
||||
return ("-" if neg else "") + txt
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Cancel:
|
||||
"""Кооперативная отмена длительных обходов."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._e = threading.Event()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._e.set()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._e.clear()
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
return self._e.is_set()
|
||||
|
||||
def check(self) -> None:
|
||||
if self._e.is_set():
|
||||
raise Cancelled()
|
||||
|
||||
|
||||
def dir_stats(
|
||||
path: str,
|
||||
cancel: "Cancel | None" = None,
|
||||
max_entries: int = 400_000,
|
||||
follow_links: bool = False,
|
||||
) -> tuple[int, int, int, float]:
|
||||
"""Размер каталога: (байты, файлов, папок, max_mtime). Устойчиво к ошибкам."""
|
||||
total = files = dirs = 0
|
||||
newest = 0.0
|
||||
seen = 0
|
||||
stack = [path]
|
||||
while stack:
|
||||
if cancel is not None and cancel.cancelled:
|
||||
break
|
||||
cur = stack.pop()
|
||||
try:
|
||||
it = os.scandir(cur)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
with it:
|
||||
while True:
|
||||
try:
|
||||
entry = next(it)
|
||||
except StopIteration:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
seen += 1
|
||||
if seen > max_entries:
|
||||
stack.clear()
|
||||
break
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=follow_links):
|
||||
dirs += 1
|
||||
stack.append(entry.path)
|
||||
elif entry.is_file(follow_symlinks=follow_links):
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
files += 1
|
||||
total += st.st_size
|
||||
if st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if not newest:
|
||||
try:
|
||||
newest = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
newest = 0.0
|
||||
return total, files, dirs, newest
|
||||
|
||||
|
||||
def path_size(path: str, cancel: "Cancel | None" = None) -> tuple[int, int, int, float]:
|
||||
"""Размер файла или каталога в том же формате, что и dir_stats."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return 0, 0, 0, 0.0
|
||||
if stat.S_ISDIR(st.st_mode):
|
||||
return dir_stats(path, cancel)
|
||||
return st.st_size, 1, 0, st.st_mtime
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ пути ----
|
||||
|
||||
|
||||
def env(name: str) -> str:
|
||||
return os.environ.get(name, "") or ""
|
||||
|
||||
|
||||
def expand(path: str) -> str:
|
||||
return os.path.expandvars(os.path.expanduser(path))
|
||||
|
||||
|
||||
def known_paths() -> dict[str, str]:
|
||||
home = os.path.expanduser("~")
|
||||
local = env("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
roaming = env("APPDATA") or os.path.join(home, "AppData", "Roaming")
|
||||
return {
|
||||
"HOME": home,
|
||||
"LOCAL": local,
|
||||
"LOCALLOW": os.path.join(os.path.dirname(local), "LocalLow") if local else "",
|
||||
"ROAMING": roaming,
|
||||
"TEMP": env("TEMP") or os.path.join(local, "Temp"),
|
||||
"WINDIR": env("WINDIR") or "C:\\Windows",
|
||||
"SYSTEMDRIVE": (env("SYSTEMDRIVE") or "C:") + "\\",
|
||||
"PROGRAMDATA": env("PROGRAMDATA") or "C:\\ProgramData",
|
||||
"PROGRAMFILES": env("PROGRAMFILES") or "C:\\Program Files",
|
||||
"PROGRAMFILES86": env("ProgramFiles(x86)") or "C:\\Program Files (x86)",
|
||||
"PUBLIC": env("PUBLIC") or "C:\\Users\\Public",
|
||||
}
|
||||
|
||||
|
||||
def drives() -> list[str]:
|
||||
"""Список смонтированных дисков ('C:\\', 'D:\\')."""
|
||||
if not IS_WIN:
|
||||
return ["/"]
|
||||
out = []
|
||||
try:
|
||||
mask = ctypes.windll.kernel32.GetLogicalDrives()
|
||||
except Exception:
|
||||
mask = 0
|
||||
for i, letter in enumerate(string.ascii_uppercase):
|
||||
if mask >> i & 1:
|
||||
root = f"{letter}:\\"
|
||||
try:
|
||||
dtype = ctypes.windll.kernel32.GetDriveTypeW(ctypes.c_wchar_p(root))
|
||||
except Exception:
|
||||
dtype = 3
|
||||
if dtype in (2, 3, 4): # removable / fixed / network
|
||||
out.append(root)
|
||||
return out or ["C:\\"]
|
||||
|
||||
|
||||
def disk_usage(root: str) -> dict:
|
||||
try:
|
||||
u = shutil.disk_usage(root)
|
||||
return {
|
||||
"root": root,
|
||||
"total": u.total,
|
||||
"used": u.used,
|
||||
"free": u.free,
|
||||
"percent": (u.used / u.total * 100) if u.total else 0.0,
|
||||
}
|
||||
except OSError:
|
||||
return {"root": root, "total": 0, "used": 0, "free": 0, "percent": 0.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- защита -----
|
||||
|
||||
|
||||
def _norm(p: str) -> str:
|
||||
return os.path.normcase(os.path.abspath(p)).rstrip("\\/")
|
||||
|
||||
|
||||
def _protected_roots() -> set[str]:
|
||||
k = known_paths()
|
||||
raw = [
|
||||
k["WINDIR"],
|
||||
os.path.join(k["WINDIR"], "System32"),
|
||||
os.path.join(k["WINDIR"], "SysWOW64"),
|
||||
k["PROGRAMFILES"],
|
||||
k["PROGRAMFILES86"],
|
||||
k["SYSTEMDRIVE"],
|
||||
k["HOME"],
|
||||
k["LOCAL"],
|
||||
k["ROAMING"],
|
||||
k["PROGRAMDATA"],
|
||||
os.path.join(k["HOME"], "Documents"),
|
||||
os.path.join(k["HOME"], "Desktop"),
|
||||
os.path.join(k["HOME"], "Downloads"),
|
||||
os.path.join(k["HOME"], "Pictures"),
|
||||
os.path.join(k["HOME"], "Videos"),
|
||||
os.path.join(k["HOME"], "Music"),
|
||||
os.path.join(k["HOME"], "OneDrive"),
|
||||
]
|
||||
out = {_norm(p) for p in raw if p}
|
||||
out |= {_norm(d) for d in drives()}
|
||||
return out
|
||||
|
||||
|
||||
PROTECTED = _protected_roots()
|
||||
|
||||
|
||||
def is_protected(path: str) -> bool:
|
||||
"""Нельзя удалять сам путь (но можно то, что глубже внутри)."""
|
||||
n = _norm(path)
|
||||
if not n or len(n) <= 3:
|
||||
return True
|
||||
return n in PROTECTED
|
||||
|
||||
|
||||
def is_safe_target(path: str) -> tuple[bool, str]:
|
||||
"""Финальная проверка перед удалением."""
|
||||
if not path:
|
||||
return False, "пустой путь"
|
||||
n = _norm(path)
|
||||
if len(n) <= 3:
|
||||
return False, "корень диска"
|
||||
if is_protected(path):
|
||||
return False, "системный/пользовательский корневой каталог"
|
||||
depth = n.replace("/", "\\").count("\\")
|
||||
if depth < 2:
|
||||
return False, "слишком высокий уровень в дереве"
|
||||
return True, ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------- удаление ---
|
||||
|
||||
|
||||
def _on_rm_error(func: Callable, path: str, exc_info) -> None:
|
||||
"""Снимаем read-only и пробуем ещё раз."""
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def delete_path(path: str, to_recycle_bin: bool = True) -> tuple[bool, str]:
|
||||
"""Удалить файл/каталог. Возвращает (успех, сообщение об ошибке)."""
|
||||
if not os.path.exists(path):
|
||||
return True, ""
|
||||
if to_recycle_bin and IS_WIN:
|
||||
ok, err = _recycle(path)
|
||||
if ok:
|
||||
return True, ""
|
||||
# корзина не справилась (сетевой диск, слишком большой объект) — не насилуем
|
||||
return False, err
|
||||
try:
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
shutil.rmtree(path, onerror=_on_rm_error)
|
||||
else:
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
except OSError:
|
||||
pass
|
||||
os.remove(path)
|
||||
if os.path.exists(path):
|
||||
return False, "путь остался на месте"
|
||||
return True, ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _recycle(path: str) -> tuple[bool, str]:
|
||||
"""SHFileOperationW с флагом FOF_ALLOWUNDO."""
|
||||
try:
|
||||
from ctypes import wintypes
|
||||
|
||||
class SHFILEOPSTRUCTW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("hwnd", wintypes.HWND),
|
||||
("wFunc", wintypes.UINT),
|
||||
("pFrom", wintypes.LPCWSTR),
|
||||
("pTo", wintypes.LPCWSTR),
|
||||
("fFlags", ctypes.c_uint16),
|
||||
("fAnyOperationsAborted", wintypes.BOOL),
|
||||
("hNameMappings", ctypes.c_void_p),
|
||||
("lpszProgressTitle", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
FO_DELETE = 3
|
||||
FOF_ALLOWUNDO = 0x0040
|
||||
FOF_NOCONFIRMATION = 0x0010
|
||||
FOF_NOERRORUI = 0x0400
|
||||
FOF_SILENT = 0x0004
|
||||
FOF_NOCONFIRMMKDIR = 0x0200
|
||||
|
||||
op = SHFILEOPSTRUCTW()
|
||||
op.wFunc = FO_DELETE
|
||||
op.pFrom = os.path.abspath(path) + "\0\0"
|
||||
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI
|
||||
| FOF_SILENT | FOF_NOCONFIRMMKDIR)
|
||||
res = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
|
||||
if res == 0 and not op.fAnyOperationsAborted:
|
||||
return True, ""
|
||||
return False, f"SHFileOperation код {res}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def clear_contents(path: str, to_recycle_bin: bool = True) -> tuple[int, int, list[str]]:
|
||||
"""Очистить содержимое каталога, сам каталог оставить.
|
||||
|
||||
Возвращает (удалено, пропущено, ошибки).
|
||||
"""
|
||||
done = skipped = 0
|
||||
errs: list[str] = []
|
||||
try:
|
||||
entries = list(os.scandir(path))
|
||||
except OSError as e:
|
||||
return 0, 0, [f"{path}: {e}"]
|
||||
for entry in entries:
|
||||
ok, err = delete_path(entry.path, to_recycle_bin)
|
||||
if ok:
|
||||
done += 1
|
||||
else:
|
||||
skipped += 1
|
||||
if err and len(errs) < 20:
|
||||
errs.append(f"{entry.name}: {err}")
|
||||
return done, skipped, errs
|
||||
|
||||
|
||||
# ------------------------------------------------------------- процессы -----
|
||||
|
||||
|
||||
def running_processes() -> set[str]:
|
||||
"""Имена запущенных процессов в нижнем регистре."""
|
||||
names: set[str] = set()
|
||||
if not IS_WIN:
|
||||
return names
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FO", "CSV", "/NH"],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
errors="ignore",
|
||||
).stdout
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('"'):
|
||||
names.add(line.split('","')[0].strip('"').lower())
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
if not IS_WIN:
|
||||
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
|
||||
try:
|
||||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def open_in_explorer(path: str) -> None:
|
||||
try:
|
||||
if IS_WIN:
|
||||
if os.path.isdir(path):
|
||||
os.startfile(path) # noqa: S606
|
||||
else:
|
||||
subprocess.Popen(["explorer", "/select,", os.path.abspath(path)])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def iter_globs(patterns: Iterable[str]) -> list[str]:
|
||||
import glob as _g
|
||||
out: list[str] = []
|
||||
for p in patterns:
|
||||
try:
|
||||
out.extend(_g.glob(expand(p), recursive=True))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
Reference in New Issue
Block a user