Анализ 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>
186 lines
7.0 KiB
Python
186 lines
7.0 KiB
Python
"""Инвентаризация установленных программ: размер на диске, давность использования."""
|
||
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)
|