Files
SysDevCleaner/sysdevcleaner/gui/app_tk.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

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