From b01cf37e66e53928e440efc0f4b0af9d74a993aa Mon Sep 17 00:00:00 2001 From: HeagBoKaT Date: Fri, 11 Sep 2026 14:55:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D1=83=D1=81=D0=BA=20SysDevCleaner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Анализ 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 --- .gitignore | 6 + README.md | 276 +++++++++ SysDevCleaner-no-admin.bat | 30 + SysDevCleaner.bat | 38 ++ build-exe.bat | 28 + build_exe.py | 201 ++++++ make_icon.py | 111 ++++ requirements.txt | 4 + run.py | 11 + sysdevcleaner-cli.bat | 14 + sysdevcleaner.ico | Bin 0 -> 32547 bytes sysdevcleaner.png | Bin 0 -> 15866 bytes sysdevcleaner/__init__.py | 3 + sysdevcleaner/__main__.py | 46 ++ sysdevcleaner/cli.py | 188 ++++++ sysdevcleaner/core/__init__.py | 1 + sysdevcleaner/core/advisor.py | 336 ++++++++++ sysdevcleaner/core/cleaner.py | 251 ++++++++ sysdevcleaner/core/devscan.py | 188 ++++++ sysdevcleaner/core/heavy.py | 220 +++++++ sysdevcleaner/core/model.py | 172 ++++++ sysdevcleaner/core/programs.py | 185 ++++++ sysdevcleaner/core/rules.py | 645 +++++++++++++++++++ sysdevcleaner/core/scanner.py | 328 ++++++++++ sysdevcleaner/core/util.py | 396 ++++++++++++ sysdevcleaner/gui/__init__.py | 1 + sysdevcleaner/gui/app.py | 1054 ++++++++++++++++++++++++++++++++ sysdevcleaner/gui/app_tk.py | 990 ++++++++++++++++++++++++++++++ sysdevcleaner/gui/dpg_theme.py | 156 +++++ sysdevcleaner/gui/fonts.py | 215 +++++++ sysdevcleaner/gui/icons.py | 83 +++ sysdevcleaner/gui/theme_tk.py | 151 +++++ sysdevcleaner/winui.py | 144 +++++ 33 files changed, 6472 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SysDevCleaner-no-admin.bat create mode 100644 SysDevCleaner.bat create mode 100644 build-exe.bat create mode 100644 build_exe.py create mode 100644 make_icon.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 sysdevcleaner-cli.bat create mode 100644 sysdevcleaner.ico create mode 100644 sysdevcleaner.png create mode 100644 sysdevcleaner/__init__.py create mode 100644 sysdevcleaner/__main__.py create mode 100644 sysdevcleaner/cli.py create mode 100644 sysdevcleaner/core/__init__.py create mode 100644 sysdevcleaner/core/advisor.py create mode 100644 sysdevcleaner/core/cleaner.py create mode 100644 sysdevcleaner/core/devscan.py create mode 100644 sysdevcleaner/core/heavy.py create mode 100644 sysdevcleaner/core/model.py create mode 100644 sysdevcleaner/core/programs.py create mode 100644 sysdevcleaner/core/rules.py create mode 100644 sysdevcleaner/core/scanner.py create mode 100644 sysdevcleaner/core/util.py create mode 100644 sysdevcleaner/gui/__init__.py create mode 100644 sysdevcleaner/gui/app.py create mode 100644 sysdevcleaner/gui/app_tk.py create mode 100644 sysdevcleaner/gui/dpg_theme.py create mode 100644 sysdevcleaner/gui/fonts.py create mode 100644 sysdevcleaner/gui/icons.py create mode 100644 sysdevcleaner/gui/theme_tk.py create mode 100644 sysdevcleaner/winui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..966c93f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +dist/ +fonts/ +__pycache__/ +*.pyc +*.spec diff --git a/README.md b/README.md new file mode 100644 index 0000000..22c8096 --- /dev/null +++ b/README.md @@ -0,0 +1,276 @@ +# SysDevCleaner + +значок 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 консольный режим +``` + +## Что приложение не делает + +Не трогает реестр, не «оптимизирует» систему, не отключает службы и не удаляет +ничего без вашей отметки. Список установленных программ показывается для +анализа — удаление запускает штатный деинсталлятор, а не вычищает файлы само. diff --git a/SysDevCleaner-no-admin.bat b/SysDevCleaner-no-admin.bat new file mode 100644 index 0000000..3acd3a5 --- /dev/null +++ b/SysDevCleaner-no-admin.bat @@ -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 + diff --git a/SysDevCleaner.bat b/SysDevCleaner.bat new file mode 100644 index 0000000..e715331 --- /dev/null +++ b/SysDevCleaner.bat @@ -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 + diff --git a/build-exe.bat b/build-exe.bat new file mode 100644 index 0000000..0f8fe19 --- /dev/null +++ b/build-exe.bat @@ -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 + diff --git a/build_exe.py b/build_exe.py new file mode 100644 index 0000000..b08c977 --- /dev/null +++ b/build_exe.py @@ -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()) diff --git a/make_icon.py b/make_icon.py new file mode 100644 index 0000000..85a7a11 --- /dev/null +++ b/make_icon.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..03f77c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Интерфейс. Работает и на 1.x, и на 2.x: +# 1.x — диапазоны шрифта задаются через add_font_range_hint; +# 2.x — диапазоны определяются автоматически, вызовы игнорируются. +dearpygui>=1.9 diff --git a/run.py b/run.py new file mode 100644 index 0000000..edf741f --- /dev/null +++ b/run.py @@ -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()) diff --git a/sysdevcleaner-cli.bat b/sysdevcleaner-cli.bat new file mode 100644 index 0000000..b3498a7 --- /dev/null +++ b/sysdevcleaner-cli.bat @@ -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 + diff --git a/sysdevcleaner.ico b/sysdevcleaner.ico new file mode 100644 index 0000000000000000000000000000000000000000..c4b0548c1f4902aca9f47019f14a3e6f5caad71c GIT binary patch literal 32547 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 literal 0 HcmV?d00001 diff --git a/sysdevcleaner.png b/sysdevcleaner.png new file mode 100644 index 0000000000000000000000000000000000000000..588a86e0d47348f4b6632ed0bce8cd9eb0bc992d GIT binary patch literal 15866 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 literal 0 HcmV?d00001 diff --git a/sysdevcleaner/__init__.py b/sysdevcleaner/__init__.py new file mode 100644 index 0000000..c11d9a8 --- /dev/null +++ b/sysdevcleaner/__init__.py @@ -0,0 +1,3 @@ +"""SysDevCleaner — анализ и очистка кешей, мусора и остатков разработки.""" + +__version__ = "1.0.0" diff --git a/sysdevcleaner/__main__.py b/sysdevcleaner/__main__.py new file mode 100644 index 0000000..fdeecc2 --- /dev/null +++ b/sysdevcleaner/__main__.py @@ -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()) diff --git a/sysdevcleaner/cli.py b/sysdevcleaner/cli.py new file mode 100644 index 0000000..3ad02a4 --- /dev/null +++ b/sysdevcleaner/cli.py @@ -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()) diff --git a/sysdevcleaner/core/__init__.py b/sysdevcleaner/core/__init__.py new file mode 100644 index 0000000..3a779ab --- /dev/null +++ b/sysdevcleaner/core/__init__.py @@ -0,0 +1 @@ +"""Ядро SysDevCleaner.""" diff --git a/sysdevcleaner/core/advisor.py b/sysdevcleaner/core/advisor.py new file mode 100644 index 0000000..51be8eb --- /dev/null +++ b/sysdevcleaner/core/advisor.py @@ -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) diff --git a/sysdevcleaner/core/cleaner.py b/sysdevcleaner/core/cleaner.py new file mode 100644 index 0000000..72629f7 --- /dev/null +++ b/sysdevcleaner/core/cleaner.py @@ -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 diff --git a/sysdevcleaner/core/devscan.py b/sysdevcleaner/core/devscan.py new file mode 100644 index 0000000..8e909c4 --- /dev/null +++ b/sysdevcleaner/core/devscan.py @@ -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])) diff --git a/sysdevcleaner/core/heavy.py b/sysdevcleaner/core/heavy.py new file mode 100644 index 0000000..465ad9f --- /dev/null +++ b/sysdevcleaner/core/heavy.py @@ -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] diff --git a/sysdevcleaner/core/model.py b/sysdevcleaner/core/model.py new file mode 100644 index 0000000..6e096bc --- /dev/null +++ b/sysdevcleaner/core/model.py @@ -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 diff --git a/sysdevcleaner/core/programs.py b/sysdevcleaner/core/programs.py new file mode 100644 index 0000000..cce7f67 --- /dev/null +++ b/sysdevcleaner/core/programs.py @@ -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) diff --git a/sysdevcleaner/core/rules.py b/sysdevcleaner/core/rules.py new file mode 100644 index 0000000..fe62fba --- /dev/null +++ b/sysdevcleaner/core/rules.py @@ -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] diff --git a/sysdevcleaner/core/scanner.py b/sysdevcleaner/core/scanner.py new file mode 100644 index 0000000..19b8ea5 --- /dev/null +++ b/sysdevcleaner/core/scanner.py @@ -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):] diff --git a/sysdevcleaner/core/util.py b/sysdevcleaner/core/util.py new file mode 100644 index 0000000..1fb3a99 --- /dev/null +++ b/sysdevcleaner/core/util.py @@ -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 diff --git a/sysdevcleaner/gui/__init__.py b/sysdevcleaner/gui/__init__.py new file mode 100644 index 0000000..c7ea2dc --- /dev/null +++ b/sysdevcleaner/gui/__init__.py @@ -0,0 +1 @@ +"""Графический интерфейс SysDevCleaner.""" diff --git a/sysdevcleaner/gui/app.py b/sysdevcleaner/gui/app.py new file mode 100644 index 0000000..548c923 --- /dev/null +++ b/sysdevcleaner/gui/app.py @@ -0,0 +1,1054 @@ +"""SysDevCleaner — интерфейс на Dear PyGui.""" +from __future__ import annotations + +import os +import queue +import subprocess +import sys +import threading +import time + +import dearpygui.dearpygui as dpg + +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, Program, Risk, ScanResult +from ..core.scanner import (ScanOptions, default_project_roots, full_scan, + normalize_drives) +from ..core.util import Cancel, disk_usage, drives, human, is_admin, open_in_explorer +from ..winui import BOOT_TITLE, icon_path, set_window_title +from . import icons as I +from .dpg_theme import (DARK, LIGHT, build_accent_button_theme, build_bar_theme, + build_card_theme, build_theme, risk_color) +from .fonts import MISSING_FONT_MESSAGE, Fonts + +PRIMARY = "primary" + +ALL_CATEGORIES = [ + Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT, + Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS, +] + +FOOTER_H = 78 + + +class App: + def __init__(self) -> None: + self.result: ScanResult | None = None + self.cancel = Cancel() + self.q: queue.Queue = queue.Queue() + self.busy = False + + self.findings: list[Finding] = [] # индекс -> находка + self.visible: list[int] = [] # индексы, показанные сейчас + self.cat_index: dict[Category, list[int]] = {} + self.programs: list[Program] = [] + + self.dark = True + self.p = DARK + self.roots: list[str] = default_project_roots() + self.fonts = Fonts(18) + + # Диски: список вида ['C:', 'D:']. Отмеченные — те, что будут сканироваться. + self.all_drives: list[str] = [d.rstrip("\\/").upper() for d in drives()] + self.rec_items: list[int] = [] # тексты рекомендаций — для переносов строк + + self._title_set = False + self.theme_dark = 0 + self.theme_light = 0 + self.th_accent = 0 + self.th_danger = 0 + self.th_card = 0 + self.th_bar: dict[str, int] = {} + + # ================================================================ запуск = + + def run(self) -> None: + dpg.create_context() + + # Шрифты — строго до setup_dearpygui(), иначе атлас глифов уже собран + # и кириллица с иконками в него не попадут. + self.fonts.build() + + self._build_themes() + self._build_ui() + + # Заголовок ставим служебный, ASCII: Dear PyGui передаёт его в ANSI-функцию + # Windows как UTF-8 байты, и русский текст превратился бы в кракозябры. + # Настоящее имя подставим через SetWindowTextW, как только окно появится. + ico = icon_path() + dpg.create_viewport(title=BOOT_TITLE, + width=1420, height=900, min_width=1100, min_height=680, + small_icon=ico, large_icon=ico) + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.set_primary_window(PRIMARY, True) + dpg.set_viewport_resize_callback(self._on_resize) + self._apply_theme() + self._on_resize() + self._title_set = False + + if not self.fonts.ok: + self._alert("Шрифт не найден", MISSING_FONT_MESSAGE) + + self._refresh_disks() + self._load_journals() + + while dpg.is_dearpygui_running(): + if not self._title_set: + self._title_set = set_window_title(self.window_title()) + self._drain() + dpg.render_dearpygui_frame() + + self.cancel.cancel() + dpg.destroy_context() + + @staticmethod + def window_title() -> str: + suffix = "" if is_admin() else " (без прав администратора)" + return f"SysDevCleaner — анализ и очистка системы{suffix}" + + def _build_themes(self) -> None: + self.theme_dark = build_theme(DARK) + self.theme_light = build_theme(LIGHT) + self.th_card = build_card_theme(self.p) + self.th_accent = build_accent_button_theme(self.p, "accent") + self.th_danger = build_accent_button_theme(self.p, "danger") + for key in ("accent", "ok", "warn", "danger"): + self.th_bar[key] = build_bar_theme(self.p, key) + + def _apply_theme(self) -> None: + dpg.bind_theme(self.theme_dark if self.dark else self.theme_light) + + def _on_resize(self, *_a) -> None: + """Подгоняем высоту прокручиваемых областей, чтобы подвал всегда был виден. + + Вычитаемое для каждой вкладки — это высота того, что стоит выше и ниже + прокручиваемой области: шапка, полоса вкладок, панель инструментов, + панель подробностей, строка действий и сам подвал. + """ + try: + h = dpg.get_viewport_client_height() + w = dpg.get_viewport_client_width() + except Exception: + return + detail_h = min(190, max(96, int(h * 0.15))) + if dpg.does_item_exist("cl.detailbox"): + dpg.configure_item("cl.detailbox", height=detail_h) + heights = { + "ov.left": h - 350, # шапка + полоса дисков + карточки + подвал + "ov.right": h - 350, + "cl.body": h - 347 - detail_h, # + инструменты, подробности, действия + "pg.body": h - 315, # + сортировка и подсказка + "set.body": h - 245, + "jr.body": h - 245, + } + for tag, value in heights.items(): + if dpg.does_item_exist(tag): + dpg.configure_item(tag, height=max(150, int(value))) + + # кнопки шапки прижимаем к правому краю + if dpg.does_item_exist("hdr.spacer"): + left = _rect_w("hdr.left", 420) + right = _rect_w("hdr.right", 430) + dpg.configure_item("hdr.spacer", width=max(16, int(w - left - right - 36))) + + # карточки делят ширину поровну + gap = 10 + card_w = max(190, int((w - 28 - gap * 3) / 4)) + for key in ("total", "safe", "dev", "free"): + tag = f"ov.card.{key}" + if dpg.does_item_exist(tag): + dpg.configure_item(tag, width=card_w) + + # переносы строк — по фактической ширине панелей + rec_wrap = max(320, int(w - _rect_w("ov.left", 650) - 90)) + for item in self.rec_items: + if dpg.does_item_exist(item): + dpg.configure_item(item, wrap=rec_wrap) + if dpg.does_item_exist("cl.detail"): + dpg.configure_item("cl.detail", wrap=max(400, w - 70)) + if dpg.does_item_exist("jr.errors"): + dpg.configure_item("jr.errors", wrap=max(400, w - 70)) + + # ============================================================= разметка == + + def _build_ui(self) -> None: + with dpg.window(tag=PRIMARY): + self._header() + dpg.add_separator() + with dpg.tab_bar(tag="tabs"): + self._tab_overview() + self._tab_clean() + self._tab_programs() + self._tab_settings() + self._tab_journal() + self._footer() + self._build_dialogs() + + def _header(self) -> None: + with dpg.group(horizontal=True): + with dpg.group(tag="hdr.left"): + t = dpg.add_text(f"{I.BROOM} SysDevCleaner") + self.fonts.apply(t, self.fonts.h1) + admin = ("права администратора получены" if is_admin() + else "запущено без прав администратора") + s = dpg.add_text(f"Полный анализ кешей, мусора и остатков разработки " + f"· {admin}", color=self.p["fg_dim"]) + self.fonts.apply(s, self.fonts.small) + dpg.add_spacer(width=40, tag="hdr.spacer") + with dpg.group(horizontal=True, tag="hdr.right"): + b = dpg.add_button(label=I.btn(I.SEARCH, "Сканировать"), tag="btn.scan", + callback=self.start_scan, width=185) + dpg.bind_item_theme(b, self.th_accent) + dpg.add_button(label=I.btn(I.STOP, "Стоп"), tag="btn.stop", + callback=self.stop, width=110, enabled=False) + dpg.add_button(label=I.btn(I.MOON, "Тема"), tag="btn.theme", + callback=self.toggle_theme, width=110) + + dpg.add_spacer(height=2) + with dpg.group(horizontal=True, tag="hdr.drives"): + dpg.add_text(f"{I.HDD} Сканировать диски:", color=self.p["fg_dim"]) + for d in self.all_drives: + u = disk_usage(d + "\\") + label = f"{d}" + (f" ({human(u['total'])})" if u["total"] else "") + dpg.add_checkbox(label=label, tag=f"drv.{d[0]}", default_value=True, + callback=self._on_drive_toggle) + dpg.add_spacer(width=8) + dpg.add_button(label="все", width=70, callback=lambda: self._set_drives(None)) + dpg.add_text("", tag="hdr.scope", color=self.p["fg_dim"]) + self._update_scope() + + # ---------------------------------------------------------- выбор дисков - + + def selected_drives(self) -> set[str]: + """Отмеченные диски. Пустое множество = ограничения нет (все диски).""" + checked = {d for d in self.all_drives if dpg.get_value(f"drv.{d[0]}")} + if not checked or len(checked) == len(self.all_drives): + return set() + return normalize_drives(checked) + + def _set_drives(self, only: str | None) -> None: + """``only=None`` — отметить все диски, иначе оставить только указанный.""" + for d in self.all_drives: + dpg.set_value(f"drv.{d[0]}", only is None or d == only) + self._update_scope() + + def _on_drive_toggle(self, *_a) -> None: + if not any(dpg.get_value(f"drv.{d[0]}") for d in self.all_drives): + # снять все нельзя — это значило бы «не сканировать ничего» + self._set_drives(None) + return + self._update_scope() + + def _update_scope(self) -> None: + sel = self.selected_drives() + if not dpg.does_item_exist("hdr.scope"): + return + dpg.set_value("hdr.scope", + "" if not sel else f" только {', '.join(sorted(sel))}") + + def scan_only(self, drive: str) -> None: + """Быстрый сценарий с вкладки «Обзор»: сканировать один диск.""" + self._set_drives(drive.rstrip("\\/").upper()) + dpg.set_value("tabs", dpg.get_item_children("tabs", 1)[1]) + self.start_scan() + + def _footer(self) -> None: + dpg.add_separator() + dpg.add_progress_bar(tag="st.pb", default_value=0.0, width=-1, height=8) + with dpg.group(horizontal=True): + dpg.add_text("Готов к сканированию", tag="st.msg", color=self.p["fg_dim"]) + dpg.add_spacer(width=20) + dpg.add_text("", tag="st.sel", color=self.p["accent_hi"]) + + # ------------------------------------------------------------- Обзор ---- + + def _tab_overview(self) -> None: + with dpg.tab(label=I.btn(I.CHART, "Обзор")): + with dpg.group(horizontal=True, tag="ov.cards"): + for key, title in (("total", "Найдено мусора"), + ("safe", "Безопасно удалить"), + ("dev", "Кеши разработки"), + ("free", "Свободно на системном диске")): + with dpg.child_window(width=310, height=104, tag=f"ov.card.{key}", + no_scrollbar=True): + v = dpg.add_text("—", tag=f"ov.{key}") + self.fonts.apply(v, self.fonts.big) + c = dpg.add_text(title, tag=f"ov.cap.{key}", + color=self.p["fg_dim"]) + self.fonts.apply(c, self.fonts.small) + dpg.bind_item_theme(f"ov.card.{key}", self.th_card) + + with dpg.group(horizontal=True): + with dpg.child_window(tag="ov.left", width=650, height=500): + h = dpg.add_text(f"{I.HDD} Диски") + self.fonts.apply(h, self.fonts.h2) + dpg.add_group(tag="ov.disks") + dpg.add_spacer(height=12) + h2 = dpg.add_text(f"{I.LIST} По категориям") + self.fonts.apply(h2, self.fonts.h2) + dpg.add_group(tag="ov.cats") + + with dpg.child_window(tag="ov.right", width=-1, height=500): + with dpg.group(horizontal=True): + h = dpg.add_text(f"{I.LIGHTBULB} Рекомендации") + self.fonts.apply(h, self.fonts.h2) + dpg.add_spacer(width=14) + dpg.add_button(label=I.btn(I.SAVE, "Сохранить отчёт"), + callback=lambda: dpg.show_item("dlg.report")) + dpg.add_separator() + dpg.add_group(tag="ov.recs") + self.rec_items.append(dpg.add_text( + "Нажмите «Сканировать», чтобы проанализировать систему.\n\n" + "Что будет проверено:\n" + " · временные файлы, дампы, отчёты об ошибках Windows;\n" + " · кеши всех установленных браузеров;\n" + " · кеши npm, pip, Gradle, Maven, Cargo, NuGet, Go и других;\n" + " · node_modules, target, build, __pycache__ внутри проектов;\n" + " · кеши IDE, игровых лаунчеров и мессенджеров;\n" + " · установленные программы с реальным размером на диске.", + parent="ov.recs", color=self.p["fg_dim"], wrap=640)) + + # ------------------------------------------------------------ Очистка --- + + def _tab_clean(self) -> None: + with dpg.tab(label=I.btn(I.TRASH, "Очистка")): + with dpg.group(horizontal=True): + dpg.add_button(label=I.btn(I.SHIELD, "Безопасное"), tag="cl.b1", + callback=lambda: self._bulk("safe"), width=190) + dpg.add_button(label=I.btn(I.CHECK, "+ низкий риск"), tag="cl.b2", + callback=lambda: self._bulk("low"), width=210) + dpg.add_button(label=I.btn(I.CLOSE, "Снять всё"), tag="cl.b3", + callback=lambda: self._bulk("none"), width=160) + dpg.add_spacer(width=6) + dpg.add_text(I.FILTER) + dpg.add_input_text(tag="cl.filter", hint="название или путь", + width=220, callback=lambda *_: self._render_findings()) + dpg.add_checkbox(label="скрыть < 10 МБ", tag="cl.small", + callback=lambda *_: self._render_findings()) + + dpg.add_child_window(tag="cl.body", height=420, width=-1) + + with dpg.child_window(tag="cl.detailbox", height=118, width=-1): + dpg.add_text("Выберите строку, чтобы увидеть подробности.", + tag="cl.detail", color=self.p["fg_dim"], wrap=1280) + + with dpg.group(horizontal=True): + dpg.add_checkbox(label="удалять в Корзину (можно восстановить)", + tag="cl.recycle", default_value=True) + dpg.add_checkbox(label="пробный запуск — ничего не удалять", + tag="cl.dry", default_value=False) + dpg.add_spacer(width=20) + b = dpg.add_button(label=I.btn(I.TRASH, "Очистить выбранное"), + tag="btn.clean", callback=self.ask_clean, + width=280, enabled=False) + dpg.bind_item_theme(b, self.th_danger) + + # ----------------------------------------------------------- Программы -- + + def _tab_programs(self) -> None: + with dpg.tab(label=I.btn(I.FOLDER, "Программы")): + with dpg.group(horizontal=True): + dpg.add_text("Сортировать:", color=self.p["fg_dim"]) + dpg.add_combo(("по размеру", "по простою", "по имени", "по издателю"), + default_value="по размеру", width=190, tag="pg.sort", + callback=lambda *_: self._render_programs()) + dpg.add_input_text(tag="pg.filter", hint="поиск по названию", width=250, + callback=lambda *_: self._render_programs()) + dpg.add_spacer(width=10) + dpg.add_button(label=I.btn(I.TRASH, "Удалить программу"), + callback=self.ask_uninstall, width=250) + dpg.add_button(label=I.btn(I.GEAR, "Приложения Windows"), + callback=self._open_appwiz, width=240) + dpg.add_text("Двойной клик по строке открывает папку установки. " + "«Простой» — сколько дней не менялись файлы программы.", + color=self.p["fg_dim"]) + dpg.add_child_window(tag="pg.body", height=520, width=-1) + + # ----------------------------------------------------------- Настройки -- + + def _tab_settings(self) -> None: + with dpg.tab(label=I.btn(I.GEAR, "Настройки")): + with dpg.child_window(tag="set.body", height=560, width=-1): + with dpg.group(horizontal=True): + with dpg.group(): + h = dpg.add_text(f"{I.LIST} Что искать") + self.fonts.apply(h, self.fonts.h2) + for cat in ALL_CATEGORIES: + dpg.add_checkbox(label=f"{I.cat(cat)}{cat.label}", + tag=f"set.cat.{int(cat)}", default_value=True) + dpg.add_spacer(height=14) + + h = dpg.add_text(f"{I.SEARCH} Дополнительный анализ") + self.fonts.apply(h, self.fonts.h2) + with dpg.group(horizontal=True): + dpg.add_checkbox(label="крупные файлы, больше", tag="set.big") + dpg.add_input_int(tag="set.bigmb", default_value=200, width=110, + min_value=1, min_clamped=True, step=50) + dpg.add_text("МБ", color=self.p["fg_dim"]) + with dpg.group(horizontal=True): + dpg.add_checkbox(label="дубликаты, больше", tag="set.dup") + dpg.add_input_int(tag="set.dupmb", default_value=10, width=110, + min_value=1, min_clamped=True, step=5) + dpg.add_text("МБ (заметно дольше)", color=self.p["fg_dim"]) + dpg.add_checkbox(label="собирать список установленных программ", + tag="set.progs", default_value=True) + dpg.add_checkbox(label="измерять реальный размер программ (дольше)", + tag="set.progsize", default_value=True) + + dpg.add_spacer(width=40) + with dpg.group(): + h = dpg.add_text(f"{I.FOLDER} Где искать проекты и крупные файлы") + self.fonts.apply(h, self.fonts.h2) + dpg.add_text("По умолчанию — папки разработки в профиле " + "и несистемные диски.", color=self.p["fg_dim"]) + dpg.add_listbox(self.roots, tag="set.roots", width=620, num_items=12) + with dpg.group(horizontal=True): + dpg.add_button(label=I.btn(I.PLUS, "Добавить папку"), + callback=lambda: dpg.show_item("dlg.root"), + width=220) + dpg.add_button(label=I.btn(I.MINUS, "Убрать"), + callback=self._del_root, width=150) + dpg.add_button(label=I.btn(I.UNDO, "По умолчанию"), + callback=self._reset_roots, width=210) + + # ------------------------------------------------------------- Журнал --- + + def _tab_journal(self) -> None: + with dpg.tab(label=I.btn(I.HISTORY, "Журнал")): + with dpg.child_window(tag="jr.body", height=560, width=-1): + h = dpg.add_text(f"{I.HISTORY} История очисток") + self.fonts.apply(h, self.fonts.h2) + dpg.add_text("Каждая очистка пишется в файл — видно, что и когда удалено.", + color=self.p["fg_dim"]) + dpg.add_group(tag="jr.list") + dpg.add_spacer(height=16) + h = dpg.add_text(f"{I.WARNING} Ошибки доступа при последнем сканировании") + self.fonts.apply(h, self.fonts.h2) + dpg.add_text("—", tag="jr.errors", color=self.p["fg_dim"], wrap=1280) + + # ------------------------------------------------------------ диалоги --- + + def _build_dialogs(self) -> None: + with dpg.file_dialog(directory_selector=True, show=False, tag="dlg.root", + callback=self._add_root, width=760, height=480, + label="Папка для поиска проектов"): + pass + with dpg.file_dialog(directory_selector=False, show=False, tag="dlg.report", + callback=self._save_report, width=760, height=480, + default_filename=time.strftime("sysdevcleaner-%Y%m%d-%H%M"), + label="Сохранить отчёт"): + dpg.add_file_extension(".txt", color=(120, 200, 255, 255)) + dpg.add_file_extension(".*") + + def _alert(self, title: str, message: str) -> None: + tag = "modal.alert" + if dpg.does_item_exist(tag): + dpg.delete_item(tag) + with dpg.window(label=title, modal=True, tag=tag, width=740, height=440, + pos=(340, 200), no_resize=False): + with dpg.child_window(height=-46, width=-1): + dpg.add_text(message, wrap=680) + dpg.add_button(label="Закрыть", width=150, + callback=lambda: dpg.delete_item(tag)) + + def _confirm(self, title: str, message: str, on_yes, yes_label: str = "Да", + danger: bool = False) -> None: + tag = "modal.confirm" + if dpg.does_item_exist(tag): + dpg.delete_item(tag) + + def go(): + dpg.delete_item(tag) + on_yes() + + with dpg.window(label=title, modal=True, tag=tag, width=820, height=560, + pos=(300, 150)): + with dpg.child_window(height=-46, width=-1): + dpg.add_text(message, wrap=760) + with dpg.group(horizontal=True): + b = dpg.add_button(label=yes_label, width=230, callback=go) + dpg.bind_item_theme(b, self.th_danger if danger else self.th_accent) + dpg.add_button(label="Отмена", width=160, + callback=lambda: dpg.delete_item(tag)) + + # =========================================================== сканирование + + def start_scan(self) -> None: + if self.busy: + return + cats = {c for c in ALL_CATEGORIES if dpg.get_value(f"set.cat.{int(c)}")} + if not cats: + self._alert("Нечего искать", + "Выберите хотя бы одну категорию на вкладке «Настройки».") + return + + opts = ScanOptions( + categories=cats, + project_roots=list(self.roots), + scan_big_files=dpg.get_value("set.big"), + big_file_min=max(1, dpg.get_value("set.bigmb")) * 1024 * 1024, + scan_duplicates=dpg.get_value("set.dup"), + duplicate_min=max(1, dpg.get_value("set.dupmb")) * 1024 * 1024, + scan_programs=dpg.get_value("set.progs"), + measure_program_size=dpg.get_value("set.progsize"), + drives=self.selected_drives(), + ) + + self.busy = True + self.cancel.reset() + dpg.configure_item("btn.scan", enabled=False) + dpg.configure_item("btn.stop", enabled=True) + dpg.configure_item("btn.clean", enabled=False) + dpg.delete_item("cl.body", children_only=True) + self.findings.clear() + dpg.set_value("st.msg", "Сканирование…") + dpg.set_value("st.pb", 0.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}")) + + threading.Thread(target=work, daemon=True).start() + + def stop(self) -> None: + self.cancel.cancel() + dpg.set_value("st.msg", "Останавливаю…") + + def _drain(self) -> None: + """Забираем сообщения рабочих потоков. Вызывается каждый кадр.""" + try: + while True: + kind, payload = self.q.get_nowait() + if kind == "progress": + msg, frac = payload + dpg.set_value("st.msg", msg) + dpg.set_value("st.pb", frac) + elif kind == "clean_progress": + msg, frac = payload + dpg.set_value("st.msg", f"Очистка: {msg}") + dpg.set_value("st.pb", frac) + elif kind == "scan_done": + self._scan_done(payload) + elif kind == "clean_done": + self._clean_done(payload) + elif kind == "error": + self._finish_busy() + dpg.set_value("st.msg", "Ошибка") + self._alert("Ошибка", str(payload)) + except queue.Empty: + pass + + def _finish_busy(self) -> None: + self.busy = False + dpg.configure_item("btn.scan", enabled=True) + dpg.configure_item("btn.stop", enabled=False) + dpg.configure_item("btn.clean", enabled=bool(self.findings)) + + def _scan_done(self, res: ScanResult) -> None: + self.result = res + self.findings = list(res.findings) + self.programs = list(res.programs) + default_selection(self.findings) + self._finish_busy() + dpg.set_value("st.pb", 1.0) + + self._render_findings() + self._render_overview() + self._render_programs() + self._render_errors() + self._refresh_disks() + self._update_sel() + tail = " · прервано" if self.cancel.cancelled else "" + dpg.set_value("st.msg", f"Найдено {human(res.total())} в " + f"{len(res.findings)} местах · {res.duration:.0f} с{tail}") + + # ========================================================= список находок + + def _render_findings(self) -> None: + dpg.delete_item("cl.body", children_only=True) + self.cat_index.clear() + if not self.findings: + dpg.add_text("Пока ничего не найдено — запустите сканирование.", + parent="cl.body", color=self.p["fg_dim"]) + return + + needle = (dpg.get_value("cl.filter") or "").strip().lower() + floor = 10 * 1024 * 1024 if dpg.get_value("cl.small") else 0 + + buckets: dict[Category, list[int]] = {} + for idx, f in enumerate(self.findings): + if f.size < floor: + continue + if needle and needle not in f.title.lower() and needle not in f.path.lower(): + continue + buckets.setdefault(f.category, []).append(idx) + + if not buckets: + dpg.add_text("Под фильтр ничего не подходит.", parent="cl.body", + color=self.p["fg_dim"]) + return + + order = sorted(buckets.items(), + key=lambda kv: -sum(self.findings[i].size for i in kv[1])) + for cat, idxs in order: + idxs.sort(key=lambda i: -self.findings[i].size) + self.cat_index[cat] = idxs + total = sum(self.findings[i].size for i in idxs) + label = f"{I.cat(cat)}{cat.label} — {human(total)} ({len(idxs)})" + opened = cat in (Category.DEV_PROJECT, Category.DEV_CACHE, Category.WINDOWS) + with dpg.collapsing_header(label=label, parent="cl.body", + default_open=opened): + dpg.add_checkbox(label="отметить всю категорию", + tag=f"cat.chk.{int(cat)}", + default_value=all(self.findings[i].selected for i in idxs), + callback=self._on_cat_check, user_data=cat) + with dpg.table(header_row=True, resizable=True, policy=dpg.mvTable_SizingStretchProp, + borders_innerH=False, borders_outerH=False, + borders_innerV=True, borders_outerV=False, + row_background=True, scrollY=False): + dpg.add_table_column(label="", width_fixed=True, init_width_or_weight=34) + dpg.add_table_column(label="Объект", init_width_or_weight=0.34) + dpg.add_table_column(label="Размер", width_fixed=True, + init_width_or_weight=110) + dpg.add_table_column(label="Риск", width_fixed=True, + init_width_or_weight=140) + dpg.add_table_column(label="Изменён", width_fixed=True, + init_width_or_weight=100) + dpg.add_table_column(label="Путь", init_width_or_weight=0.46) + + for i in idxs: + f = self.findings[i] + with dpg.table_row(): + dpg.add_checkbox(tag=f"chk.{i}", default_value=f.selected, + callback=self._on_check, user_data=i) + dpg.add_selectable(label=_clip(f.title, 46), + callback=self._on_row, user_data=i, + tag=f"row.{i}") + dpg.add_text(human(f.size)) + dpg.add_text(f.risk.label, color=risk_color(f.risk, self.p)) + dpg.add_text(_age(f)) + dpg.add_text(_clip(f.path, 70)) + with dpg.tooltip(f"row.{i}"): + dpg.add_text(f.path, wrap=700) + self._update_sel() + + def _on_check(self, sender, value, idx: int) -> None: + self.findings[idx].selected = bool(value) + self._sync_cat(self.findings[idx].category) + self._update_sel() + + def _on_cat_check(self, sender, value, cat: Category) -> None: + for i in self.cat_index.get(cat, []): + self.findings[i].selected = bool(value) + if dpg.does_item_exist(f"chk.{i}"): + dpg.set_value(f"chk.{i}", bool(value)) + self._update_sel() + + def _sync_cat(self, cat: Category) -> None: + tag = f"cat.chk.{int(cat)}" + idxs = self.cat_index.get(cat, []) + if idxs and dpg.does_item_exist(tag): + dpg.set_value(tag, all(self.findings[i].selected for i in idxs)) + + def _on_row(self, sender, value, idx: int) -> None: + # add_selectable остаётся «нажатым» — снимаем, нам нужен только клик + dpg.set_value(sender, False) + f = self.findings[idx] + mode = ("удаляется содержимое каталога" if f.delete_contents_only + else "удаляется целиком") + if f.members: + mode = f"удаляются лишние копии ({len(f.members)} шт.), оригинал остаётся" + busy = f.extra.get("busy") or [] + warn = (f"\n\n{I.WARNING} Сейчас запущено: {', '.join(busy)} — " + "часть файлов удалить не получится." if busy else "") + dpg.set_value("cl.detail", + f"{f.title}\n{f.path}\n\n" + f"{human(f.size)} · {f.files} файлов · {f.risk.label} · {mode}\n\n" + f"{f.detail}{warn}") + + def _bulk(self, mode: str) -> None: + for i, f in enumerate(self.findings): + 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)) + if dpg.does_item_exist(f"chk.{i}"): + dpg.set_value(f"chk.{i}", f.selected) + for cat in self.cat_index: + self._sync_cat(cat) + self._update_sel() + + def _update_sel(self) -> None: + sel = [f for f in self.findings if f.selected] + if not sel: + dpg.set_value("st.sel", "") + return + risky = sum(1 for f in sel if f.risk >= Risk.MEDIUM) + txt = f"{I.CHECK} выбрано {len(sel)} · {human(sum(f.size for f in sel))}" + if risky: + txt += f" · из них {risky} с повышенным риском" + dpg.set_value("st.sel", txt) + + # ================================================================ обзор == + + def _refresh_disks(self) -> None: + dpg.delete_item("ov.disks", children_only=True) + disks = self.result.disks if self.result else [disk_usage(d) for d in drives()] + for d in disks: + if not d["total"]: + continue + pct = d["percent"] / 100.0 + key = "danger" if pct > 0.9 else ("warn" if pct > 0.8 else "accent") + letter = d["root"].rstrip("\\/").upper() + with dpg.group(parent="ov.disks", horizontal=True): + dpg.add_text(f"{I.HDD} {d['root']}", color=self.p["fg_dim"]) + bar = dpg.add_progress_bar( + default_value=pct, width=200, height=16, + overlay=f"{d['percent']:.0f}%") + dpg.bind_item_theme(bar, self.th_bar[key]) + dpg.add_text(f"{human(d['free'])} своб.", color=self.p["fg_dim"]) + dpg.add_button(label=I.btn(I.SEARCH, "только этот"), width=165, + user_data=letter, + callback=lambda s, a, u: self.scan_only(u)) + if self.result is None and disks: + dpg.set_value("ov.free", human(disks[0]["free"])) + + def _render_overview(self) -> None: + res = self.result + if res is None: + return + safe = sum(f.size for f in res.findings if f.risk == Risk.SAFE) + dev = res.total({Category.DEV_CACHE, Category.DEV_PROJECT}) + dpg.set_value("ov.total", human(res.total())) + dpg.set_value("ov.safe", human(safe)) + dpg.set_value("ov.dev", human(dev)) + # если системный диск не сканировался — показываем первый из выбранных + sysroot = next((d for d in res.disks if d["root"].upper().startswith("C")), None) + shown = sysroot or (res.disks[0] if res.disks else None) + dpg.set_value("ov.free", human(shown["free"]) if shown else "—") + dpg.set_value("ov.cap.free", + "Свободно на системном диске" if sysroot + else (f"Свободно на {shown['root']}" if shown else "Свободно")) + + dpg.delete_item("ov.cats", children_only=True) + 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) + with dpg.group(parent="ov.cats", horizontal=True): + dpg.add_text(f"{I.cat(cat)}{cat.label[:20]:<20}", color=self.p["fg_dim"]) + bar = dpg.add_progress_bar(default_value=total / top, width=180, height=14, + overlay=human(total)) + dpg.bind_item_theme(bar, self.th_bar["accent"]) + dpg.add_text(f"({len(items)})", color=self.p["fg_dim"]) + + dpg.delete_item("ov.recs", children_only=True) + self.rec_items.clear() + if not res.recommendations: + self.rec_items.append(dpg.add_text( + "Система выглядит чистой — существенного мусора не найдено.", + parent="ov.recs", color=self.p["fg_dim"], wrap=640)) + for i, r in enumerate(res.recommendations, 1): + icon = {0: I.INFO, 1: I.LIGHTBULB, 2: I.BOLT}[r.severity] + color = {0: self.p["fg_dim"], 1: self.p["fg"], 2: self.p["warn"]}[r.severity] + t = dpg.add_text(f"{icon} {i}. {r.title}", parent="ov.recs", + color=color, wrap=640) + self.fonts.apply(t, self.fonts.h2) + self.rec_items.append(t) + if r.gain: + dpg.add_text(f" освободится {human(r.gain)}", parent="ov.recs", + color=self.p["ok"]) + self.rec_items.append(dpg.add_text( + r.body, parent="ov.recs", color=self.p["fg_dim"], wrap=630)) + dpg.add_separator(parent="ov.recs") + self._on_resize() + + def _render_errors(self) -> None: + if self.result is None: + return + errs = self.result.errors + dpg.set_value("jr.errors", + "\n".join(errs[:200]) if errs else "Ошибок не было.") + + # ============================================================= программы = + + def _render_programs(self) -> None: + dpg.delete_item("pg.body", children_only=True) + if not self.programs: + dpg.add_text("Список программ не собран.", parent="pg.body", + color=self.p["fg_dim"]) + return + needle = (dpg.get_value("pg.filter") or "").strip().lower() + items = [p for p in self.programs + if not needle or needle in p.name.lower() + or needle in (p.publisher or "").lower()] + mode = dpg.get_value("pg.sort") + if mode == "по размеру": + items.sort(key=lambda p: -p.best_size) + elif mode == "по простою": + items.sort(key=lambda p: -p.idle_days) + elif mode == "по имени": + items.sort(key=lambda p: p.name.lower()) + else: + items.sort(key=lambda p: ((p.publisher or "я").lower(), -p.best_size)) + + with dpg.table(parent="pg.body", header_row=True, resizable=True, + policy=dpg.mvTable_SizingStretchProp, row_background=True, + borders_innerV=True, scrollY=False): + dpg.add_table_column(label="Программа", init_width_or_weight=0.34) + dpg.add_table_column(label="Размер", width_fixed=True, init_width_or_weight=110) + dpg.add_table_column(label="Простой", width_fixed=True, init_width_or_weight=110) + dpg.add_table_column(label="Версия", width_fixed=True, init_width_or_weight=140) + dpg.add_table_column(label="Издатель", init_width_or_weight=0.22) + dpg.add_table_column(label="Расположение", init_width_or_weight=0.4) + for n, p in enumerate(items): + idle = f"{int(p.idle_days)} дн." if p.idle_days >= 0 else "—" + color = self.p["fg"] + if p.best_size > 2 * 1024 ** 3: + color = self.p["warn"] + if p.idle_days > 365 and p.best_size > 500 * 1024 ** 2: + color = self.p["danger"] + with dpg.table_row(): + dpg.add_selectable(label=_clip(p.name, 42), tag=f"prog.{n}", + callback=self._on_prog, user_data=p) + dpg.add_text(human(p.best_size), color=color) + dpg.add_text(idle) + dpg.add_text(_clip(p.version, 16)) + dpg.add_text(_clip(p.publisher, 26)) + dpg.add_text(_clip(p.install_location, 46)) + self._selected_program = None + + def _on_prog(self, sender, value, prog: Program) -> None: + dpg.set_value(sender, False) + self._selected_program = prog + dpg.set_value("st.msg", f"{prog.name} — {human(prog.best_size)} · " + f"{prog.install_location or 'путь не указан в реестре'}") + if prog.install_location: + open_in_explorer(prog.install_location) + + def ask_uninstall(self) -> None: + prog = getattr(self, "_selected_program", None) + if prog is None: + self._alert("Программа не выбрана", + "Кликните по строке в списке, затем нажмите «Удалить программу».") + return + + def go(): + ok, err = progmod.uninstall(prog) + if not ok: + self._alert("Не получилось", f"Не удалось запустить удаление: {err}") + + self._confirm("Удаление программы", + f"Запустить штатный деинсталлятор?\n\n{prog.name}\n" + f"{human(prog.best_size)}\n{prog.install_location}\n\n" + "Откроется окно самой программы — дальнейшие шаги за вами.", + go, yes_label=I.btn(I.TRASH, "Запустить удаление"), danger=True) + + # =============================================================== очистка = + + def ask_clean(self) -> None: + if self.busy: + return + sel = [f for f in self.findings if f.selected] + if not sel: + self._alert("Ничего не выбрано", + "Сначала отметьте флажками то, что нужно удалить.") + return + dry = dpg.get_value("cl.dry") + recycle = dpg.get_value("cl.recycle") + mode = ("ПРОБНЫЙ ЗАПУСК — ничего не будет удалено." if dry + else ("Файлы отправятся в Корзину — их можно вернуть." if recycle + else "ВНИМАНИЕ: удаление БЕЗВОЗВРАТНОЕ, мимо Корзины.")) + self._confirm("Подтверждение очистки", + plan_summary(sel) + "\n\n" + mode, + lambda: self._start_clean(sel, recycle, dry), + yes_label=I.btn(I.TRASH, "Очистить"), danger=not dry) + + def _start_clean(self, sel: list[Finding], recycle: bool, dry: bool) -> None: + opts = cleanmod.CleanOptions(to_recycle_bin=recycle, dry_run=dry) + self.busy = True + self.cancel.reset() + dpg.configure_item("btn.clean", enabled=False) + dpg.configure_item("btn.scan", enabled=False) + dpg.configure_item("btn.stop", enabled=True) + + 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._finish_busy() + dpg.set_value("st.pb", 1.0) + dpg.set_value("st.msg", 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[:14]: + lines.append(f" · {i.finding.title} — " + f"{i.message or 'файлы заняты другой программой'}") + if len(fails) > 14: + lines.append(f" … и ещё {len(fails) - 14}") + lines.append("") + lines.append("Обычная причина — файлы используются запущенной программой " + "или нужны права администратора.") + if res.journal_path: + lines.append("") + lines.append(f"Журнал: {res.journal_path}") + + # убираем из списка то, что удалилось целиком + done = {id(i.finding) for i in res.items + if i.ok and not i.finding.delete_contents_only} + if done: + self.findings = [f for f in self.findings if id(f) not in done] + self._render_findings() + self._update_sel() + self._alert("Очистка завершена", "\n".join(lines)) + + # ============================================================== прочее === + + def _save_report(self, sender, app_data) -> None: + if self.result is None: + self._alert("Нет данных", "Сначала выполните сканирование.") + return + path = app_data.get("file_path_name") or "" + if not path: + return + if not os.path.splitext(path)[1]: + path += ".txt" + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(report_text(self.result)) + dpg.set_value("st.msg", f"Отчёт сохранён: {path}") + open_in_explorer(path) + except OSError as e: + self._alert("Не сохранилось", f"Не удалось записать файл: {e}") + + def _add_root(self, sender, app_data) -> None: + path = app_data.get("file_path_name") or app_data.get("current_path") or "" + if not path: + return + path = os.path.normpath(path) + if os.path.isdir(path) and path not in self.roots: + self.roots.append(path) + dpg.configure_item("set.roots", items=self.roots) + + def _del_root(self) -> None: + cur = dpg.get_value("set.roots") + if cur and cur in self.roots: + self.roots.remove(cur) + dpg.configure_item("set.roots", items=self.roots) + + def _reset_roots(self) -> None: + self.roots = default_project_roots() + dpg.configure_item("set.roots", items=self.roots) + + def _load_journals(self) -> None: + dpg.delete_item("jr.list", children_only=True) + rows = cleanmod.list_journals() + if not rows: + dpg.add_text("Очисток пока не было.", parent="jr.list", + color=self.p["fg_dim"]) + return + with dpg.table(parent="jr.list", header_row=True, row_background=True, + policy=dpg.mvTable_SizingStretchProp, borders_innerV=True): + dpg.add_table_column(label="Дата", width_fixed=True, init_width_or_weight=170) + dpg.add_table_column(label="Освобождено", width_fixed=True, + init_width_or_weight=140) + dpg.add_table_column(label="Файл журнала") + for n, (path, when, freed) in enumerate(rows): + with dpg.table_row(): + dpg.add_selectable(label=when, callback=self._open_journal, + user_data=path) + dpg.add_text(human(freed), color=self.p["ok"]) + dpg.add_text(path) + + def _open_journal(self, sender, value, path: str) -> None: + dpg.set_value(sender, False) + open_in_explorer(path) + + def _open_appwiz(self) -> None: + try: + if sys.platform == "win32": + subprocess.Popen(["cmd", "/c", "start", "ms-settings:appsfeatures"]) + except Exception: + pass + + def toggle_theme(self) -> None: + self.dark = not self.dark + self.p = DARK if self.dark else LIGHT + self._apply_theme() + dpg.configure_item("btn.theme", + label=I.btn(I.MOON if self.dark else I.SUN, "Тема")) + # цвета текста заданы поштучно — перерисовываем то, что от них зависит + self.th_card = build_card_theme(self.p) + self.th_accent = build_accent_button_theme(self.p, "accent") + self.th_danger = build_accent_button_theme(self.p, "danger") + for key in ("accent", "ok", "warn", "danger"): + self.th_bar[key] = build_bar_theme(self.p, key) + for key in ("total", "safe", "dev", "free"): + if dpg.does_item_exist(f"ov.card.{key}"): + dpg.bind_item_theme(f"ov.card.{key}", self.th_card) + dpg.bind_item_theme("btn.scan", self.th_accent) + dpg.bind_item_theme("btn.clean", self.th_danger) + self._refresh_disks() + if self.result is not None: + self._render_overview() + self._render_findings() + self._render_programs() + self._load_journals() + + +def _rect_w(tag: str, fallback: int) -> int: + """Фактическая ширина элемента; до первой отрисовки вернёт запасное значение.""" + try: + size = dpg.get_item_rect_size(tag) + return int(size[0]) if size and size[0] > 1 else fallback + except Exception: + return fallback + + +def _clip(text: str, width: int) -> str: + text = text or "" + return text if len(text) <= width else "…" + text[-(width - 1):] + + +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().run() + + +if __name__ == "__main__": + main() diff --git a/sysdevcleaner/gui/app_tk.py b/sysdevcleaner/gui/app_tk.py new file mode 100644 index 0000000..92fc106 --- /dev/null +++ b/sysdevcleaner/gui/app_tk.py @@ -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("", self._on_tree_click) + self.tree.bind("", self._on_space) + self.tree.bind("<>", self._on_tree_select) + self.tree.bind("", self._on_tree_double) + self.tree.bind("", 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("", 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("", 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("", 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("", 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() diff --git a/sysdevcleaner/gui/dpg_theme.py b/sysdevcleaner/gui/dpg_theme.py new file mode 100644 index 0000000..4fedd9f --- /dev/null +++ b/sysdevcleaner/gui/dpg_theme.py @@ -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 diff --git a/sysdevcleaner/gui/fonts.py b/sysdevcleaner/gui/fonts.py new file mode 100644 index 0000000..e678534 --- /dev/null +++ b/sysdevcleaner/gui/fonts.py @@ -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 рядом с приложением, " + "иначе русский текст будет отображаться квадратами." +) diff --git a/sysdevcleaner/gui/icons.py b/sysdevcleaner/gui/icons.py new file mode 100644 index 0000000..78bb29e --- /dev/null +++ b/sysdevcleaner/gui/icons.py @@ -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}" diff --git a/sysdevcleaner/gui/theme_tk.py b/sysdevcleaner/gui/theme_tk.py new file mode 100644 index 0000000..dda1a15 --- /dev/null +++ b/sysdevcleaner/gui/theme_tk.py @@ -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"]}, + } diff --git a/sysdevcleaner/winui.py b/sysdevcleaner/winui.py new file mode 100644 index 0000000..b42012e --- /dev/null +++ b/sysdevcleaner/winui.py @@ -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()