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

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

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

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

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

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

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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
build/
dist/
fonts/
__pycache__/
*.pyc
*.spec

276
README.md Normal file
View File

@@ -0,0 +1,276 @@
# SysDevCleaner
<img src="sysdevcleaner.png" width="96" align="right" alt="значок SysDevCleaner">
Приложение для полного анализа Windows: находит кеши, временный мусор, остатки
разработки и тяжёлые программы, объясняет, что каждая находка означает, и даёт
удалить ровно то, что вы отметили.
Нужен Python 3.10+ и одна зависимость — [Dear PyGui](https://github.com/hoffstadt/DearPyGui)
(`pip install dearpygui`); `SysDevCleaner.bat` ставит её сам при первом запуске.
Если Dear PyGui недоступен, приложение откатывается на запасной интерфейс
на tkinter из стандартной библиотеки.
## Запуск
| Что запустить | Зачем |
|---|---|
| `SysDevCleaner.bat` | запуск с графическим интерфейсом |
| `SysDevCleaner-no-admin.bat` | то же, но без повышения прав |
| `sysdevcleaner-cli.bat` | консольный режим |
| `python run.py` | запуск из исходников |
| `python -m sysdevcleaner` | то же самое |
| `build-exe.bat` | собрать `dist\SysDevCleaner.exe` |
| `python make_icon.py` | перерисовать значок `sysdevcleaner.ico` |
## Права администратора
Приложение **всегда запускается с правами администратора**: без них недоступны
`Windows\Temp`, кеш обновлений Windows, Prefetch, `Windows.old` и история
Defender — а это обычно самая крупная добыча (у автора на тестовой машине
8.5 ГБ из 17.5 ГБ).
Как это сделано:
* собранный `SysDevCleaner.exe` несёт манифест UAC (`requireAdministrator`) —
Windows поднимает права ещё до старта Python;
* при запуске из исходников приложение само перезапускается через
`ShellExecuteW` с глаголом `runas` (см. `sysdevcleaner/winui.py`).
Отказ в UAC не фатален: программа продолжит работу с обычными правами, честно
напишет об этом в заголовке окна и в шапке, а недоступные пункты покажет
отдельной рекомендацией. Чтобы сознательно запуститься без повышения —
`SysDevCleaner-no-admin.bat` или переменная окружения `SYSDEVCLEAN_NO_ELEVATE=1`.
Консольный режим права не повышает: elevation открыл бы отдельное окно, и вывод
команды потерялся бы. Запускайте `sysdevcleaner-cli.bat` из консоли администратора,
если нужен полный охват.
## Заголовок окна
`dpg.create_viewport(title=...)` отдаёт заголовок в ANSI-функцию Windows как
UTF-8 байты, из-за чего русское название превращалось в
«SysDevCleaner вЂ" анализ...». Поэтому окно создаётся со служебным
ASCII-заголовком, а настоящее имя ставится через `SetWindowTextW` (UTF-16) —
`sysdevcleaner/winui.py`.
## Выбор дисков
По умолчанию сканируются все диски. Чтобы ограничить область, снимите лишние
флажки в строке **«Сканировать диски»** под заголовком окна — рядом появится
подпись вроде «только D:». Кнопка **«все»** возвращает полный охват.
Фильтр применяется ко всему сразу: к правилам путей, к поиску проектов, крупных
файлов и дубликатов, к списку дисков в отчёте. «Просканировать только D:»
действительно не трогает C:.
На вкладке «Обзор» у каждого диска есть кнопка **«только этот»** — один клик
ограничивает область этим диском и сразу запускает сканирование.
В консоли то же самое делает `--drive`:
```bash
python -m sysdevcleaner --drive D --categories projects
```
## Сборка в exe
```bash
build-exe.bat
```
Готовый файл появится в `dist\SysDevCleaner.exe` (~15 МБ, одним файлом).
Скрипт сам доставит PyInstaller и Dear PyGui, если их нет, **вошьёт в сборку
JetBrains Mono Nerd Font** (иначе на чужой машине без этого шрифта интерфейс
останется с кириллицей, но без иконок) и **встроит манифест UAC**, чтобы exe
сразу запрашивал права администратора.
Значок берётся из `sysdevcleaner.ico` — он ставится и на сам exe, и на окно
приложения (`create_viewport(small_icon=…, large_icon=…)`), поэтому иконка
одинакова в проводнике, на панели задач и в заголовке. Перерисовать:
`python make_icon.py` — метла рисуется геометрией, без зависимости от шрифта,
чтобы оставаться разборчивой на 16×16.
Ключи `build_exe.py`:
| Ключ | Что делает |
|---|---|
| `--onedir` | папка вместо одного файла: запускается быстрее, без распаковки во временный каталог |
| `--no-admin` | не встраивать манифест UAC (по умолчанию он есть) |
| `--no-font` | не вшивать шрифт, брать системный |
| `--console` | оставить окно консоли, чтобы видеть ошибки |
| `--icon ФАЙЛ` | свой значок `.ico` |
Шрифт можно подменить без пересборки: положите свой TTF в папку `fonts` рядом
с `SysDevCleaner.exe`.
## Шрифт и кириллица
Интерфейс построен на Dear PyGui, а тот рисует текст встроенным шрифтом
ProggyClean, в котором **нет кириллицы** — без настройки весь русский текст
превращается в пустые квадраты. Решение живёт в `sysdevcleaner/gui/fonts.py`:
1. после `create_context()`, но **до** `setup_dearpygui()` регистрируем свой TTF
внутри `dpg.font_registry()` — на шаге `setup_dearpygui()` собирается атлас
глифов, и позже добавить символы уже нельзя;
2. вызываем `dpg.bind_font()` внутри того же блока, пока шрифт заведомо жив;
3. на Dear PyGui **1.x** дополнительно просим диапазоны
`mvFontRangeHint_Default` и `mvFontRangeHint_Cyrillic`. Начиная с **2.0** эти
вызовы — no-op: диапазоны определяются автоматически по содержимому шрифта.
Мы их всё равно делаем ради совместимости с 1.x, но глушим предупреждения.
По умолчанию берётся **JetBrains Mono Nerd Font** — в нём есть и полная
кириллица, и иконочные глифы, поэтому отдельный иконочный шрифт не нужен
(иконки в `sysdevcleaner/gui/icons.py`). Если его нет, подхватывается обычный
JetBrains Mono, затем Segoe UI, Calibri, Tahoma, Arial — тогда интерфейс
останется читаемым, но без иконок.
Можно положить любой свой TTF в папку `fonts/` рядом с приложением — она имеет
приоритет над системными шрифтами.
Важная деталь: берутся только иконки из BMP (U+E000–U+F8FF). Набор Material
Design Icons в Nerd Font лежит выше U+FFFF, а Dear ImGui собран с 16-битным
`ImWchar` и такие символы не отрисует.
## Что ищется
**Система Windows** — временные файлы пользователя и системы, кеш Windows Update,
оптимизация доставки, `Windows.old`, Prefetch, дампы аварийных завершений и
`MEMORY.DMP`, отчёты WER, кеш эскизов и значков, кеш шрифтов, INetCache, кеш
шейдеров DirectX, Корзина, `$PatchCache$`, логи установки Panther, история
проверок Defender, LiveKernelReports.
**Браузеры** — Chrome, Edge, Brave, Opera, Opera GX, Vivaldi, Yandex, Chromium,
Firefox, Tor. Для каждого профиля отдельно: HTTP-кеш, Code Cache, GPUCache,
кеши шейдеров и WebGPU, Service Worker, Media Cache, IndexedDB, отчёты Crashpad.
**Кеши разработки** — npm, Yarn, pnpm, Bun, Deno, node-gyp, Electron,
Playwright/Puppeteer, Cypress, pip, Poetry, uv, Conda, PyInstaller, NuGet,
Maven, Gradle, Cargo, Go (модули и сборка), JDK, Android SDK и образы
эмулятора, Flutter/Dart, Composer, RubyGems, Terraform, Helm, vcpkg/Conan,
ccache/sccache, Unity, Unreal DDC, Docker Desktop.
**IDE** — VS Code (и Insiders/VSCodium), Cursor, Windsurf, JetBrains (индексы,
кеши, данные старых версий), Visual Studio и его установщик, Xamarin, Eclipse,
NetBeans.
**Артефакты внутри проектов** — рекурсивный обход ваших папок с кодом:
`node_modules`, `.next`, `.nuxt`, `.turbo`, `.parcel-cache`, `.angular`,
`.svelte-kit`, `.astro`, `coverage`, `__pycache__`, `.pytest_cache`,
`.mypy_cache`, `.ruff_cache`, `.tox`, `.venv`, `target`, `bin`/`obj`, `.vs`,
`build`, `cmake-build-*`, `.gradle`, `.dart_tool`, `vendor`, `Pods`,
`Library` (Unity), `Intermediate`/`DerivedDataCache` (Unreal), `.terraform`,
`dist`, `out`.
**Приложения** — Discord, Slack, Teams, Telegram, Spotify, Steam, Epic,
Battle.net, EA/Origin, Adobe Media Cache, OBS, Zoom, Office, OneDrive, кеши
шейдеров NVIDIA и AMD, плюс автоматический поиск кешей любых Electron-программ.
**Пакетные менеджеры** — Chocolatey, Scoop, winget, Package Cache.
**Дополнительно (включается в настройках)** — крупные файлы от заданного
размера, дубликаты файлов (сверка по содержимому), список установленных
программ с реальным размером на диске и давностью использования.
## Как это устроено
Каждая находка получает уровень риска, и это определяет, что приложение
отмечает само:
| Риск | Что это значит | Отмечается автоматически |
|---|---|---|
| **Безопасно** | мусор, который система пересоздаёт сама | да |
| **Низкий риск** | кеш; после удаления первый запуск будет медленнее | нет |
| **Средний риск** | потеря состояния, нужна пересборка или повторная закачка | нет |
| **Высокий риск** | можно сломать проект — только осознанное решение | нет |
Защита от ошибок:
- корни дисков, `Windows`, `Program Files`, профиль пользователя и папки
«Документы», «Рабочий стол», «Загрузки» и т.п. нельзя удалить — проверка
срабатывает и при выборе, и повторно перед самим удалением;
- для каталогов вроде `%TEMP%` удаляется **содержимое**, а не сам каталог;
- по умолчанию всё уходит **в Корзину** — можно вернуть;
- есть **пробный запуск**: показывает точный план, ничего не трогая;
- приложение предупреждает, если браузер или редактор сейчас запущен, — занятые
файлы всё равно не удалятся;
- каждая очистка пишется в журнал
`%LOCALAPPDATA%\SysDevCleaner\journal\clean-*.json` — видно, что и когда было
удалено.
## Вкладки
- **Обзор** — заполненность дисков, сводка по категориям и рекомендации:
что чистить в первую очередь, где заброшенные проекты, какие программы весят
много и давно не запускались.
- **Очистка** — находки, сгруппированные по категориям в раскрывающиеся
разделы. У каждой строки свой флажок, у раздела — флажок «отметить всю
категорию». Клик по названию показывает подробности внизу, наведение —
полный путь во всплывающей подсказке. Есть фильтр по названию и пути.
- **Программы** — установленное ПО с сортировкой по размеру, простою, имени или
издателю и поиском по названию. Колонка «простой» показывает, сколько дней не
менялись файлы программы. Клик по строке открывает папку установки, кнопка —
запускает штатный деинсталлятор.
- **Настройки** — какие категории сканировать, где искать проекты, включение
поиска крупных файлов и дубликатов.
- **Журнал** — история очисток и ошибки доступа последнего сканирования.
## Консольный режим
```bash
python -m sysdevcleaner --categories devcache,projects --top 40
```
```bash
python -m sysdevcleaner --root "D:\code" --big-files 500 --duplicates 50 --report отчёт.txt
```
```bash
python -m sysdevcleaner --clean safe --dry-run
```
Ключи:
- `--categories` — `windows,browsers,devcache,projects,apps,logs,pkg` или `all`
- `--drive БУКВА` — сканировать только этот диск (можно повторять)
- `--root ПАПКА` — где искать проекты (можно повторять)
- `--big-files МБ`, `--duplicates МБ` — включить дополнительный анализ
- `--no-programs` — не собирать список установленных программ
- `--report ФАЙЛ` — сохранить подробный отчёт
- `--clean УРОВЕНЬ` — удалить находки с риском не выше `safe`/`low`/`medium`/`high`
- `--dry-run` — показать план, ничего не удаляя
- `--permanent` — удалять мимо Корзины
- `--yes` — без подтверждения
## Структура
```
build_exe.py сборка в exe через PyInstaller
make_icon.py генерация значка
sysdevcleaner/
core/
model.py структуры данных, уровни риска и категории
rules.py каталог правил: 228 правил путей (496 шаблонов) + 43 правила артефактов
util.py размеры, защита путей, удаление в Корзину, список процессов
scanner.py раскрытие правил в находки, параллельный подсчёт размеров
devscan.py рекурсивный поиск артефактов внутри проектов
heavy.py крупные файлы и дубликаты (хеш по началу/середине/концу + полная сверка)
programs.py инвентаризация установленного ПО через реестр
advisor.py рекомендации и текстовый отчёт
cleaner.py удаление с журналом и повторной проверкой безопасности
winui.py заголовок окна через Unicode-API и повышение прав
gui/
app.py интерфейс на Dear PyGui
fonts.py подключение TTF и кириллица (см. раздел «Шрифт и кириллица»)
icons.py иконки Nerd Font
dpg_theme.py тёмная и светлая темы для Dear PyGui
app_tk.py запасной интерфейс на tkinter
theme_tk.py темы для запасного интерфейса
cli.py консольный режим
```
## Что приложение не делает
Не трогает реестр, не «оптимизирует» систему, не отключает службы и не удаляет
ничего без вашей отметки. Список установленных программ показывается для
анализа — удаление запускает штатный деинсталлятор, а не вычищает файлы само.

View File

@@ -0,0 +1,30 @@
@echo off
rem SysDevCleaner without elevation - some system folders will be unavailable
setlocal
cd /d "%~dp0"
set SYSDEVCLEAN_NO_ELEVATE=1
set PY=
where pythonw >nul 2>&1 && set PY=pythonw
if "%PY%"=="" (where py >nul 2>&1 && set PY=py -3)
if "%PY%"=="" (where python >nul 2>&1 && set PY=python)
if "%PY%"=="" (
echo Python 3.10+ not found.
pause
exit /b 1
)
start "" %PY% -m sysdevcleaner

38
SysDevCleaner.bat Normal file
View File

@@ -0,0 +1,38 @@
@echo off
rem SysDevCleaner - graphical mode (no console window)
setlocal
cd /d "%~dp0"
set PY=
where pythonw >nul 2>&1 && set PY=pythonw
if "%PY%"=="" (where py >nul 2>&1 && set PY=py -3)
if "%PY%"=="" (where python >nul 2>&1 && set PY=python)
if "%PY%"=="" (
echo Python 3.10+ not found. Install it from https://python.org and try again.
pause
exit /b 1
)
%PY% -c "import dearpygui" 2>nul
if errorlevel 1 (
echo Installing Dear PyGui, one moment...
%PY% -m pip install --quiet dearpygui
)
start "" %PY% -m sysdevcleaner

28
build-exe.bat Normal file
View File

@@ -0,0 +1,28 @@
@echo off
rem Build SysDevCleaner into dist\SysDevCleaner.exe
setlocal
cd /d "%~dp0"
set PY=
where py >nul 2>&1 && set PY=py -3
if "%PY%"=="" (where python >nul 2>&1 && set PY=python)
if "%PY%"=="" (
echo Python 3.10+ not found.
pause
exit /b 1
)
%PY% build_exe.py %*
pause

201
build_exe.py Normal file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Сборка SysDevCleaner в исполняемый файл через PyInstaller.
Примеры:
python build_exe.py один файл SysDevCleaner.exe: шрифт вшит,
запуск сразу с правами администратора
python build_exe.py --onedir папка dist\\SysDevCleaner (запускается быстрее)
python build_exe.py --no-admin без манифеста UAC
python build_exe.py --no-font не вшивать шрифт (возьмётся системный)
python build_exe.py --console оставить окно консоли (для отладки)
Права администратора нужны для Windows\\Temp, кеша обновлений, Prefetch и
Windows.old — без них самая крупная добыча остаётся недоступной. Поэтому
манифест UAC встраивается по умолчанию.
Про шрифт: без вшитого TTF на чужой машине без JetBrains Mono NF интерфейс
откатится на Segoe UI — кириллица будет, иконки пропадут. Поэтому по умолчанию
скрипт копирует найденный Nerd Font в папку ``fonts`` и вкладывает её в сборку.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
FONTS_DIR = os.path.join(ROOT, "fonts")
ICON = os.path.join(ROOT, "sysdevcleaner.ico")
NAME = "SysDevCleaner"
# что искать для вшивания: обычное начертание + жирное + моноширинное
WANTED_FONTS = (
"JetBrainsMonoNerdFont-Regular.ttf",
"JetBrainsMonoNerdFont-Bold.ttf",
"JetBrainsMonoNerdFontMono-Regular.ttf",
)
FALLBACK_FONTS = ("segoeui.ttf", "segoeuib.ttf", "consola.ttf")
def log(msg: str) -> None:
print(f" {msg}", flush=True)
def ensure_pyinstaller() -> bool:
try:
import PyInstaller # noqa: F401
return True
except ImportError:
pass
log("PyInstaller не найден, устанавливаю…")
res = subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"])
if res.returncode != 0:
print("Не удалось установить PyInstaller.", file=sys.stderr)
return False
return True
def ensure_dearpygui() -> bool:
try:
import dearpygui # noqa: F401
return True
except ImportError:
pass
log("Dear PyGui не найден, устанавливаю…")
res = subprocess.run([sys.executable, "-m", "pip", "install", "dearpygui"])
return res.returncode == 0
def system_font_dirs() -> list[str]:
windir = os.environ.get("WINDIR", r"C:\Windows")
local = os.environ.get("LOCALAPPDATA", "")
dirs = [os.path.join(windir, "Fonts")]
if local:
dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts"))
return [d for d in dirs if os.path.isdir(d)]
def _copy_font(name: str) -> bool:
"""Скопировать один системный шрифт в fonts/. True, если он там оказался."""
target = os.path.join(FONTS_DIR, name)
if os.path.isfile(target):
return True
for directory in system_font_dirs():
src = os.path.join(directory, name)
if not os.path.isfile(src):
continue
try:
shutil.copy2(src, target)
log(f"шрифт: {name}")
return True
except OSError as e:
log(f"не скопировался {name}: {e}")
return False
return False
def collect_fonts() -> int:
"""Сложить шрифты в fonts/ рядом со скриптом. Возвращает их количество.
Сначала пробуем Nerd Font — только с ним в интерфейсе будут иконки.
Если его в системе нет, берём Segoe UI: кириллица останется, иконки нет.
"""
os.makedirs(FONTS_DIR, exist_ok=True)
# список, а не генератор: нужны все начертания, а не только первое удачное
if not any([_copy_font(n) for n in WANTED_FONTS]):
log("JetBrains Mono Nerd Font в системе не найден — беру системный шрифт "
"(интерфейс будет без иконок)")
for name in FALLBACK_FONTS:
_copy_font(name)
return len([f for f in os.listdir(FONTS_DIR) if f.lower().endswith(".ttf")])
def build(args: argparse.Namespace) -> int:
if not ensure_pyinstaller() or not ensure_dearpygui():
return 1
add_data: list[str] = []
if not args.no_font:
count = collect_fonts()
if count:
log(f"в сборку войдёт шрифтов: {count}")
add_data.append(f"{FONTS_DIR}{os.pathsep}fonts")
else:
log("шрифты не найдены — exe будет искать их в системе")
cmd = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--name", NAME,
"--onedir" if args.onedir else "--onefile",
"--console" if args.console else "--windowed",
"--collect-all", "dearpygui",
"--exclude-module", "tkinter", # запасной интерфейс в exe не нужен
"--exclude-module", "numpy",
"--exclude-module", "PIL",
"--distpath", os.path.join(ROOT, "dist"),
"--workpath", os.path.join(ROOT, "build"),
"--specpath", os.path.join(ROOT, "build"),
]
# значок нужен и внутри сборки: его ставит себе окно приложения
if os.path.isfile(ICON):
add_data.append(f"{ICON}{os.pathsep}.")
for item in add_data:
cmd += ["--add-data", item]
if not args.no_admin:
# манифест UAC: Windows поднимет права ещё до старта Python
cmd.append("--uac-admin")
log("exe будет запрашивать права администратора при запуске")
else:
log("без манифеста UAC — права запросит уже само приложение")
if args.icon and os.path.isfile(args.icon):
cmd += ["--icon", args.icon]
log(f"значок: {os.path.basename(args.icon)}")
else:
log("значок не найден — запустите make_icon.py")
cmd.append(os.path.join(ROOT, "run.py"))
log("запускаю PyInstaller…")
print()
res = subprocess.run(cmd, cwd=ROOT)
print()
if res.returncode != 0:
print("Сборка не удалась.", file=sys.stderr)
return res.returncode
target = (os.path.join(ROOT, "dist", NAME, f"{NAME}.exe") if args.onedir
else os.path.join(ROOT, "dist", f"{NAME}.exe"))
if os.path.isfile(target):
size = os.path.getsize(target) / (1024 * 1024)
log(f"готово: {target} ({size:.1f} МБ)")
if args.onedir:
log("раздавать нужно всю папку dist\\SysDevCleaner целиком")
else:
log(f"PyInstaller отработал, но {target} не найден — проверьте вывод выше")
return 1
return 0
def main() -> int:
p = argparse.ArgumentParser(description="Сборка SysDevCleaner в exe")
p.add_argument("--onedir", action="store_true",
help="папка вместо одного файла: запуск быстрее, "
"нет распаковки во временный каталог")
p.add_argument("--no-admin", action="store_true",
help="не встраивать манифест UAC (по умолчанию он есть, и exe "
"запускается с правами администратора)")
p.add_argument("--no-font", action="store_true",
help="не вшивать шрифт в сборку")
p.add_argument("--console", action="store_true",
help="оставить консольное окно (видны ошибки)")
p.add_argument("--icon", default=ICON,
help="путь к .ico (если файла нет, значок будет стандартный)")
return build(p.parse_args())
if __name__ == "__main__":
raise SystemExit(main())

