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