#!/usr/bin/env python3 """Сборка SysDevCleaner в исполняемый файл через PyInstaller. Примеры: python build_exe.py один файл SysDevCleaner.exe: шрифт вшит, запуск сразу с правами администратора python build_exe.py --onedir папка dist\\SysDevCleaner (запускается быстрее) python build_exe.py --no-admin без манифеста UAC python build_exe.py --no-font не вшивать шрифт (возьмётся системный) python build_exe.py --console оставить окно консоли (для отладки) Права администратора нужны для Windows\\Temp, кеша обновлений, Prefetch и Windows.old — без них самая крупная добыча остаётся недоступной. Поэтому манифест UAC встраивается по умолчанию. Про шрифт: без вшитого TTF на чужой машине без JetBrains Mono NF интерфейс откатится на Segoe UI — кириллица будет, иконки пропадут. Поэтому по умолчанию скрипт копирует найденный Nerd Font в папку ``fonts`` и вкладывает её в сборку. """ from __future__ import annotations import argparse import os import shutil import subprocess import sys ROOT = os.path.dirname(os.path.abspath(__file__)) FONTS_DIR = os.path.join(ROOT, "fonts") ICON = os.path.join(ROOT, "sysdevcleaner.ico") NAME = "SysDevCleaner" # что искать для вшивания: обычное начертание + жирное + моноширинное WANTED_FONTS = ( "JetBrainsMonoNerdFont-Regular.ttf", "JetBrainsMonoNerdFont-Bold.ttf", "JetBrainsMonoNerdFontMono-Regular.ttf", ) FALLBACK_FONTS = ("segoeui.ttf", "segoeuib.ttf", "consola.ttf") def log(msg: str) -> None: print(f" {msg}", flush=True) def ensure_pyinstaller() -> bool: try: import PyInstaller # noqa: F401 return True except ImportError: pass log("PyInstaller не найден, устанавливаю…") res = subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"]) if res.returncode != 0: print("Не удалось установить PyInstaller.", file=sys.stderr) return False return True def ensure_dearpygui() -> bool: try: import dearpygui # noqa: F401 return True except ImportError: pass log("Dear PyGui не найден, устанавливаю…") res = subprocess.run([sys.executable, "-m", "pip", "install", "dearpygui"]) return res.returncode == 0 def system_font_dirs() -> list[str]: windir = os.environ.get("WINDIR", r"C:\Windows") local = os.environ.get("LOCALAPPDATA", "") dirs = [os.path.join(windir, "Fonts")] if local: dirs.append(os.path.join(local, "Microsoft", "Windows", "Fonts")) return [d for d in dirs if os.path.isdir(d)] def _copy_font(name: str) -> bool: """Скопировать один системный шрифт в fonts/. True, если он там оказался.""" target = os.path.join(FONTS_DIR, name) if os.path.isfile(target): return True for directory in system_font_dirs(): src = os.path.join(directory, name) if not os.path.isfile(src): continue try: shutil.copy2(src, target) log(f"шрифт: {name}") return True except OSError as e: log(f"не скопировался {name}: {e}") return False return False def collect_fonts() -> int: """Сложить шрифты в fonts/ рядом со скриптом. Возвращает их количество. Сначала пробуем Nerd Font — только с ним в интерфейсе будут иконки. Если его в системе нет, берём Segoe UI: кириллица останется, иконки нет. """ os.makedirs(FONTS_DIR, exist_ok=True) # список, а не генератор: нужны все начертания, а не только первое удачное if not any([_copy_font(n) for n in WANTED_FONTS]): log("JetBrains Mono Nerd Font в системе не найден — беру системный шрифт " "(интерфейс будет без иконок)") for name in FALLBACK_FONTS: _copy_font(name) return len([f for f in os.listdir(FONTS_DIR) if f.lower().endswith(".ttf")]) def build(args: argparse.Namespace) -> int: if not ensure_pyinstaller() or not ensure_dearpygui(): return 1 add_data: list[str] = [] if not args.no_font: count = collect_fonts() if count: log(f"в сборку войдёт шрифтов: {count}") add_data.append(f"{FONTS_DIR}{os.pathsep}fonts") else: log("шрифты не найдены — exe будет искать их в системе") cmd = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--name", NAME, "--onedir" if args.onedir else "--onefile", "--console" if args.console else "--windowed", "--collect-all", "dearpygui", "--exclude-module", "tkinter", # запасной интерфейс в exe не нужен "--exclude-module", "numpy", "--exclude-module", "PIL", "--distpath", os.path.join(ROOT, "dist"), "--workpath", os.path.join(ROOT, "build"), "--specpath", os.path.join(ROOT, "build"), ] # значок нужен и внутри сборки: его ставит себе окно приложения if os.path.isfile(ICON): add_data.append(f"{ICON}{os.pathsep}.") for item in add_data: cmd += ["--add-data", item] if not args.no_admin: # манифест UAC: Windows поднимет права ещё до старта Python cmd.append("--uac-admin") log("exe будет запрашивать права администратора при запуске") else: log("без манифеста UAC — права запросит уже само приложение") if args.icon and os.path.isfile(args.icon): cmd += ["--icon", args.icon] log(f"значок: {os.path.basename(args.icon)}") else: log("значок не найден — запустите make_icon.py") cmd.append(os.path.join(ROOT, "run.py")) log("запускаю PyInstaller…") print() res = subprocess.run(cmd, cwd=ROOT) print() if res.returncode != 0: print("Сборка не удалась.", file=sys.stderr) return res.returncode target = (os.path.join(ROOT, "dist", NAME, f"{NAME}.exe") if args.onedir else os.path.join(ROOT, "dist", f"{NAME}.exe")) if os.path.isfile(target): size = os.path.getsize(target) / (1024 * 1024) log(f"готово: {target} ({size:.1f} МБ)") if args.onedir: log("раздавать нужно всю папку dist\\SysDevCleaner целиком") else: log(f"PyInstaller отработал, но {target} не найден — проверьте вывод выше") return 1 return 0 def main() -> int: p = argparse.ArgumentParser(description="Сборка SysDevCleaner в exe") p.add_argument("--onedir", action="store_true", help="папка вместо одного файла: запуск быстрее, " "нет распаковки во временный каталог") p.add_argument("--no-admin", action="store_true", help="не встраивать манифест UAC (по умолчанию он есть, и exe " "запускается с правами администратора)") p.add_argument("--no-font", action="store_true", help="не вшивать шрифт в сборку") p.add_argument("--console", action="store_true", help="оставить консольное окно (видны ошибки)") p.add_argument("--icon", default=ICON, help="путь к .ico (если файла нет, значок будет стандартный)") return build(p.parse_args()) if __name__ == "__main__": raise SystemExit(main())