111
make_icon.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Генерация значка SysDevCleaner.
Значок рисуется геометрией, а не глифом шрифта: тонкая «палочка» из Nerd Font
на 16×16 превращалась в неразборчивое пятно. Здесь метла нарисована жирными
примитивами и остаётся узнаваемой даже в самом мелком размере.
python make_icon.py
Результат — ``sysdevcleaner.ico`` (размеры 16…256) и ``sysdevcleaner.png``
256×256 для README.
"""
from __future__ import annotations
import os
from PIL import Image, ImageDraw
ROOT = os.path.dirname(os.path.abspath(__file__))
ICO = os.path.join(ROOT, "sysdevcleaner.ico")
PNG = os.path.join(ROOT, "sysdevcleaner.png")
SIZES = (16, 24, 32, 48, 64, 128, 256)
R = 1024 # рисуем крупно, потом уменьшаем — края мягче
BG_TOP = (82, 154, 226) # accent из тёмной темы, чуть светлее
BG_BOTTOM = (42, 88, 150)
FG = 255 # метла белая, задаётся через маску
def gradient(size: int) -> Image.Image:
img = Image.new("RGB", (1, size))
d = ImageDraw.Draw(img)
for y in range(size):
t = y / max(1, size - 1)
d.point((0, y), tuple(int(a + (b - a) * t)
for a, b in zip(BG_TOP, BG_BOTTOM)))
return img.resize((size, size), Image.BICUBIC)
def rounded_mask(size: int, radius_ratio: float = 0.22) -> Image.Image:
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[0, 0, size - 1, size - 1], radius=int(size * radius_ratio), fill=255)
return mask
def broom_mask() -> Image.Image:
"""Маска метлы: рисуем вертикально, потом поворачиваем.
Вертикальная заготовка проще в координатах, а наклон даёт узнаваемый силуэт.
"""
pad = R // 2
m = Image.new("L", (R + pad, R + pad), 0)
d = ImageDraw.Draw(m)
cx = (R + pad) // 2
# черенок
d.rounded_rectangle([cx - 52, 150, cx + 52, 640], radius=52, fill=FG)
# обхват под черенком
d.rounded_rectangle([cx - 132, 610, cx + 132, 710], radius=40, fill=FG)
# ворс — расширяющаяся трапеция
d.polygon([(cx - 118, 700), (cx + 118, 700),
(cx + 250, 1030), (cx - 250, 1030)], fill=FG)
# прорези в ворсе: режем маску, а не рисуем поверх фона
for dx in (-125, 0, 125):
d.line([(cx + dx * 0.62, 790), (cx + dx, 1040)], fill=0, width=34)
# нижний край чуть скруглим, срезав уголки
d.polygon([(cx - 250, 1030), (cx - 250, 1100), (cx + 250, 1100),
(cx + 250, 1030)], fill=0)
m = m.rotate(-38, resample=Image.BICUBIC, expand=True,
center=(cx, (R + pad) // 2))
# вписываем в плитку: обрезаем по содержимому и масштабируем до 66%
bbox = m.getbbox()
m = m.crop(bbox)
target = int(R * 0.66)
scale = target / max(m.size)
m = m.resize((max(1, int(m.width * scale)), max(1, int(m.height * scale))),
Image.LANCZOS)
out = Image.new("L", (R, R), 0)
out.paste(m, ((R - m.width) // 2, (R - m.height) // 2))
return out
def build() -> Image.Image:
base = gradient(R).convert("RGBA")
base.putalpha(rounded_mask(R))
mask = broom_mask()
white = Image.new("RGBA", (R, R), (255, 255, 255, 0))
white.putalpha(mask)
return Image.alpha_composite(base, white)
def main() -> int:
icon = build()
icon.resize((256, 256), Image.LANCZOS).save(PNG)
print(f" {PNG}")
frames = [icon.resize((s, s), Image.LANCZOS) for s in SIZES]
frames[-1].save(ICO, format="ICO",
sizes=[(s, s) for s in SIZES], append_images=frames[:-1])
print(f" {ICO} (размеры: {', '.join(str(s) for s in SIZES)})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
# Интерфейс. Работает и на 1.x, и на 2.x:
# 1.x — диапазоны шрифта задаются через add_font_range_hint;
# 2.x — диапазоны определяются автоматически, вызовы игнорируются.
dearpygui>=1.9

11
run.py Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Запуск SysDevCleaner без установки: python run.py [аргументы]"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sysdevcleaner.__main__ import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())

14
sysdevcleaner-cli.bat Normal file
View File

@@ -0,0 +1,14 @@
@echo off
rem SysDevCleaner - console mode. Example: sysdevcleaner-cli.bat --categories devcache,projects --top 40
setlocal
cd /d "%~dp0"
chcp 65001 >nul
python -X utf8 -m sysdevcleaner %*
pause

BIN
sysdevcleaner.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
sysdevcleaner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,3 @@
"""SysDevCleaner — анализ и очистка кешей, мусора и остатков разработки."""
__version__ = "1.0.0"

46
sysdevcleaner/__main__.py Normal file
View File

@@ -0,0 +1,46 @@
"""Точка входа: `python -m sysdevcleaner` открывает GUI, с аргументами — консоль."""
from __future__ import annotations
import sys
from .winui import NO_ELEVATE_ENV, ensure_admin, is_admin
def launch_gui(argv: list[str] | None = None) -> int:
"""Dear PyGui — основной интерфейс; tkinter остаётся запасным вариантом.
Перед запуском поднимаем права: без них недоступны Windows\\Temp, кеш
обновлений, Prefetch и Windows.old — а это обычно самая крупная добыча.
Отказ в UAC не фатален: приложение просто запустится с обычными правами
и честно скажет об этом в заголовке и шапке.
"""
if ensure_admin():
return 0 # работать дальше будет повышенная копия
try:
from .gui.app import main as gui_main
except ImportError as e:
print(f"Dear PyGui недоступен ({e}).", file=sys.stderr)
print("Установите его командой: python -m pip install dearpygui",
file=sys.stderr)
print("Пока запускаю запасной интерфейс на tkinter.", file=sys.stderr)
from .gui.app_tk import main as gui_main # type: ignore[assignment]
gui_main()
return 0
def main() -> int:
args = sys.argv[1:]
if not args:
return launch_gui()
if args == ["--gui"]:
return launch_gui([])
from .cli import main as cli_main
return cli_main()
__all__ = ["main", "launch_gui", "is_admin", "NO_ELEVATE_ENV"]
if __name__ == "__main__":
raise SystemExit(main())

188
sysdevcleaner/cli.py Normal file
View File

@@ -0,0 +1,188 @@
"""Консольный режим SysDevCleaner — для скриптов и быстрой проверки."""
from __future__ import annotations
import argparse
import sys
from .core import cleaner as cleanmod
from .core.advisor import report_text
from .core.model import Category, Risk
from .core.scanner import (ScanOptions, default_project_roots, full_scan,
normalize_drives)
from .core.util import Cancel, human, is_admin
CAT_KEYS = {
"windows": Category.WINDOWS,
"browsers": Category.BROWSERS,
"devcache": Category.DEV_CACHE,
"projects": Category.DEV_PROJECT,
"apps": Category.APPS,
"logs": Category.LOGS,
"pkg": Category.PACKAGE_MANAGERS,
}
RISK_KEYS = {"safe": Risk.SAFE, "low": Risk.LOW, "medium": Risk.MEDIUM, "high": Risk.HIGH}
def out(text: str = "", **kw) -> None:
"""print, устойчивый к кодировке консоли.
В русской Windows консоль обычно cp1251: кириллица в неё ложится, а эмодзи
из названий категорий — нет, и обычный print падает с UnicodeEncodeError.
Заменяем то, что консоль не осилит, на безопасный символ.
"""
enc = getattr(sys.stdout, "encoding", None) or "utf-8"
try:
text.encode(enc)
except (UnicodeEncodeError, LookupError):
text = text.encode(enc, "replace").decode(enc, "replace")
print(text, **kw)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="sysdevcleaner",
description="Анализ и очистка кешей, мусора и остатков разработки.")
p.add_argument("--gui", action="store_true", help="запустить графический интерфейс")
p.add_argument("--categories", default="all",
help="через запятую: " + ",".join(CAT_KEYS) + " или all")
p.add_argument("--root", action="append", default=[],
help="папка для поиска проектов (можно указать несколько раз)")
p.add_argument("--drive", action="append", default=[], metavar="БУКВА",
help="сканировать только указанный диск, например --drive D "
"(можно указать несколько раз; по умолчанию — все)")
p.add_argument("--big-files", type=int, metavar="МБ", default=0,
help="искать файлы крупнее указанного размера")
p.add_argument("--duplicates", type=int, metavar="МБ", default=0,
help="искать дубликаты файлов крупнее указанного размера")
p.add_argument("--no-programs", action="store_true", help="не собирать список программ")
p.add_argument("--report", metavar="ФАЙЛ", help="сохранить подробный отчёт")
p.add_argument("--top", type=int, default=30, help="сколько находок показать (по умолчанию 30)")
p.add_argument("--clean", metavar="РИСК", choices=list(RISK_KEYS),
help="удалить находки с риском не выше указанного")
p.add_argument("--permanent", action="store_true",
help="удалять мимо Корзины (по умолчанию — в Корзину)")
p.add_argument("--dry-run", action="store_true", help="показать план, ничего не удаляя")
p.add_argument("--yes", action="store_true", help="не спрашивать подтверждение")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.gui:
from .__main__ import launch_gui
return launch_gui()
if args.categories.strip().lower() == "all":
cats = set(CAT_KEYS.values())
else:
cats = set()
for key in args.categories.split(","):
key = key.strip().lower()
if key not in CAT_KEYS:
print(f"Неизвестная категория: {key}", file=sys.stderr)
return 2
cats.add(CAT_KEYS[key])
opts = ScanOptions(
categories=cats,
project_roots=args.root or default_project_roots(),
scan_big_files=args.big_files > 0,
big_file_min=max(1, args.big_files) * 1024 * 1024,
scan_duplicates=args.duplicates > 0,
duplicate_min=max(1, args.duplicates) * 1024 * 1024,
scan_programs=not args.no_programs,
drives=normalize_drives(args.drive),
)
cancel = Cancel()
last = [""]
def progress(msg: str, frac: float) -> None:
line = f" [{frac * 100:5.1f}%] {msg}"[:110]
if line != last[0]:
last[0] = line
out(line.ljust(112), end="\r", flush=True)
scope = ("диски: " + ", ".join(sorted(opts.drives))) if opts.drives else "все диски"
out(f"SysDevCleaner · сканирование ({scope})"
f"{'' if is_admin() else ' · без прав администратора'}…")
try:
res = full_scan(opts, cancel, progress)
except KeyboardInterrupt:
cancel.cancel()
out("\nПрервано.")
return 130
out(" " * 112, end="\r")
out(f"\nНайдено {human(res.total())} в {len(res.findings)} местах "
f"за {res.duration:.0f} с\n")
for cat, items in sorted(res.by_category().items(),
key=lambda kv: -sum(f.size for f in kv[1])):
out(f" {cat.icon} {cat.label:<28} {human(sum(f.size for f in items)):>10} "
f"({len(items)})")
out(f"\nКрупнейшие находки (топ-{args.top}):")
for f in res.findings[:args.top]:
out(f" {human(f.size):>10} [{f.risk.label:<14}] {f.title}")
out(f" {f.path}")
if res.recommendations:
out("\nРекомендации:")
for i, r in enumerate(res.recommendations, 1):
gain = f" — {human(r.gain)}" if r.gain else ""
out(f" {i}. {r.title}{gain}")
if args.report:
try:
with open(args.report, "w", encoding="utf-8") as fh:
fh.write(report_text(res))
out(f"\nОтчёт сохранён: {args.report}")
except OSError as e:
print(f"\nНе удалось сохранить отчёт: {e}", file=sys.stderr)
if args.clean:
limit = RISK_KEYS[args.clean]
targets = [f for f in res.findings
if f.risk <= limit
and f.category not in (Category.BIG_FILES, Category.DUPLICATES)]
if not targets:
out("\nНечего удалять с таким уровнем риска.")
return 0
total = sum(f.size for f in targets)
out(f"\nК удалению: {len(targets)} объектов, {human(total)} "
f"(риск не выше «{limit.label}»)")
if args.permanent:
out("Режим: БЕЗВОЗВРАТНОЕ удаление мимо Корзины.")
if args.dry_run:
out("Пробный запуск — ничего не будет удалено.")
if not args.yes and not args.dry_run:
ans = input("Продолжить? [y/N] ").strip().lower()
if ans not in ("y", "yes", "д", "да"):
out("Отменено.")
return 0
cres = cleanmod.clean(
targets,
cleanmod.CleanOptions(to_recycle_bin=not args.permanent, dry_run=args.dry_run),
cancel,
lambda m, f: progress(m, f),
)
out(" " * 112, end="\r")
out("\n" + cres.summary())
fails = [i for i in cres.items if not i.ok]
if fails:
out(f"\nНе удалось очистить {len(fails)}:")
for i in fails[:15]:
out(f" • {i.finding.title}: {i.message or 'файлы заняты'}")
if cres.journal_path:
out(f"\nЖурнал: {cres.journal_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1 @@
"""Ядро SysDevCleaner."""

View 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)

View 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

View File

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

220
sysdevcleaner/core/heavy.py Normal file
View 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
View 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

View 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
View 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]

View File

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

396
sysdevcleaner/core/util.py Normal file
View 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

View File

@@ -0,0 +1 @@
"""Графический интерфейс SysDevCleaner."""

1054
sysdevcleaner/gui/app.py Normal file

File diff suppressed because it is too large Load Diff

990
sysdevcleaner/gui/app_tk.py Normal file
View File

@@ -0,0 +1,990 @@
"""SysDevCleaner — графический интерфейс."""
from __future__ import annotations
import os
import queue
import subprocess
import sys
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from ..core import cleaner as cleanmod
from ..core import programs as progmod
from ..core.advisor import default_selection, plan_summary, report_text
from ..core.model import Category, Finding, Risk, ScanResult
from ..core.scanner import ScanOptions, default_project_roots, full_scan
from ..core.util import (Cancel, disk_usage, drives, human, is_admin,
open_in_explorer)
from .theme_tk import Theme, tag_colors
CHECKED = "☑"
UNCHECKED = "☐"
PARTIAL = "◪"
RISK_TAG = {Risk.SAFE: "safe", Risk.LOW: "low", Risk.MEDIUM: "medium", Risk.HIGH: "high"}
ALL_CATEGORIES = [
Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT,
Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS,
]
class App(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("SysDevCleaner — анализ и очистка системы")
self.geometry("1280x820")
self.minsize(1040, 660)
self.theme = Theme(self, dark=True)
self.tags = tag_colors(self.theme)
self.result: ScanResult | None = None
self.cancel = Cancel()
self.q: queue.Queue = queue.Queue()
self.worker: threading.Thread | None = None
self.busy = False
self.items: dict[str, Finding] = {} # iid дерева -> находка
self.cat_nodes: dict[Category, str] = {}
self.prog_rows: dict[str, object] = {}
# настройки
self.var_cat = {c: tk.BooleanVar(value=True) for c in ALL_CATEGORIES}
self.var_big = tk.BooleanVar(value=False)
self.var_big_mb = tk.StringVar(value="200")
self.var_dup = tk.BooleanVar(value=False)
self.var_dup_mb = tk.StringVar(value="10")
self.var_progs = tk.BooleanVar(value=True)
self.var_prog_size = tk.BooleanVar(value=True)
self.var_recycle = tk.BooleanVar(value=True)
self.var_dry = tk.BooleanVar(value=False)
self.var_status = tk.StringVar(value="Готов к сканированию")
self.var_sel = tk.StringVar(value="Ничего не выбрано")
self.roots: list[str] = default_project_roots()
self._build()
self.after(120, self._poll)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self._refresh_disks()
self._load_journals()
# ------------------------------------------------------------ разметка --
def _build(self) -> None:
self._build_header()
self.nb = ttk.Notebook(self)
self.nb.pack(fill="both", expand=True, padx=14, pady=(4, 0))
self._build_overview()
self._build_clean()
self._build_programs()
self._build_settings()
self._build_journal()
self._build_status()
def _build_header(self) -> None:
c = self.theme.c
head = ttk.Frame(self)
head.pack(fill="x", padx=16, pady=(14, 6))
left = ttk.Frame(head)
left.pack(side="left", fill="x", expand=True)
ttk.Label(left, text="SysDevCleaner", style="H1.TLabel").pack(anchor="w")
sub = "Полный анализ кешей, мусора и остатков разработки"
if is_admin():
sub += " · права администратора получены"
else:
sub += " · запущено без прав администратора"
ttk.Label(left, text=sub, style="Dim.TLabel").pack(anchor="w", pady=(2, 0))
right = ttk.Frame(head)
right.pack(side="right")
self.btn_scan = ttk.Button(right, text="Сканировать", style="Accent.TButton",
command=self.start_scan)
self.btn_scan.pack(side="left", padx=(0, 8))
self.btn_stop = ttk.Button(right, text="Стоп", command=self.stop, state="disabled")
self.btn_stop.pack(side="left", padx=(0, 8))
ttk.Button(right, text="Тема", command=self._toggle_theme).pack(side="left")
def _build_status(self) -> None:
bar = ttk.Frame(self)
bar.pack(fill="x", side="bottom", padx=16, pady=(6, 12))
self.pb = ttk.Progressbar(bar, style="Thin.Horizontal.TProgressbar",
mode="determinate", maximum=1000)
self.pb.pack(fill="x")
row = ttk.Frame(bar)
row.pack(fill="x", pady=(6, 0))
ttk.Label(row, textvariable=self.var_status, style="Dim.TLabel").pack(side="left")
ttk.Label(row, textvariable=self.var_sel, style="Dim.TLabel").pack(side="right")
# ------------------------------------------------------------- Обзор ----
def _build_overview(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text=" Обзор ")
outer = ttk.Frame(tab)
outer.pack(fill="both", expand=True, padx=14, pady=14)
# карточки-итоги
self.cards = ttk.Frame(outer)
self.cards.pack(fill="x")
self.card_vals: dict[str, tk.StringVar] = {}
for key, title in (("total", "Найдено мусора"), ("safe", "Безопасно удалить"),
("dev", "Кеши разработки"), ("free", "Свободно на C:")):
card = ttk.Frame(self.cards, style="Card.TFrame", padding=16)
card.pack(side="left", fill="both", expand=True, padx=(0, 10))
v = tk.StringVar(value="—")
self.card_vals[key] = v
ttk.Label(card, textvariable=v, style="CardBig.TLabel").pack(anchor="w")
ttk.Label(card, text=title, style="CardDim.TLabel").pack(anchor="w", pady=(4, 0))
body = ttk.Frame(outer)
body.pack(fill="both", expand=True, pady=(14, 0))
# левая колонка: диски + категории
left = ttk.Frame(body)
left.pack(side="left", fill="both", expand=True, padx=(0, 12))
ttk.Label(left, text="Диски", style="H2.TLabel").pack(anchor="w")
self.disk_box = ttk.Frame(left)
self.disk_box.pack(fill="x", pady=(8, 16))
ttk.Label(left, text="По категориям", style="H2.TLabel").pack(anchor="w")
self.cat_box = ttk.Frame(left)
self.cat_box.pack(fill="both", expand=True, pady=(8, 0))
# правая колонка: рекомендации
right = ttk.Frame(body)
right.pack(side="left", fill="both", expand=True)
head = ttk.Frame(right)
head.pack(fill="x")
ttk.Label(head, text="Рекомендации", style="H2.TLabel").pack(side="left")
ttk.Button(head, text="Сохранить отчёт…", command=self.save_report).pack(side="right")
wrap = ttk.Frame(right)
wrap.pack(fill="both", expand=True, pady=(8, 0))
c = self.theme.c
self.rec_text = tk.Text(wrap, wrap="word", bg=c["bg2"], fg=c["fg"],
insertbackground=c["fg"], relief="flat", padx=14, pady=12,
font=("Segoe UI", 10), spacing1=2, spacing3=4,
highlightthickness=0)
sb = ttk.Scrollbar(wrap, orient="vertical", command=self.rec_text.yview)
self.rec_text.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
self.rec_text.pack(side="left", fill="both", expand=True)
self.rec_text.tag_configure("h", font=("Segoe UI Semibold", 11),
foreground=c["fg"], spacing1=10, spacing3=4)
self.rec_text.tag_configure("imp", foreground=c["warn"],
font=("Segoe UI Semibold", 11), spacing1=10, spacing3=4)
self.rec_text.tag_configure("gain", foreground=c["ok"],
font=("Segoe UI Semibold", 10))
self.rec_text.tag_configure("body", foreground=c["fg_dim"], lmargin1=14, lmargin2=14)
self.rec_text.insert("1.0", "Нажмите «Сканировать», чтобы проанализировать систему.\n\n"
"Что будет проверено:\n"
" • временные файлы, дампы, отчёты об ошибках Windows;\n"
" • кеши всех установленных браузеров;\n"
" • кеши npm, pip, Gradle, Maven, Cargo, NuGet, Go и др.;\n"
" • node_modules, target, build, __pycache__ в проектах;\n"
" • кеши IDE, игровых лаунчеров и мессенджеров;\n"
" • список установленных программ с реальным размером.\n",
"body")
self.rec_text.configure(state="disabled")
# ------------------------------------------------------------ Очистка ---
def _build_clean(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text=" Очистка ")
outer = ttk.Frame(tab)
outer.pack(fill="both", expand=True, padx=14, pady=14)
bar = ttk.Frame(outer)
bar.pack(fill="x", pady=(0, 10))
ttk.Button(bar, text="Отметить безопасное",
command=lambda: self._bulk("safe")).pack(side="left", padx=(0, 6))
ttk.Button(bar, text="Отметить безопасное и низкий риск",
command=lambda: self._bulk("low")).pack(side="left", padx=(0, 6))
ttk.Button(bar, text="Снять всё",
command=lambda: self._bulk("none")).pack(side="left", padx=(0, 6))
ttk.Button(bar, text="Развернуть", command=lambda: self._expand(True)).pack(side="left")
ttk.Button(bar, text="Свернуть", command=lambda: self._expand(False)).pack(side="left",
padx=(6, 0))
ttk.Label(bar, text="Фильтр:", style="Dim.TLabel").pack(side="left", padx=(18, 6))
self.var_filter = tk.StringVar()
ent = ttk.Entry(bar, textvariable=self.var_filter, width=26)
ent.pack(side="left")
self.var_filter.trace_add("write", lambda *_: self._refill_tree())
self.var_hide_small = tk.BooleanVar(value=False)
ttk.Checkbutton(bar, text="Скрыть мельче 10 МБ", variable=self.var_hide_small,
command=self._refill_tree).pack(side="left", padx=(14, 0))
pane = ttk.PanedWindow(outer, orient="vertical")
pane.pack(fill="both", expand=True)
top = ttk.Frame(pane)
pane.add(top, weight=4)
cols = ("chk", "size", "risk", "age", "path")
self.tree = ttk.Treeview(top, columns=cols, show="tree headings", selectmode="extended")
self.tree.heading("#0", text="Объект")
self.tree.heading("chk", text="")
self.tree.heading("size", text="Размер")
self.tree.heading("risk", text="Риск")
self.tree.heading("age", text="Изменён")
self.tree.heading("path", text="Путь")
self.tree.column("#0", width=380, minwidth=220, stretch=False)
self.tree.column("chk", width=40, minwidth=40, anchor="center", stretch=False)
self.tree.column("size", width=100, anchor="e", stretch=False)
self.tree.column("risk", width=120, anchor="w", stretch=False)
self.tree.column("age", width=110, anchor="e", stretch=False)
self.tree.column("path", width=520, anchor="w")
vsb = ttk.Scrollbar(top, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(top, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
vsb.pack(side="right", fill="y")
hsb.pack(side="bottom", fill="x")
self.tree.pack(side="left", fill="both", expand=True)
for name, cfg in self.tags.items():
self.tree.tag_configure(name, **cfg)
self.tree.bind("<Button-1>", self._on_tree_click)
self.tree.bind("<space>", self._on_space)
self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
self.tree.bind("<Double-1>", self._on_tree_double)
self.tree.bind("<Button-3>", self._on_tree_menu)
self.menu = tk.Menu(self, tearoff=0)
self.menu.add_command(label="Открыть в проводнике", command=self._open_selected)
self.menu.add_command(label="Скопировать путь", command=self._copy_path)
self.menu.add_separator()
self.menu.add_command(label="Отметить", command=lambda: self._set_selected(True))
self.menu.add_command(label="Снять отметку", command=lambda: self._set_selected(False))
bottom = ttk.Frame(pane)
pane.add(bottom, weight=1)
c = self.theme.c
self.detail = tk.Text(bottom, height=7, wrap="word", bg=c["bg2"], fg=c["fg_dim"],
relief="flat", padx=14, pady=10, font=("Segoe UI", 10),
highlightthickness=0)
self.detail.pack(fill="both", expand=True)
self.detail.insert("1.0", "Выделите строку, чтобы увидеть подробности.")
self.detail.configure(state="disabled")
act = ttk.Frame(outer)
act.pack(fill="x", pady=(10, 0))
ttk.Checkbutton(act, text="Удалять в Корзину (можно восстановить)",
variable=self.var_recycle).pack(side="left")
ttk.Checkbutton(act, text="Пробный запуск (ничего не удалять)",
variable=self.var_dry).pack(side="left", padx=(18, 0))
self.btn_clean = ttk.Button(act, text="Очистить выбранное", style="Danger.TButton",
command=self.do_clean, state="disabled")
self.btn_clean.pack(side="right")
# ----------------------------------------------------------- Программы --
def _build_programs(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text=" Программы ")
outer = ttk.Frame(tab)
outer.pack(fill="both", expand=True, padx=14, pady=14)
bar = ttk.Frame(outer)
bar.pack(fill="x", pady=(0, 10))
ttk.Label(bar, text="Установленные программы, отсортированные по занимаемому месту. "
"Двойной клик открывает папку установки.",
style="Dim.TLabel").pack(side="left")
ttk.Button(bar, text="Удалить программу…",
command=self._uninstall).pack(side="right")
ttk.Button(bar, text="Открыть «Приложения и возможности»",
command=self._open_appwiz).pack(side="right", padx=(0, 8))
cols = ("size", "idle", "version", "publisher", "location")
self.ptree = ttk.Treeview(outer, columns=cols, show="tree headings")
self.ptree.heading("#0", text="Программа")
self.ptree.heading("size", text="Размер")
self.ptree.heading("idle", text="Простой")
self.ptree.heading("version", text="Версия")
self.ptree.heading("publisher", text="Издатель")
self.ptree.heading("location", text="Расположение")
self.ptree.column("#0", width=340, stretch=False)
self.ptree.column("size", width=100, anchor="e", stretch=False)
self.ptree.column("idle", width=100, anchor="e", stretch=False)
self.ptree.column("version", width=120, stretch=False)
self.ptree.column("publisher", width=200, stretch=False)
self.ptree.column("location", width=420)
vsb = ttk.Scrollbar(outer, orient="vertical", command=self.ptree.yview)
self.ptree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self.ptree.pack(fill="both", expand=True)
for name, cfg in self.tags.items():
self.ptree.tag_configure(name, **cfg)
self.ptree.bind("<Double-1>", self._open_program_folder)
# ----------------------------------------------------------- Настройки --
def _build_settings(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text=" Настройки ")
outer = ttk.Frame(tab)
outer.pack(fill="both", expand=True, padx=14, pady=14)
left = ttk.Frame(outer)
left.pack(side="left", fill="both", expand=True, padx=(0, 16))
ttk.Label(left, text="Что искать", style="H2.TLabel").pack(anchor="w")
box = ttk.Frame(left, style="Card.TFrame", padding=14)
box.pack(fill="x", pady=(8, 16))
for cat in ALL_CATEGORIES:
ttk.Checkbutton(box, text=f"{cat.icon} {cat.label}", variable=self.var_cat[cat],
style="Card.TCheckbutton").pack(anchor="w", pady=2)
ttk.Label(left, text="Дополнительный анализ", style="H2.TLabel").pack(anchor="w")
box2 = ttk.Frame(left, style="Card.TFrame", padding=14)
box2.pack(fill="x", pady=(8, 16))
r1 = ttk.Frame(box2, style="Card.TFrame")
r1.pack(fill="x", pady=2)
ttk.Checkbutton(r1, text="Искать крупные файлы, больше", variable=self.var_big,
style="Card.TCheckbutton").pack(side="left")
ttk.Entry(r1, textvariable=self.var_big_mb, width=6).pack(side="left", padx=6)
ttk.Label(r1, text="МБ", style="CardDim.TLabel").pack(side="left")
r2 = ttk.Frame(box2, style="Card.TFrame")
r2.pack(fill="x", pady=2)
ttk.Checkbutton(r2, text="Искать дубликаты, больше", variable=self.var_dup,
style="Card.TCheckbutton").pack(side="left")
ttk.Entry(r2, textvariable=self.var_dup_mb, width=6).pack(side="left", padx=6)
ttk.Label(r2, text="МБ (медленно)", style="CardDim.TLabel").pack(side="left")
ttk.Checkbutton(box2, text="Собирать список установленных программ",
variable=self.var_progs, style="Card.TCheckbutton").pack(anchor="w", pady=2)
ttk.Checkbutton(box2, text="Измерять реальный размер программ на диске (дольше)",
variable=self.var_prog_size,
style="Card.TCheckbutton").pack(anchor="w", pady=2)
right = ttk.Frame(outer)
right.pack(side="left", fill="both", expand=True)
ttk.Label(right, text="Где искать проекты и крупные файлы",
style="H2.TLabel").pack(anchor="w")
ttk.Label(right, text="По умолчанию — папки разработки в профиле и несистемные диски.",
style="Dim.TLabel").pack(anchor="w", pady=(2, 8))
self.roots_list = tk.Listbox(right, height=14, bg=self.theme.c["bg2"],
fg=self.theme.c["fg"], relief="flat",
selectbackground=self.theme.c["sel"],
highlightthickness=0, font=("Segoe UI", 10))
self.roots_list.pack(fill="both", expand=True)
for r in self.roots:
self.roots_list.insert("end", r)
rb = ttk.Frame(right)
rb.pack(fill="x", pady=(8, 0))
ttk.Button(rb, text="Добавить папку…", command=self._add_root).pack(side="left")
ttk.Button(rb, text="Убрать", command=self._del_root).pack(side="left", padx=6)
ttk.Button(rb, text="По умолчанию", command=self._reset_roots).pack(side="left")
# ------------------------------------------------------------- Журнал ---
def _build_journal(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text=" Журнал ")
outer = ttk.Frame(tab)
outer.pack(fill="both", expand=True, padx=14, pady=14)
ttk.Label(outer, text="История очисток", style="H2.TLabel").pack(anchor="w")
ttk.Label(outer, text="Каждая очистка записывается в файл — видно, что и когда "
"было удалено.", style="Dim.TLabel").pack(anchor="w", pady=(2, 8))
self.jtree = ttk.Treeview(outer, columns=("when", "freed", "file"),
show="headings", height=10)
self.jtree.heading("when", text="Дата")
self.jtree.heading("freed", text="Освобождено")
self.jtree.heading("file", text="Файл журнала")
self.jtree.column("when", width=160, stretch=False)
self.jtree.column("freed", width=130, anchor="e", stretch=False)
self.jtree.column("file", width=700)
self.jtree.pack(fill="x")
self.jtree.bind("<Double-1>", lambda e: self._open_journal())
ttk.Label(outer, text="Ошибки доступа при последнем сканировании",
style="H2.TLabel").pack(anchor="w", pady=(18, 0))
c = self.theme.c
self.err_text = tk.Text(outer, height=10, wrap="none", bg=c["bg2"], fg=c["fg_dim"],
relief="flat", padx=12, pady=10, font=("Consolas", 9),
highlightthickness=0)
self.err_text.pack(fill="both", expand=True, pady=(8, 0))
self.err_text.insert("1.0", "—")
self.err_text.configure(state="disabled")
# =========================================================== сканирование
def start_scan(self) -> None:
if self.busy:
return
cats = {c for c in ALL_CATEGORIES if self.var_cat[c].get()}
if not cats:
messagebox.showwarning("SysDevCleaner", "Выберите хотя бы одну категорию в настройках.")
return
opts = ScanOptions(
categories=cats,
project_roots=list(self.roots),
scan_big_files=self.var_big.get(),
big_file_min=_mb(self.var_big_mb.get(), 200),
scan_duplicates=self.var_dup.get(),
duplicate_min=_mb(self.var_dup_mb.get(), 10),
scan_programs=self.var_progs.get(),
measure_program_size=self.var_prog_size.get(),
)
self.busy = True
self.cancel.reset()
self.btn_scan.configure(state="disabled")
self.btn_stop.configure(state="normal")
self.btn_clean.configure(state="disabled")
self.tree.delete(*self.tree.get_children())
self.items.clear()
self.cat_nodes.clear()
self.var_status.set("Сканирование…")
self.pb.configure(value=0)
def work() -> None:
try:
res = full_scan(opts, self.cancel,
lambda m, f: self.q.put(("progress", (m, f))))
self.q.put(("scan_done", res))
except Exception as e: # noqa: BLE001
self.q.put(("error", f"Сбой сканирования: {e}"))
self.worker = threading.Thread(target=work, daemon=True)
self.worker.start()
def stop(self) -> None:
self.cancel.cancel()
self.var_status.set("Останавливаю…")
def _poll(self) -> None:
try:
while True:
kind, payload = self.q.get_nowait()
if kind == "progress":
msg, frac = payload
self.var_status.set(msg)
self.pb.configure(value=int(frac * 1000))
elif kind == "scan_done":
self._scan_done(payload)
elif kind == "clean_progress":
msg, frac = payload
self.var_status.set(f"Очистка: {msg}")
self.pb.configure(value=int(frac * 1000))
elif kind == "clean_done":
self._clean_done(payload)
elif kind == "error":
self.busy = False
self.btn_scan.configure(state="normal")
self.btn_stop.configure(state="disabled")
self.var_status.set("Ошибка")
messagebox.showerror("SysDevCleaner", str(payload))
except queue.Empty:
pass
self.after(120, self._poll)
def _scan_done(self, res: ScanResult) -> None:
self.busy = False
self.result = res
self.btn_scan.configure(state="normal")
self.btn_stop.configure(state="disabled")
self.btn_clean.configure(state="normal")
self.pb.configure(value=1000)
default_selection(res.findings)
self._refill_tree()
self._fill_overview(res)
self._fill_programs(res)
self._fill_errors(res)
self._refresh_disks()
self._update_sel()
self.var_status.set(
f"Найдено {human(res.total())} в {len(res.findings)} местах · "
f"{res.duration:.0f} с" + (" · сканирование прервано" if self.cancel.cancelled else "")
)
# =============================================================== дерево ==
def _refill_tree(self) -> None:
if self.result is None:
return
self.tree.delete(*self.tree.get_children())
self.items.clear()
self.cat_nodes.clear()
needle = self.var_filter.get().strip().lower()
floor = 10 * 1024 * 1024 if self.var_hide_small.get() else 0
for cat, findings in sorted(self.result.by_category().items(),
key=lambda kv: -sum(f.size for f in kv[1])):
shown = [f for f in findings
if f.size >= floor
and (not needle or needle in f.title.lower() or needle in f.path.lower())]
if not shown:
continue
total = sum(f.size for f in shown)
node = self.tree.insert(
"", "end", text=f"{cat.icon} {cat.label}",
values=(UNCHECKED, human(total), f"{len(shown)} шт.", "", ""),
open=cat in (Category.DEV_PROJECT, Category.DEV_CACHE), tags=("cat",))
self.cat_nodes[cat] = node
for f in shown:
iid = self.tree.insert(
node, "end", text=" " + f.title,
values=(CHECKED if f.selected else UNCHECKED,
human(f.size), f.risk.label, _age(f), f.path),
tags=(RISK_TAG[f.risk],))
self.items[iid] = f
self._sync_cat(node)
self._update_sel()
def _on_tree_click(self, event: tk.Event) -> None:
region = self.tree.identify_region(event.x, event.y)
if region not in ("cell", "tree"):
return
col = self.tree.identify_column(event.x)
iid = self.tree.identify_row(event.y)
if not iid or col != "#1":
return
if iid in self.items:
self._toggle(iid)
else:
self._toggle_cat(iid)
return "break"
def _on_space(self, event: tk.Event) -> str:
for iid in self.tree.selection():
if iid in self.items:
self._toggle(iid)
else:
self._toggle_cat(iid)
return "break"
def _toggle(self, iid: str, value: bool | None = None) -> None:
f = self.items[iid]
f.selected = (not f.selected) if value is None else value
self.tree.set(iid, "chk", CHECKED if f.selected else UNCHECKED)
parent = self.tree.parent(iid)
if parent:
self._sync_cat(parent)
self._update_sel()
def _toggle_cat(self, node: str) -> None:
kids = self.tree.get_children(node)
target = not all(self.items[k].selected for k in kids if k in self.items)
for k in kids:
if k in self.items:
self.items[k].selected = target
self.tree.set(k, "chk", CHECKED if target else UNCHECKED)
self._sync_cat(node)
self._update_sel()
def _sync_cat(self, node: str) -> None:
kids = [k for k in self.tree.get_children(node) if k in self.items]
if not kids:
return
n = sum(1 for k in kids if self.items[k].selected)
glyph = CHECKED if n == len(kids) else (UNCHECKED if n == 0 else PARTIAL)
self.tree.set(node, "chk", glyph)
def _set_selected(self, value: bool) -> None:
for iid in self.tree.selection():
if iid in self.items:
self._toggle(iid, value)
def _bulk(self, mode: str) -> None:
for iid, f in self.items.items():
if mode == "none":
f.selected = False
elif mode == "safe":
f.selected = f.risk == Risk.SAFE and not f.extra.get("busy")
elif mode == "low":
f.selected = f.risk <= Risk.LOW and f.category not in (
Category.BIG_FILES, Category.DUPLICATES)
self.tree.set(iid, "chk", CHECKED if f.selected else UNCHECKED)
for node in self.cat_nodes.values():
self._sync_cat(node)
self._update_sel()
def _expand(self, opened: bool) -> None:
for node in self.cat_nodes.values():
self.tree.item(node, open=opened)
def _update_sel(self) -> None:
sel = [f for f in self.items.values() if f.selected]
if not sel:
self.var_sel.set("Ничего не выбрано")
else:
risky = sum(1 for f in sel if f.risk >= Risk.MEDIUM)
txt = f"Выбрано {len(sel)} · {human(sum(f.size for f in sel))}"
if risky:
txt += f" · из них {risky} с повышенным риском"
self.var_sel.set(txt)
def _on_tree_select(self, event: tk.Event) -> None:
sel = self.tree.selection()
if not sel:
return
iid = sel[0]
self.detail.configure(state="normal")
self.detail.delete("1.0", "end")
if iid in self.items:
f = self.items[iid]
mode = "Удаляется содержимое каталога" if f.delete_contents_only else "Удаляется целиком"
if f.members:
mode = f"Удаляются лишние копии ({len(f.members)} шт.), оригинал остаётся"
self.detail.insert("1.0",
f"{f.title}\n{f.path}\n\n"
f"{human(f.size)} · {f.files} файлов · {f.risk.label} · {mode}\n\n"
f"{f.detail}")
else:
self.detail.insert("1.0", "Категория. Клик по квадратику отмечает всё внутри.")
self.detail.configure(state="disabled")
def _on_tree_double(self, event: tk.Event) -> str:
iid = self.tree.identify_row(event.y)
if iid in self.items:
open_in_explorer(self.items[iid].path)
return "break"
return ""
def _on_tree_menu(self, event: tk.Event) -> None:
iid = self.tree.identify_row(event.y)
if iid:
if iid not in self.tree.selection():
self.tree.selection_set(iid)
self.menu.tk_popup(event.x_root, event.y_root)
def _open_selected(self) -> None:
for iid in self.tree.selection():
if iid in self.items:
open_in_explorer(self.items[iid].path)
break
def _copy_path(self) -> None:
paths = [self.items[i].path for i in self.tree.selection() if i in self.items]
if paths:
self.clipboard_clear()
self.clipboard_append("\n".join(paths))
# ================================================================ обзор ==
def _refresh_disks(self) -> None:
for w in self.disk_box.winfo_children():
w.destroy()
c = self.theme.c
for d in (self.result.disks if self.result else [disk_usage(x) for x in drives()]):
if not d["total"]:
continue
row = ttk.Frame(self.disk_box)
row.pack(fill="x", pady=4)
ttk.Label(row, text=d["root"], width=5, style="Dim.TLabel").pack(side="left")
canvas = tk.Canvas(row, height=14, bg=c["bg3"], highlightthickness=0)
canvas.pack(side="left", fill="x", expand=True, padx=(4, 10))
pct = d["percent"]
col = c["danger"] if pct > 90 else (c["warn"] if pct > 80 else c["accent"])
def draw(ev=None, cv=canvas, p=pct, color=col):
cv.delete("all")
w = cv.winfo_width()
cv.create_rectangle(0, 0, w * p / 100, 14, fill=color, outline="")
canvas.bind("<Configure>", draw)
ttk.Label(row, text=f"{human(d['free'])} свободно",
style="Dim.TLabel", width=18).pack(side="right")
if self.result is None:
self.card_vals["free"].set(human(disk_usage(drives()[0])["free"]))
def _fill_overview(self, res: ScanResult) -> None:
safe = sum(f.size for f in res.findings if f.risk == Risk.SAFE)
dev = res.total({Category.DEV_CACHE, Category.DEV_PROJECT})
self.card_vals["total"].set(human(res.total()))
self.card_vals["safe"].set(human(safe))
self.card_vals["dev"].set(human(dev))
sysroot = next((d for d in res.disks if d["root"].upper().startswith("C")), None)
self.card_vals["free"].set(human(sysroot["free"]) if sysroot else "—")
for w in self.cat_box.winfo_children():
w.destroy()
c = self.theme.c
cats = sorted(res.by_category().items(), key=lambda kv: -sum(f.size for f in kv[1]))
top = max((sum(f.size for f in v) for _k, v in cats), default=1) or 1
for cat, items in cats:
total = sum(f.size for f in items)
row = ttk.Frame(self.cat_box)
row.pack(fill="x", pady=3)
ttk.Label(row, text=f"{cat.icon} {cat.label}", width=24,
style="Dim.TLabel").pack(side="left")
cv = tk.Canvas(row, height=12, bg=c["bg3"], highlightthickness=0)
cv.pack(side="left", fill="x", expand=True, padx=(4, 10))
def draw(ev=None, cv=cv, frac=total / top):
cv.delete("all")
w = cv.winfo_width()
cv.create_rectangle(0, 0, max(2, w * frac), 12, fill=c["accent"], outline="")
cv.bind("<Configure>", draw)
ttk.Label(row, text=f"{human(total)} ({len(items)})", width=18,
style="Dim.TLabel").pack(side="right")
self.rec_text.configure(state="normal")
self.rec_text.delete("1.0", "end")
if not res.recommendations:
self.rec_text.insert("end", "Система выглядит чистой — существенного мусора "
"не найдено.\n", "body")
for i, r in enumerate(res.recommendations, 1):
tag = "imp" if r.severity == 2 else "h"
self.rec_text.insert("end", f"{i}. {r.title}\n", tag)
if r.gain:
self.rec_text.insert("end", f" освободится {human(r.gain)}\n", "gain")
self.rec_text.insert("end", r.body + "\n", "body")
self.rec_text.configure(state="disabled")
def _fill_programs(self, res: ScanResult) -> None:
self.ptree.delete(*self.ptree.get_children())
self.prog_rows.clear()
for p in res.programs:
idle = f"{int(p.idle_days)} дн." if p.idle_days >= 0 else "—"
tag = ""
if p.best_size > 2 * 1024 ** 3:
tag = "medium"
if p.idle_days > 365 and p.best_size > 500 * 1024 ** 2:
tag = "high"
iid = self.ptree.insert("", "end", text=p.name,
values=(human(p.best_size), idle, p.version,
p.publisher, p.install_location),
tags=(tag,) if tag else ())
self.prog_rows[iid] = p
def _fill_errors(self, res: ScanResult) -> None:
self.err_text.configure(state="normal")
self.err_text.delete("1.0", "end")
if res.errors:
self.err_text.insert("1.0", "\n".join(res.errors[:400]))
else:
self.err_text.insert("1.0", "Ошибок не было.")
self.err_text.configure(state="disabled")
# =============================================================== очистка =
def do_clean(self) -> None:
if self.busy:
return
sel = [f for f in self.items.values() if f.selected]
if not sel:
messagebox.showinfo("SysDevCleaner", "Сначала отметьте, что нужно удалить.")
return
mode = ("ПРОБНЫЙ ЗАПУСК — ничего не будет удалено." if self.var_dry.get()
else ("Файлы отправятся в Корзину." if self.var_recycle.get()
else "ВНИМАНИЕ: удаление БЕЗВОЗВРАТНОЕ, мимо Корзины."))
if not messagebox.askyesno("Подтверждение очистки",
plan_summary(sel) + "\n\n" + mode + "\n\nПродолжить?"):
return
opts = cleanmod.CleanOptions(to_recycle_bin=self.var_recycle.get(),
dry_run=self.var_dry.get())
self.busy = True
self.cancel.reset()
self.btn_clean.configure(state="disabled")
self.btn_scan.configure(state="disabled")
self.btn_stop.configure(state="normal")
def work() -> None:
try:
res = cleanmod.clean(sel, opts, self.cancel,
lambda m, f: self.q.put(("clean_progress", (m, f))))
self.q.put(("clean_done", res))
except Exception as e: # noqa: BLE001
self.q.put(("error", f"Сбой очистки: {e}"))
threading.Thread(target=work, daemon=True).start()
def _clean_done(self, res) -> None:
self.busy = False
self.btn_scan.configure(state="normal")
self.btn_stop.configure(state="disabled")
self.btn_clean.configure(state="normal")
self.pb.configure(value=1000)
self.var_status.set(res.summary())
self._load_journals()
self._refresh_disks()
lines = [res.summary(), ""]
fails = [i for i in res.items if not i.ok]
if fails:
lines.append(f"Не удалось полностью очистить {len(fails)}:")
for i in fails[:12]:
lines.append(f" • {i.title} — {i.message or 'файлы заняты другой программой'}")
if len(fails) > 12:
lines.append(f" … и ещё {len(fails) - 12}")
lines.append("")
lines.append("Обычная причина — файлы используются запущенной программой "
"или нужны права администратора.")
if res.journal_path:
lines.append("")
lines.append(f"Журнал: {res.journal_path}")
messagebox.showinfo("Очистка завершена", "\n".join(lines))
# убираем из дерева то, что удалось удалить целиком
for item in res.items:
if item.ok and not item.finding.delete_contents_only:
for iid, f in list(self.items.items()):
if f is item.finding:
try:
self.tree.delete(iid)
except tk.TclError:
pass
self.items.pop(iid, None)
for node in list(self.cat_nodes.values()):
try:
self._sync_cat(node)
except tk.TclError:
pass
self._update_sel()
# ============================================================== прочее ===
def save_report(self) -> None:
if self.result is None:
messagebox.showinfo("SysDevCleaner", "Сначала выполните сканирование.")
return
path = filedialog.asksaveasfilename(
title="Сохранить отчёт", defaultextension=".txt",
initialfile=time.strftime("sysdevcleaner-report-%Y%m%d-%H%M.txt"),
filetypes=[("Текстовый файл", "*.txt"), ("Все файлы", "*.*")])
if not path:
return
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(report_text(self.result))
if messagebox.askyesno("SysDevCleaner", "Отчёт сохранён. Открыть его?"):
open_in_explorer(path)
except OSError as e:
messagebox.showerror("SysDevCleaner", f"Не удалось сохранить: {e}")
def _add_root(self) -> None:
d = filedialog.askdirectory(title="Папка для поиска проектов")
if d:
d = os.path.normpath(d)
if d not in self.roots:
self.roots.append(d)
self.roots_list.insert("end", d)
def _del_root(self) -> None:
for i in reversed(self.roots_list.curselection()):
self.roots_list.delete(i)
del self.roots[i]
def _reset_roots(self) -> None:
self.roots = default_project_roots()
self.roots_list.delete(0, "end")
for r in self.roots:
self.roots_list.insert("end", r)
def _uninstall(self) -> None:
sel = self.ptree.selection()
if not sel:
messagebox.showinfo("SysDevCleaner", "Выберите программу в списке.")
return
p = self.prog_rows.get(sel[0])
if p is None:
return
if not messagebox.askyesno(
"Удаление программы",
f"Запустить штатный деинсталлятор?\n\n{p.name}\n{human(p.best_size)}\n\n"
"Откроется окно самой программы — дальнейшие шаги за вами."):
return
ok, err = progmod.uninstall(p)
if not ok:
messagebox.showerror("SysDevCleaner", f"Не удалось запустить удаление: {err}")
def _open_program_folder(self, event: tk.Event) -> None:
iid = self.ptree.identify_row(event.y)
p = self.prog_rows.get(iid)
if p is not None and p.install_location:
open_in_explorer(p.install_location)
def _open_appwiz(self) -> None:
try:
if sys.platform == "win32":
subprocess.Popen(["cmd", "/c", "start", "ms-settings:appsfeatures"], shell=False)
except Exception:
pass
def _load_journals(self) -> None:
self.jtree.delete(*self.jtree.get_children())
for path, when, freed in cleanmod.list_journals():
self.jtree.insert("", "end", values=(when, human(freed), path))
def _open_journal(self) -> None:
sel = self.jtree.selection()
if sel:
open_in_explorer(self.jtree.item(sel[0], "values")[2])
def _toggle_theme(self) -> None:
self.theme.toggle()
self.tags = tag_colors(self.theme)
messagebox.showinfo("SysDevCleaner",
"Тема изменена. Некоторые элементы обновятся после "
"перезапуска приложения.")
for t in (self.tree, self.ptree):
for name, cfg in self.tags.items():
t.tag_configure(name, **cfg)
c = self.theme.c
for txt in (self.rec_text, self.detail, self.err_text):
txt.configure(bg=c["bg2"], fg=c["fg_dim"])
self._refresh_disks()
if self.result:
self._fill_overview(self.result)
def _on_close(self) -> None:
if self.busy:
if not messagebox.askyesno("SysDevCleaner", "Идёт операция. Прервать и выйти?"):
return
self.cancel.cancel()
self.destroy()
def _mb(text: str, default: int) -> int:
try:
return max(1, int(float(text))) * 1024 * 1024
except (TypeError, ValueError):
return default * 1024 * 1024
def _age(f: Finding) -> str:
if not f.mtime:
return "—"
d = int(f.age_days)
if d < 1:
return "сегодня"
if d < 30:
return f"{d} дн."
if d < 365:
return f"{d // 30} мес."
return f"{d // 365} г."
def main() -> None:
app = App()
app.mainloop()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,156 @@
"""Тёмная и светлая темы для Dear PyGui."""
from __future__ import annotations
import dearpygui.dearpygui as dpg
from ..core.model import Risk
DARK = {
"bg": (27, 29, 34),
"bg2": (34, 37, 43),
"bg3": (42, 46, 54),
"fg": (230, 232, 236),
"fg_dim": (154, 161, 173),
"accent": (74, 144, 217),
"accent_hi": (95, 164, 232),
"ok": (62, 181, 110),
"warn": (217, 154, 43),
"danger": (217, 83, 79),
"border": (52, 57, 67),
"sel": (47, 58, 74),
}
LIGHT = {
"bg": (244, 245, 247),
"bg2": (255, 255, 255),
"bg3": (233, 235, 239),
"fg": (29, 33, 39),
"fg_dim": (95, 103, 115),
"accent": (47, 111, 181),
"accent_hi": (61, 130, 207),
"ok": (30, 122, 69),
"warn": (165, 113, 15),
"danger": (184, 51, 47),
"border": (207, 212, 220),
"sel": (214, 228, 245),
}
def risk_color(risk: Risk, palette: dict) -> tuple[int, int, int]:
return {
Risk.SAFE: palette["ok"],
Risk.LOW: palette["accent_hi"],
Risk.MEDIUM: palette["warn"],
Risk.HIGH: palette["danger"],
}[risk]
def _rgba(c: tuple[int, int, int], a: int = 255) -> tuple[int, int, int, int]:
return (c[0], c[1], c[2], a)
def build_theme(p: dict) -> int:
"""Глобальная тема под палитру ``p``."""
with dpg.theme() as theme:
with dpg.theme_component(dpg.mvAll):
col = dpg.add_theme_color
col(dpg.mvThemeCol_WindowBg, _rgba(p["bg"]))
col(dpg.mvThemeCol_ChildBg, _rgba(p["bg"]))
col(dpg.mvThemeCol_PopupBg, _rgba(p["bg2"]))
col(dpg.mvThemeCol_Border, _rgba(p["border"]))
col(dpg.mvThemeCol_BorderShadow, (0, 0, 0, 0))
col(dpg.mvThemeCol_Text, _rgba(p["fg"]))
col(dpg.mvThemeCol_TextDisabled, _rgba(p["fg_dim"]))
col(dpg.mvThemeCol_FrameBg, _rgba(p["bg3"]))
col(dpg.mvThemeCol_FrameBgHovered, _rgba(p["border"]))
col(dpg.mvThemeCol_FrameBgActive, _rgba(p["sel"]))
col(dpg.mvThemeCol_TitleBg, _rgba(p["bg2"]))
col(dpg.mvThemeCol_TitleBgActive, _rgba(p["bg2"]))
col(dpg.mvThemeCol_TitleBgCollapsed, _rgba(p["bg2"]))
col(dpg.mvThemeCol_MenuBarBg, _rgba(p["bg2"]))
col(dpg.mvThemeCol_Button, _rgba(p["bg3"]))
col(dpg.mvThemeCol_ButtonHovered, _rgba(p["border"]))
col(dpg.mvThemeCol_ButtonActive, _rgba(p["sel"]))
col(dpg.mvThemeCol_Header, _rgba(p["bg2"]))
col(dpg.mvThemeCol_HeaderHovered, _rgba(p["sel"]))
col(dpg.mvThemeCol_HeaderActive, _rgba(p["sel"]))
col(dpg.mvThemeCol_Tab, _rgba(p["bg"]))
col(dpg.mvThemeCol_TabHovered, _rgba(p["bg3"]))
col(dpg.mvThemeCol_TabActive, _rgba(p["bg2"]))
col(dpg.mvThemeCol_TabUnfocused, _rgba(p["bg"]))
col(dpg.mvThemeCol_TabUnfocusedActive, _rgba(p["bg2"]))
col(dpg.mvThemeCol_CheckMark, _rgba(p["accent_hi"]))
col(dpg.mvThemeCol_SliderGrab, _rgba(p["accent"]))
col(dpg.mvThemeCol_SliderGrabActive, _rgba(p["accent_hi"]))
col(dpg.mvThemeCol_PlotHistogram, _rgba(p["accent"]))
col(dpg.mvThemeCol_PlotHistogramHovered, _rgba(p["accent_hi"]))
col(dpg.mvThemeCol_ScrollbarBg, _rgba(p["bg"]))
col(dpg.mvThemeCol_ScrollbarGrab, _rgba(p["bg3"]))
col(dpg.mvThemeCol_ScrollbarGrabHovered, _rgba(p["border"]))
col(dpg.mvThemeCol_ScrollbarGrabActive, _rgba(p["accent"]))
col(dpg.mvThemeCol_Separator, _rgba(p["border"]))
col(dpg.mvThemeCol_TableHeaderBg, _rgba(p["bg3"]))
col(dpg.mvThemeCol_TableBorderStrong, _rgba(p["border"]))
col(dpg.mvThemeCol_TableBorderLight, _rgba(p["border"]))
col(dpg.mvThemeCol_TableRowBg, _rgba(p["bg"]))
col(dpg.mvThemeCol_TableRowBgAlt, _rgba(p["bg2"], 120))
st = dpg.add_theme_style
st(dpg.mvStyleVar_FrameRounding, 4)
st(dpg.mvStyleVar_ChildRounding, 6)
st(dpg.mvStyleVar_PopupRounding, 6)
st(dpg.mvStyleVar_WindowRounding, 6)
st(dpg.mvStyleVar_GrabRounding, 4)
st(dpg.mvStyleVar_TabRounding, 4)
st(dpg.mvStyleVar_ScrollbarRounding, 6)
st(dpg.mvStyleVar_FramePadding, 10, 6)
st(dpg.mvStyleVar_ItemSpacing, 9, 7)
st(dpg.mvStyleVar_ItemInnerSpacing, 7, 5)
st(dpg.mvStyleVar_WindowPadding, 14, 12)
st(dpg.mvStyleVar_CellPadding, 7, 5)
st(dpg.mvStyleVar_ScrollbarSize, 13)
st(dpg.mvStyleVar_WindowBorderSize, 0)
st(dpg.mvStyleVar_FrameBorderSize, 0)
return theme
def build_accent_button_theme(p: dict, base: str = "accent") -> int:
"""Тема для выделенной кнопки (основное действие / опасное действие)."""
color = p[base]
hi = tuple(min(255, v + 22) for v in color)
with dpg.theme() as theme:
with dpg.theme_component(dpg.mvAll):
dpg.add_theme_color(dpg.mvThemeCol_Button, _rgba(color))
dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, _rgba(hi))
dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, _rgba(color))
dpg.add_theme_color(dpg.mvThemeCol_Text, (255, 255, 255, 255))
dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 14, 8)
return theme
def build_card_theme(p: dict) -> int:
"""Тема для «карточки» — дочернего окна с более светлым фоном."""
with dpg.theme() as theme:
with dpg.theme_component(dpg.mvAll):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, _rgba(p["bg2"]))
dpg.add_theme_style(dpg.mvStyleVar_ChildRounding, 8)
dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 16, 14)
return theme
def build_bar_theme(p: dict, color_key: str) -> int:
"""Тема полосы заполнения (диски, категории)."""
with dpg.theme() as theme:
with dpg.theme_component(dpg.mvAll):
dpg.add_theme_color(dpg.mvThemeCol_PlotHistogram, _rgba(p[color_key]))
dpg.add_theme_color(dpg.mvThemeCol_FrameBg, _rgba(p["bg3"]))
dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 3)
return theme

215
sysdevcleaner/gui/fonts.py Normal file
View File

@@ -0,0 +1,215 @@
"""Шрифты для Dear PyGui с поддержкой кириллицы.
Почему без этого модуля интерфейс будет в квадратах
---------------------------------------------------
Dear PyGui использует Dear ImGui, а тот по умолчанию берёт встроенный шрифт
ProggyClean, в котором кириллицы нет вообще. Любой русский текст превращается в
пустые прямоугольники. Лечится это регистрацией собственного TTF.
Порядок вызовов важен:
1. ``dpg.create_context()``;
2. ``dpg.font_registry()`` → ``dpg.font(путь, размер)`` — регистрируем TTF,
в котором кириллица есть;
3. ``dpg.bind_font(...)`` — внутри блока реестра, пока шрифт заведомо жив;
4. и только потом ``dpg.setup_dearpygui()`` — на этом шаге собирается атлас
глифов, после него менять состав символов уже поздно.
Про диапазоны символов
----------------------
В Dear PyGui **1.x** нужно было явно просить нужные блоки Unicode::
dpg.add_font_range_hint(dpg.mvFontRangeHint_Default) # латиница, цифры
dpg.add_font_range_hint(dpg.mvFontRangeHint_Cyrillic) # кириллица
Начиная с **2.0** эти вызовы объявлены устаревшими и ничего не делают:
диапазоны определяются автоматически по содержимому шрифта
(«This call is a no-op because character ranges are now automatic»).
Мы всё равно их вызываем — чтобы модуль работал и на 1.x, — но глушим
предупреждения, чтобы не засорять вывод на 2.x.
Проверено на Dear PyGui 2.3.1 + Segoe UI: кириллица, «кавычки», ·, ×, –, …
отображаются корректно. А вот ⚠ и ✓ есть не во всех системных шрифтах
(в Consolas их нет), поэтому в интерфейсе они не используются.
"""
from __future__ import annotations
import os
import warnings
import dearpygui.dearpygui as dpg
# Шрифты в порядке предпочтения. Первым идёт JetBrains Mono Nerd Font: в нём
# есть и полная кириллица, и иконочные глифы Nerd Font, так что интерфейсу
# не нужен отдельный иконочный шрифт.
CANDIDATES = (
"JetBrainsMonoNerdFont-Regular.ttf", # иконки натуральной ширины
"JetBrainsMonoNLNerdFont-Regular.ttf", # без лигатур
"JetBrainsMonoNerdFontPropo-Regular.ttf",
"JetBrainsMonoNerdFontMono-Regular.ttf",
"JetBrainsMono-Regular.ttf", # обычный JetBrains Mono — без иконок
"segoeui.ttf", # системные запасные варианты
"calibri.ttf",
"tahoma.ttf",
"arial.ttf",
)
BOLD_CANDIDATES = (
"JetBrainsMonoNerdFont-Bold.ttf",
"JetBrainsMonoNLNerdFont-Bold.ttf",
"JetBrainsMonoNerdFontPropo-Bold.ttf",
"JetBrainsMonoNerdFontMono-Bold.ttf",
"JetBrainsMono-Bold.ttf",
"segoeuib.ttf",
"calibrib.ttf",
"arialbd.ttf",
)
MONO_CANDIDATES = (
"JetBrainsMonoNerdFontMono-Regular.ttf", # иконки в одну ячейку
"JetBrainsMonoNerdFont-Regular.ttf",
"JetBrainsMono-Regular.ttf",
"consola.ttf",
"cour.ttf",
)
def _font_dirs() -> list[str]:
"""Где искать TTF: вложенные в сборку, рядом с программой, системные."""
import sys
here = os.path.dirname(os.path.abspath(__file__))
project = os.path.dirname(os.path.dirname(here))
windir = os.environ.get("WINDIR", r"C:\Windows")
local = os.environ.get("LOCALAPPDATA", "")
dirs = []
# шрифты, вшитые в exe: PyInstaller распаковывает их во временный _MEIPASS
meipass = getattr(sys, "_MEIPASS", "")
if meipass:
dirs.append(os.path.join(meipass, "fonts"))
# рядом с самим exe — можно подложить свой шрифт без пересборки
dirs.append(os.path.join(os.path.dirname(os.path.abspath(sys.executable)), "fonts"))
dirs += [
os.path.join(project, "fonts"),
os.path.join(os.getcwd(), "fonts"),
os.path.join(windir, "Fonts"),
]
if local:
dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts"))
seen, out = set(), []
for d in dirs:
key = os.path.normcase(d)
if key not in seen and os.path.isdir(d):
seen.add(key)
out.append(d)
return out
def find_font(candidates: tuple[str, ...] = CANDIDATES) -> str | None:
"""Первый существующий TTF из списка кандидатов."""
for directory in _font_dirs():
for name in candidates:
path = os.path.join(directory, name)
if os.path.isfile(path):
return path
# запасной вариант — любой .ttf из папки fonts, положенной рядом с программой
for directory in _font_dirs():
if not directory.lower().endswith("fonts") or "windows" in directory.lower():
continue
try:
for entry in sorted(os.listdir(directory)):
if entry.lower().endswith((".ttf", ".otf")):
return os.path.join(directory, entry)
except OSError:
continue
return None
def _add_ranges() -> None:
"""Диапазоны символов. На Dear PyGui 2.x — no-op, нужны только для 1.x."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
try:
dpg.add_font_range_hint(dpg.mvFontRangeHint_Default) # латиница, цифры
dpg.add_font_range_hint(dpg.mvFontRangeHint_Cyrillic) # кириллица
except Exception:
pass
try:
# знаки вне базовых блоков, которые встречаются в подписях
dpg.add_font_chars([0x00AB, 0x00BB, 0x00B7, 0x00D7, 0x00A0,
0x2013, 0x2014, 0x2026, 0x2192, 0x2007])
except Exception:
pass
try:
# зона частного использования — иконки Nerd Font.
# Берём только BMP: Material Design Icons лежат выше U+FFFF,
# а Dear ImGui собран с 16-битным ImWchar и их не отрисует.
dpg.add_font_range(0xE000, 0xF8FF)
except Exception:
pass
class Fonts:
"""Набор шрифтов приложения. Строить строго до ``setup_dearpygui()``."""
def __init__(self, base_size: int = 17) -> None:
self.base_size = base_size
self.path = find_font()
self.bold_path = find_font(BOLD_CANDIDATES) or self.path
self.mono_path = find_font(MONO_CANDIDATES) or self.path
self.default = None
self.small = None
self.h1 = None
self.h2 = None
self.big = None
self.mono = None
@property
def ok(self) -> bool:
return self.path is not None
@property
def has_icons(self) -> bool:
"""Найден ли Nerd Font — от этого зависит, показывать ли иконки."""
return bool(self.path) and "nerdfont" in os.path.basename(self.path).lower()
def build(self) -> None:
"""Зарегистрировать шрифты и применить основной ко всему интерфейсу."""
if not self.path:
return # кириллицы не будет, но приложение запустится
s = self.base_size
with dpg.font_registry():
with dpg.font(self.path, s) as self.default:
_add_ranges()
with dpg.font(self.path, max(12, s - 3)) as self.small:
_add_ranges()
with dpg.font(self.bold_path, s + 10) as self.h1:
_add_ranges()
with dpg.font(self.bold_path, s + 2) as self.h2:
_add_ranges()
with dpg.font(self.bold_path, s + 16) as self.big:
_add_ranges()
with dpg.font(self.mono_path, max(12, s - 3)) as self.mono:
_add_ranges()
# bind_font — внутри блока реестра: шрифт уже создан и ещё «в области видимости»
dpg.bind_font(self.default)
@staticmethod
def apply(item, font) -> None:
"""Назначить отдельному элементу другой шрифт (заголовок, крупная цифра)."""
if font is None or item is None:
return
try:
dpg.bind_item_font(item, font)
except Exception:
pass
MISSING_FONT_MESSAGE = (
"Не найден TTF-шрифт с кириллицей. Положите любой такой шрифт "
"(например, Roboto-Regular.ttf) в папку fonts рядом с приложением, "
"иначе русский текст будет отображаться квадратами."
)

