Первый выпуск 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:
185
sysdevcleaner/core/programs.py
Normal file
185
sysdevcleaner/core/programs.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Инвентаризация установленных программ: размер на диске, давность использования."""
|
||||
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)
|
||||
Reference in New Issue
Block a user