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