View File

@@ -0,0 +1,83 @@
"""Иконки Nerd Font (JetBrains Mono NF).
Nerd Font добавляет к обычному шрифту иконочные глифы в зонах частного
использования Unicode. Мы берём только глифы из BMP (U+E000–U+F8FF):
набор Material Design Icons в Nerd Font лежит выше U+FFFF, а Dear ImGui
по умолчанию собран с 16-битным ``ImWchar`` и такие символы не покажет.
Используемые наборы:
* Font Awesome 4 — U+F000…U+F2E0
* Devicons — U+E700…U+E7C5
* Codicons — U+EA60…U+EBEB
"""
from __future__ import annotations
from ..core.model import Category
# --- общие ---------------------------------------------------------------
SEARCH = "" # лупа
STOP = "" # стоп
REFRESH = "" # обновить
TRASH = "" # корзина
BROOM = "" # магия/очистка
SAVE = "" # дискета
FOLDER_OPEN = "" # открытая папка
FOLDER = "" # папка
COPY = "" # копия
FILTER = "" # фильтр
GEAR = "" # шестерёнка
INFO = "" # инфо
WARNING = "" # треугольник с восклицательным знаком
CHECK = "" # галочка
CLOSE = "" # крестик
LIGHTBULB = "" # лампочка — рекомендация
BOLT = "" # молния — быстрое действие
SHIELD = "" # щит — безопасность
HISTORY = "" # история
CHART = "" # столбики
HDD = "" # диск
LIST = "" # список
PLUS = ""
MINUS = ""
UNDO = ""
SUN = ""
MOON = ""
EXPAND = ""
COLLAPSE = ""
CHEVRON_RIGHT = ""
CUBE = ""
ARCHIVE = ""
WRENCH = ""
FILE_TEXT = ""
GLOBE = ""
GAMEPAD = ""
WINDOWS = ""
COGS = ""
ELEPHANT = "" # архивный файл — «тяжёлые файлы»
CLOCK = ""
DATABASE = ""
POWER = ""
# --- иконки категорий ----------------------------------------------------
CATEGORY: dict[Category, str] = {
Category.WINDOWS: WINDOWS,
Category.BROWSERS: GLOBE,
Category.DEV_CACHE: COGS,
Category.DEV_PROJECT: CUBE,
Category.APPS: GAMEPAD,
Category.LOGS: FILE_TEXT,
Category.PACKAGE_MANAGERS: ARCHIVE,
Category.BIG_FILES: ELEPHANT,
Category.DUPLICATES: COPY,
Category.PROGRAMS: FOLDER,
}
def cat(category: Category) -> str:
"""Иконка категории с отступом, готовая к подстановке в подпись."""
return CATEGORY.get(category, LIST) + " "
def btn(icon: str, label: str) -> str:
"""Подпись кнопки: иконка, два пробела, текст."""
return f"{icon} {label}"

