Files
SysDevCleaner/sysdevcleaner/core/util.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

397 lines
12 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.
"""Низкоуровневые утилиты: размеры, пути, защита, корзина."""
from __future__ import annotations
import ctypes
import os
import shutil
import stat
import string
import subprocess
import sys
import threading
from typing import Callable, Iterable
IS_WIN = sys.platform == "win32"
# ---------------------------------------------------------------- размеры ----
_UNITS = ("Б", "КБ", "МБ", "ГБ", "ТБ", "ПБ")
def human(size: float) -> str:
"""1536 -> '1.5 КБ'."""
size = float(size or 0)
neg = size < 0
size = abs(size)
i = 0
while size >= 1024 and i < len(_UNITS) - 1:
size /= 1024.0
i += 1
txt = f"{size:.0f} {_UNITS[i]}" if (i == 0 or size >= 100) else f"{size:.1f} {_UNITS[i]}"
return ("-" if neg else "") + txt
class Cancelled(Exception):
pass
class Cancel:
"""Кооперативная отмена длительных обходов."""
def __init__(self) -> None:
self._e = threading.Event()
def cancel(self) -> None:
self._e.set()
def reset(self) -> None:
self._e.clear()
@property
def cancelled(self) -> bool:
return self._e.is_set()
def check(self) -> None:
if self._e.is_set():
raise Cancelled()
def dir_stats(
path: str,
cancel: "Cancel | None" = None,
max_entries: int = 400_000,
follow_links: bool = False,
) -> tuple[int, int, int, float]:
"""Размер каталога: (байты, файлов, папок, max_mtime). Устойчиво к ошибкам."""
total = files = dirs = 0
newest = 0.0
seen = 0
stack = [path]
while stack:
if cancel is not None and cancel.cancelled:
break
cur = stack.pop()
try:
it = os.scandir(cur)
except (OSError, ValueError):
continue
with it:
while True:
try:
entry = next(it)
except StopIteration:
break
except OSError:
break
seen += 1
if seen > max_entries:
stack.clear()
break
try:
if entry.is_dir(follow_symlinks=follow_links):
dirs += 1
stack.append(entry.path)
elif entry.is_file(follow_symlinks=follow_links):
st = entry.stat(follow_symlinks=False)
files += 1
total += st.st_size
if st.st_mtime > newest:
newest = st.st_mtime
except OSError:
continue
if not newest:
try:
newest = os.stat(path).st_mtime
except OSError:
newest = 0.0
return total, files, dirs, newest
def path_size(path: str, cancel: "Cancel | None" = None) -> tuple[int, int, int, float]:
"""Размер файла или каталога в том же формате, что и dir_stats."""
try:
st = os.stat(path)
except OSError:
return 0, 0, 0, 0.0
if stat.S_ISDIR(st.st_mode):
return dir_stats(path, cancel)
return st.st_size, 1, 0, st.st_mtime
# ------------------------------------------------------------------ пути ----
def env(name: str) -> str:
return os.environ.get(name, "") or ""
def expand(path: str) -> str:
return os.path.expandvars(os.path.expanduser(path))
def known_paths() -> dict[str, str]:
home = os.path.expanduser("~")
local = env("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
roaming = env("APPDATA") or os.path.join(home, "AppData", "Roaming")
return {
"HOME": home,
"LOCAL": local,
"LOCALLOW": os.path.join(os.path.dirname(local), "LocalLow") if local else "",
"ROAMING": roaming,
"TEMP": env("TEMP") or os.path.join(local, "Temp"),
"WINDIR": env("WINDIR") or "C:\\Windows",
"SYSTEMDRIVE": (env("SYSTEMDRIVE") or "C:") + "\\",
"PROGRAMDATA": env("PROGRAMDATA") or "C:\\ProgramData",
"PROGRAMFILES": env("PROGRAMFILES") or "C:\\Program Files",
"PROGRAMFILES86": env("ProgramFiles(x86)") or "C:\\Program Files (x86)",
"PUBLIC": env("PUBLIC") or "C:\\Users\\Public",
}
def drives() -> list[str]:
"""Список смонтированных дисков ('C:\\', 'D:\\')."""
if not IS_WIN:
return ["/"]
out = []
try:
mask = ctypes.windll.kernel32.GetLogicalDrives()
except Exception:
mask = 0
for i, letter in enumerate(string.ascii_uppercase):
if mask >> i & 1:
root = f"{letter}:\\"
try:
dtype = ctypes.windll.kernel32.GetDriveTypeW(ctypes.c_wchar_p(root))
except Exception:
dtype = 3
if dtype in (2, 3, 4): # removable / fixed / network
out.append(root)
return out or ["C:\\"]
def disk_usage(root: str) -> dict:
try:
u = shutil.disk_usage(root)
return {
"root": root,
"total": u.total,
"used": u.used,
"free": u.free,
"percent": (u.used / u.total * 100) if u.total else 0.0,
}
except OSError:
return {"root": root, "total": 0, "used": 0, "free": 0, "percent": 0.0}
# --------------------------------------------------------------- защита -----
def _norm(p: str) -> str:
return os.path.normcase(os.path.abspath(p)).rstrip("\\/")
def _protected_roots() -> set[str]:
k = known_paths()
raw = [
k["WINDIR"],
os.path.join(k["WINDIR"], "System32"),
os.path.join(k["WINDIR"], "SysWOW64"),
k["PROGRAMFILES"],
k["PROGRAMFILES86"],
k["SYSTEMDRIVE"],
k["HOME"],
k["LOCAL"],
k["ROAMING"],
k["PROGRAMDATA"],
os.path.join(k["HOME"], "Documents"),
os.path.join(k["HOME"], "Desktop"),
os.path.join(k["HOME"], "Downloads"),
os.path.join(k["HOME"], "Pictures"),
os.path.join(k["HOME"], "Videos"),
os.path.join(k["HOME"], "Music"),
os.path.join(k["HOME"], "OneDrive"),
]
out = {_norm(p) for p in raw if p}
out |= {_norm(d) for d in drives()}
return out
PROTECTED = _protected_roots()
def is_protected(path: str) -> bool:
"""Нельзя удалять сам путь (но можно то, что глубже внутри)."""
n = _norm(path)
if not n or len(n) <= 3:
return True
return n in PROTECTED
def is_safe_target(path: str) -> tuple[bool, str]:
"""Финальная проверка перед удалением."""
if not path:
return False, "пустой путь"
n = _norm(path)
if len(n) <= 3:
return False, "корень диска"
if is_protected(path):
return False, "системный/пользовательский корневой каталог"
depth = n.replace("/", "\\").count("\\")
if depth < 2:
return False, "слишком высокий уровень в дереве"
return True, ""
# --------------------------------------------------------------- удаление ---
def _on_rm_error(func: Callable, path: str, exc_info) -> None:
"""Снимаем read-only и пробуем ещё раз."""
try:
os.chmod(path, stat.S_IWRITE)
func(path)
except Exception:
pass
def delete_path(path: str, to_recycle_bin: bool = True) -> tuple[bool, str]:
"""Удалить файл/каталог. Возвращает (успех, сообщение об ошибке)."""
if not os.path.exists(path):
return True, ""
if to_recycle_bin and IS_WIN:
ok, err = _recycle(path)
if ok:
return True, ""
# корзина не справилась (сетевой диск, слишком большой объект) — не насилуем
return False, err
try:
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path, onerror=_on_rm_error)
else:
try:
os.chmod(path, stat.S_IWRITE)
except OSError:
pass
os.remove(path)
if os.path.exists(path):
return False, "путь остался на месте"
return True, ""
except Exception as e: # noqa: BLE001
return False, str(e)
def _recycle(path: str) -> tuple[bool, str]:
"""SHFileOperationW с флагом FOF_ALLOWUNDO."""
try:
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", wintypes.LPCWSTR),
("pTo", wintypes.LPCWSTR),
("fFlags", ctypes.c_uint16),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", wintypes.LPCWSTR),
]
FO_DELETE = 3
FOF_ALLOWUNDO = 0x0040
FOF_NOCONFIRMATION = 0x0010
FOF_NOERRORUI = 0x0400
FOF_SILENT = 0x0004
FOF_NOCONFIRMMKDIR = 0x0200
op = SHFILEOPSTRUCTW()
op.wFunc = FO_DELETE
op.pFrom = os.path.abspath(path) + "\0\0"
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI
| FOF_SILENT | FOF_NOCONFIRMMKDIR)
res = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
if res == 0 and not op.fAnyOperationsAborted:
return True, ""
return False, f"SHFileOperation код {res}"
except Exception as e: # noqa: BLE001
return False, str(e)
def clear_contents(path: str, to_recycle_bin: bool = True) -> tuple[int, int, list[str]]:
"""Очистить содержимое каталога, сам каталог оставить.
Возвращает (удалено, пропущено, ошибки).
"""
done = skipped = 0
errs: list[str] = []
try:
entries = list(os.scandir(path))
except OSError as e:
return 0, 0, [f"{path}: {e}"]
for entry in entries:
ok, err = delete_path(entry.path, to_recycle_bin)
if ok:
done += 1
else:
skipped += 1
if err and len(errs) < 20:
errs.append(f"{entry.name}: {err}")
return done, skipped, errs
# ------------------------------------------------------------- процессы -----
def running_processes() -> set[str]:
"""Имена запущенных процессов в нижнем регистре."""
names: set[str] = set()
if not IS_WIN:
return names
try:
out = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"],
capture_output=True, text=True, timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
errors="ignore",
).stdout
for line in out.splitlines():
line = line.strip()
if line.startswith('"'):
names.add(line.split('","')[0].strip('"').lower())
except Exception:
pass
return names
def is_admin() -> bool:
if not IS_WIN:
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def open_in_explorer(path: str) -> None:
try:
if IS_WIN:
if os.path.isdir(path):
os.startfile(path) # noqa: S606
else:
subprocess.Popen(["explorer", "/select,", os.path.abspath(path)])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
except Exception:
pass
def iter_globs(patterns: Iterable[str]) -> list[str]:
import glob as _g
out: list[str] = []
for p in patterns:
try:
out.extend(_g.glob(expand(p), recursive=True))
except Exception:
continue
return out