Files
SysDevCleaner/sysdevcleaner/gui/app.py
HeagBoKaT b01cf37e66 Первый выпуск SysDevCleaner
Анализ Windows и очистка кешей, мусора и остатков разработки.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:55:00 +03:00

1055 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()