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

186 lines
7.0 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 os
import subprocess
import sys
from typing import Callable
from .model import Program
from .util import Cancel, dir_stats, known_paths
_UNINSTALL_KEYS = (
("HKLM", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM"),
("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM-32"),
("HKCU", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU"),
)
def _read_registry() -> list[Program]:
if sys.platform != "win32":
return []
import winreg
roots = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER}
out: list[Program] = []
seen: set[tuple[str, str]] = set()
for root_name, subkey, label in _UNINSTALL_KEYS:
try:
key = winreg.OpenKey(roots[root_name], subkey, 0, winreg.KEY_READ)
except OSError:
continue
with key:
try:
count = winreg.QueryInfoKey(key)[0]
except OSError:
continue
for i in range(count):
try:
name = winreg.EnumKey(key, i)
sub = winreg.OpenKey(key, name, 0, winreg.KEY_READ)
except OSError:
continue
with sub:
def val(field: str, default=""):
try:
return winreg.QueryValueEx(sub, field)[0]
except OSError:
return default
display = str(val("DisplayName") or "").strip()
if not display:
continue
if val("SystemComponent", 0) == 1:
continue
if val("ParentKeyName") or val("ParentDisplayName"):
# обновления и компоненты внутри бандлов
if not val("UninstallString"):
continue
version = str(val("DisplayVersion") or "")
dedup = (display.lower(), version)
if dedup in seen:
continue
seen.add(dedup)
try:
est = int(val("EstimatedSize", 0) or 0) * 1024
except (TypeError, ValueError):
est = 0
out.append(Program(
name=display,
publisher=str(val("Publisher") or ""),
version=version,
install_date=str(val("InstallDate") or ""),
install_location=str(val("InstallLocation") or "").strip('"'),
uninstall_cmd=str(val("QuietUninstallString")
or val("UninstallString") or ""),
registry_size=est,
source=label,
))
return out
def _guess_location(prog: Program) -> str:
"""Если InstallLocation пуст — пробуем вытащить путь из строки удаления."""
if prog.install_location and os.path.isdir(prog.install_location):
return prog.install_location
cmd = prog.uninstall_cmd
if not cmd:
return ""
candidate = ""
if cmd.startswith('"'):
candidate = cmd[1:].split('"', 1)[0]
else:
parts = cmd.split(" ")
for i in range(len(parts), 0, -1):
guess = " ".join(parts[:i])
if os.path.exists(guess):
candidate = guess
break
if not candidate:
return ""
folder = os.path.dirname(candidate)
k = known_paths()
# не отдаём общие корни вроде C:\Windows\System32
bad = {os.path.normcase(k["WINDIR"]), os.path.normcase(os.path.join(k["WINDIR"], "System32")),
os.path.normcase(k["PROGRAMFILES"]), os.path.normcase(k["PROGRAMFILES86"]),
os.path.normcase(k["TEMP"])}
if not folder or os.path.normcase(folder) in bad or len(folder) <= 3:
return ""
return folder if os.path.isdir(folder) else ""
def scan_programs(
measure_disk: bool = True,
cancel: Cancel | None = None,
progress: Callable[[str], None] | None = None,
) -> list[Program]:
"""Список установленных программ, отсортированный по занимаемому месту."""
progs = _read_registry()
if measure_disk:
for i, p in enumerate(progs):
if cancel is not None and cancel.cancelled:
break
loc = _guess_location(p)
if not loc:
continue
p.install_location = loc
size, _files, _dirs, mtime = dir_stats(loc, cancel, max_entries=60_000)
p.disk_size = size
p.last_used = mtime
if progress is not None and i % 10 == 0:
progress(p.name)
progs.sort(key=lambda p: -p.best_size)
return progs
def store_apps() -> list[Program]:
"""Приложения Microsoft Store (через PowerShell, может быть медленно)."""
if sys.platform != "win32":
return []
ps = (
"Get-AppxPackage | Select-Object Name,Publisher,Version,InstallLocation | "
"ConvertTo-Csv -NoTypeInformation"
)
try:
res = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
capture_output=True, text=True, timeout=90, errors="ignore",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
return []
import csv
import io
out: list[Program] = []
try:
for row in csv.DictReader(io.StringIO(res.stdout)):
loc = (row.get("InstallLocation") or "").strip()
if not loc or not os.path.isdir(loc):
continue
size, _f, _d, mtime = dir_stats(loc, max_entries=30_000)
out.append(Program(
name=row.get("Name") or "?",
publisher=(row.get("Publisher") or "").split(",")[0].replace("CN=", ""),
version=row.get("Version") or "",
install_location=loc,
disk_size=size,
last_used=mtime,
source="Store",
))
except Exception:
return out
out.sort(key=lambda p: -p.best_size)
return out
def uninstall(prog: Program) -> tuple[bool, str]:
"""Запустить штатный деинсталлятор программы (интерактивно)."""
if not prog.uninstall_cmd:
return False, "у программы нет команды удаления в реестре"
try:
subprocess.Popen(prog.uninstall_cmd, shell=True)
return True, ""
except Exception as e: # noqa: BLE001
return False, str(e)