View File

@@ -0,0 +1,151 @@
"""Оформление интерфейса."""
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
DARK = {
"bg": "#1b1d22",
"bg2": "#22252b",
"bg3": "#2a2e36",
"fg": "#e6e8ec",
"fg_dim": "#9aa1ad",
"accent": "#4a90d9",
"accent_hi": "#5fa4e8",
"ok": "#2e9e5b",
"warn": "#d99a2b",
"danger": "#d9534f",
"border": "#343943",
"sel": "#2f3a4a",
}
LIGHT = {
"bg": "#f4f5f7",
"bg2": "#ffffff",
"bg3": "#e9ebef",
"fg": "#1d2127",
"fg_dim": "#5f6773",
"accent": "#2f6fb5",
"accent_hi": "#3d82cf",
"ok": "#1e7a45",
"warn": "#a5710f",
"danger": "#b8332f",
"border": "#cfd4dc",
"sel": "#d6e4f5",
}
class Theme:
def __init__(self, root: tk.Misc, dark: bool = True) -> None:
self.root = root
self.dark = dark
self.c = DARK if dark else LIGHT
self.style = ttk.Style(root)
self.apply()
def toggle(self) -> None:
self.dark = not self.dark
self.c = DARK if self.dark else LIGHT
self.apply()
def apply(self) -> None:
c = self.c
s = self.style
try:
s.theme_use("clam")
except tk.TclError:
pass
self.root.configure(bg=c["bg"])
base_font = ("Segoe UI", 10)
s.configure(".", background=c["bg"], foreground=c["fg"], font=base_font,
fieldbackground=c["bg2"], bordercolor=c["border"],
lightcolor=c["bg2"], darkcolor=c["bg"])
s.configure("TFrame", background=c["bg"])
s.configure("Card.TFrame", background=c["bg2"], relief="flat")
s.configure("TLabel", background=c["bg"], foreground=c["fg"])
s.configure("Card.TLabel", background=c["bg2"], foreground=c["fg"])
s.configure("Dim.TLabel", background=c["bg"], foreground=c["fg_dim"])
s.configure("CardDim.TLabel", background=c["bg2"], foreground=c["fg_dim"])
s.configure("H1.TLabel", background=c["bg"], foreground=c["fg"],
font=("Segoe UI Semibold", 17))
s.configure("H2.TLabel", background=c["bg"], foreground=c["fg"],
font=("Segoe UI Semibold", 12))
s.configure("CardH2.TLabel", background=c["bg2"], foreground=c["fg"],
font=("Segoe UI Semibold", 12))
s.configure("Big.TLabel", background=c["bg"], foreground=c["accent_hi"],
font=("Segoe UI Semibold", 26))
s.configure("CardBig.TLabel", background=c["bg2"], foreground=c["accent_hi"],
font=("Segoe UI Semibold", 24))
s.configure("TButton", background=c["bg3"], foreground=c["fg"],
borderwidth=0, focusthickness=0, padding=(14, 7))
s.map("TButton",
background=[("active", c["border"]), ("disabled", c["bg2"])],
foreground=[("disabled", c["fg_dim"])])
s.configure("Accent.TButton", background=c["accent"], foreground="#ffffff",
padding=(16, 8), font=("Segoe UI Semibold", 10))
s.map("Accent.TButton",
background=[("active", c["accent_hi"]), ("disabled", c["bg3"])],
foreground=[("disabled", c["fg_dim"])])
s.configure("Danger.TButton", background=c["danger"], foreground="#ffffff",
padding=(16, 8), font=("Segoe UI Semibold", 10))
s.map("Danger.TButton", background=[("active", "#e2645f"), ("disabled", c["bg3"])],
foreground=[("disabled", c["fg_dim"])])
s.configure("TNotebook", background=c["bg"], borderwidth=0, tabmargins=(8, 6, 8, 0))
s.configure("TNotebook.Tab", background=c["bg"], foreground=c["fg_dim"],
padding=(18, 9), borderwidth=0, font=("Segoe UI", 10))
s.map("TNotebook.Tab",
background=[("selected", c["bg2"])],
foreground=[("selected", c["fg"])])
s.configure("Treeview", background=c["bg2"], fieldbackground=c["bg2"],
foreground=c["fg"], borderwidth=0, rowheight=26)
s.configure("Treeview.Heading", background=c["bg3"], foreground=c["fg_dim"],
borderwidth=0, padding=(8, 6), font=("Segoe UI Semibold", 9))
s.map("Treeview.Heading", background=[("active", c["border"])])
s.map("Treeview", background=[("selected", c["sel"])],
foreground=[("selected", c["fg"])])
s.configure("TCheckbutton", background=c["bg"], foreground=c["fg"],
focuscolor=c["bg"])
s.map("TCheckbutton", background=[("active", c["bg"])])
s.configure("Card.TCheckbutton", background=c["bg2"], foreground=c["fg"])
s.map("Card.TCheckbutton", background=[("active", c["bg2"])])
s.configure("TRadiobutton", background=c["bg"], foreground=c["fg"])
s.map("TRadiobutton", background=[("active", c["bg"])])
s.configure("TEntry", fieldbackground=c["bg2"], foreground=c["fg"],
bordercolor=c["border"], insertcolor=c["fg"], padding=6)
s.configure("TCombobox", fieldbackground=c["bg2"], foreground=c["fg"],
background=c["bg3"], arrowcolor=c["fg"])
s.configure("TProgressbar", background=c["accent"], troughcolor=c["bg3"],
borderwidth=0, thickness=6)
s.configure("Thin.Horizontal.TProgressbar", background=c["accent"],
troughcolor=c["bg3"], borderwidth=0, thickness=6)
s.configure("TSeparator", background=c["border"])
s.configure("TPanedwindow", background=c["bg"])
s.configure("Vertical.TScrollbar", background=c["bg3"], troughcolor=c["bg"],
borderwidth=0, arrowcolor=c["fg_dim"])
s.map("Vertical.TScrollbar", background=[("active", c["border"])])
s.configure("Horizontal.TScrollbar", background=c["bg3"], troughcolor=c["bg"],
borderwidth=0, arrowcolor=c["fg_dim"])
def tag_colors(theme: Theme) -> dict[str, dict]:
c = theme.c
return {
"safe": {"foreground": c["ok"]},
"low": {"foreground": c["accent_hi"]},
"medium": {"foreground": c["warn"]},
"high": {"foreground": c["danger"]},
"cat": {"foreground": c["fg"], "font": ("Segoe UI Semibold", 10)},
"dim": {"foreground": c["fg_dim"]},
}

144
sysdevcleaner/winui.py Normal file
View File

@@ -0,0 +1,144 @@
"""Работа с нативным окном Windows и повышение прав.
Здесь две вещи, которые Dear PyGui сам не умеет:
* **Заголовок окна.** ``dpg.create_viewport(title=...)`` отдаёт строку в ANSI-функцию
Windows как UTF-8 байты, поэтому русский заголовок превращается в
«SysDevCleaner вЂ" анализ...». Лечится переустановкой заголовка через
``SetWindowTextW`` — она принимает UTF-16 и показывает текст правильно.
* **Права администратора.** Без них недоступны ``Windows\\Temp``, кеш обновлений,
Prefetch, ``Windows.old`` и ещё несколько системных каталогов.
"""
from __future__ import annotations
import ctypes
import os
import sys
IS_WIN = sys.platform == "win32"
# Служебный ASCII-заголовок: ставим его в create_viewport, чтобы гарантированно
# найти окно по имени, а потом заменяем на настоящий.
BOOT_TITLE = "SysDevCleaner (starting)"
# Переменная окружения-предохранитель от бесконечного перезапуска.
NO_ELEVATE_ENV = "SYSDEVCLEAN_NO_ELEVATE"
def is_frozen() -> bool:
"""Запущены ли мы из собранного PyInstaller exe."""
return bool(getattr(sys, "frozen", False))
def icon_path() -> str:
"""Путь к sysdevcleaner.ico: сначала внутри сборки, потом рядом с проектом."""
names = ("sysdevcleaner.ico",)
roots = []
meipass = getattr(sys, "_MEIPASS", "")
if meipass:
roots.append(meipass)
roots.append(os.path.dirname(os.path.abspath(sys.executable)))
roots.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
roots.append(os.getcwd())
for root in roots:
for name in names:
path = os.path.join(root, name)
if os.path.isfile(path):
return path
return ""
def is_admin() -> bool:
if not IS_WIN:
return hasattr(os, "geteuid") and os.geteuid() == 0
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
# ------------------------------------------------------------- заголовок ----
def set_window_title(text: str, find_by: str = BOOT_TITLE) -> bool:
"""Поставить окну настоящий заголовок через Unicode-API.
Возвращает True, если окно нашлось и заголовок применён.
"""
if not IS_WIN:
return False
try:
user32 = ctypes.windll.user32
hwnd = user32.FindWindowW(None, find_by)
if not hwnd:
return False
user32.SetWindowTextW(hwnd, text)
return True
except Exception:
return False
# ----------------------------------------------------- повышение прав -------
def _python_launcher() -> str:
"""Для графического режима предпочитаем pythonw.exe — без окна консоли."""
exe = sys.executable
if not exe:
return ""
folder, name = os.path.split(exe)
if name.lower() == "python.exe":
alt = os.path.join(folder, "pythonw.exe")
if os.path.isfile(alt):
return alt
return exe
def _quote(text: str) -> str:
return f'"{text}"' if " " in text and not text.startswith('"') else text
def relaunch_as_admin(extra_args: list[str] | None = None) -> bool:
"""Перезапустить приложение с правами администратора.
True — повышенная копия запущена, текущий процесс должен завершиться;
False — повышать не нужно или не получилось (пользователь нажал «Нет»),
работаем дальше с обычными правами.
"""
if not IS_WIN or is_admin():
return False
if os.environ.get(NO_ELEVATE_ENV):
return False
args = list(extra_args if extra_args is not None else sys.argv[1:])
if is_frozen():
target = sys.executable
params = " ".join(_quote(a) for a in args)
workdir = os.path.dirname(os.path.abspath(sys.executable))
else:
target = _python_launcher()
if not target:
return False
# каталог, в котором лежит пакет sysdevcleaner — оттуда работает `-m sysdevcleaner`
workdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
params = " ".join(["-m", "sysdevcleaner", *(_quote(a) for a in args)])
try:
# ShellExecuteW с глаголом "runas" показывает запрос UAC
rc = ctypes.windll.shell32.ShellExecuteW(
None, "runas", target, params, workdir, 1)
except Exception:
return False
# всё, что больше 32, — успех; иначе обычно ошибка 5 (пользователь отказался)
return int(rc) > 32
def ensure_admin() -> bool:
"""Вызвать перед запуском интерфейса.
True — нужно завершить текущий процесс: работать продолжит повышенная копия.
"""
if is_admin():
return False
return relaunch_as_admin()