195 lines
7.9 KiB
Python
195 lines
7.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Сборка установщика PixelLauncher (Inno Setup 6).
|
||
|
||
Что делает:
|
||
1) runtime: embeddable Python (с python.org) + tkinter/Tcl из локальной
|
||
установки той же версии — лаунчеру нужен только stdlib;
|
||
2) приложение: main.py + launcher/ + run.bat + README + requirements;
|
||
3) иконка: assets/icon.png -> build/launcher.ico (PNG-in-ICO);
|
||
4) компиляция: ISCC.exe installer.iss -> PixelLauncherSetup<версия>.exe
|
||
в папке проекта.
|
||
|
||
Запуск: python make_installer.py
|
||
Нужны: Inno Setup 6 (ISCC.exe) и интернет (первый раз — embeddable Python).
|
||
"""
|
||
import os
|
||
import shutil
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import urllib.request
|
||
import zipfile
|
||
|
||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||
BUILD = os.path.join(ROOT, "build")
|
||
STAGE = os.path.join(BUILD, "stage")
|
||
ISCC = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||
|
||
PYVER = "%d.%d.%d" % sys.version_info[:3]
|
||
APP_VERSION = "2.1.3" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py
|
||
SETUP_NAME = "PixelLauncherSetup%s.exe" % APP_VERSION
|
||
|
||
|
||
def log(msg):
|
||
print(msg, flush=True)
|
||
|
||
|
||
def embeddable_zip():
|
||
name = "python-%s-embed-amd64.zip" % PYVER
|
||
path = os.path.join(BUILD, name)
|
||
if not os.path.isfile(path):
|
||
url = "https://www.python.org/ftp/python/%s/%s" % (PYVER, name)
|
||
log("Скачиваю %s ..." % url)
|
||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||
with urllib.request.urlopen(req, timeout=300) as r, \
|
||
open(path, "wb") as f:
|
||
while True:
|
||
chunk = r.read(1 << 20)
|
||
if not chunk:
|
||
break
|
||
f.write(chunk)
|
||
return path
|
||
|
||
|
||
def build_stage():
|
||
if os.path.isdir(STAGE):
|
||
shutil.rmtree(STAGE, ignore_errors=True)
|
||
os.makedirs(STAGE)
|
||
|
||
log("Распаковка embeddable Python %s ..." % PYVER)
|
||
with zipfile.ZipFile(embeddable_zip()) as z:
|
||
z.extractall(STAGE)
|
||
|
||
# --- tkinter/Tcl: из локальной установки той же версии
|
||
base = sys.base_prefix
|
||
dlls = os.path.join(base, "DLLs")
|
||
copied = []
|
||
for fname in os.listdir(dlls):
|
||
low = fname.lower()
|
||
if low in ("_tkinter.pyd", "zlib1.dll") or (
|
||
low.endswith(".dll") and (low.startswith("tcl")
|
||
or low.startswith("tk"))):
|
||
shutil.copy2(os.path.join(dlls, fname),
|
||
os.path.join(STAGE, fname))
|
||
copied.append(fname)
|
||
tcl_src = os.path.join(base, "tcl")
|
||
assert os.path.isdir(tcl_src), "нет папки tcl в " + base
|
||
shutil.copytree(tcl_src, os.path.join(STAGE, "tcl"))
|
||
# сам пакет tkinter вырезан из embeddable — копируем из установки
|
||
tk_src = os.path.join(base, "Lib", "tkinter")
|
||
assert os.path.isdir(tk_src), "нет папки tkinter в " + base
|
||
shutil.copytree(tk_src, os.path.join(STAGE, "tkinter"),
|
||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||
copied.append("tkinter/")
|
||
log("tkinter/Tcl: " + ", ".join(copied) + " + tcl/")
|
||
|
||
# --- Pillow (опционально) — сглаженные иконки сборок; если её нет,
|
||
# лаунчер переключается на штатный Tk-путь
|
||
sites = [os.path.join(base, "Lib", "site-packages")]
|
||
try:
|
||
import site as _site
|
||
us = _site.getusersitepackages()
|
||
sites.extend(us if isinstance(us, (list, tuple)) else [us])
|
||
except Exception:
|
||
pass
|
||
staged = []
|
||
for site_dir in sites:
|
||
if not os.path.isdir(site_dir):
|
||
continue
|
||
for name in sorted(os.listdir(site_dir)):
|
||
if name != "PIL" and not name.lower().startswith("pillow"):
|
||
continue
|
||
src = os.path.join(site_dir, name)
|
||
dst = os.path.join(STAGE, name)
|
||
if os.path.isdir(src):
|
||
if os.path.isdir(dst):
|
||
shutil.rmtree(dst, ignore_errors=True)
|
||
shutil.copytree(src, dst,
|
||
ignore=shutil.ignore_patterns("__pycache__",
|
||
"*.pyc"))
|
||
elif os.path.isfile(src):
|
||
shutil.copy2(src, dst)
|
||
staged.append(name)
|
||
if staged:
|
||
break
|
||
log("Pillow: " + (", ".join(staged) if staged
|
||
else "не найдена — иконки без сглаживания"))
|
||
|
||
# ._pth: гарантируем точку в пути (иначе import launcher не найдёт)
|
||
pth = [f for f in os.listdir(STAGE) if f.endswith("._pth")][0]
|
||
pth_path = os.path.join(STAGE, pth)
|
||
lines = open(pth_path, encoding="utf-8").read().splitlines()
|
||
if "." not in lines:
|
||
lines.insert(1, ".")
|
||
with open(pth_path, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(lines) + "\n")
|
||
|
||
# --- приложение
|
||
app_files = ["main.py", "run.bat", "README.md", "requirements.txt"]
|
||
for name in app_files:
|
||
src = os.path.join(ROOT, name)
|
||
if os.path.isfile(src):
|
||
shutil.copy2(src, os.path.join(STAGE, name))
|
||
shutil.copytree(
|
||
os.path.join(ROOT, "launcher"),
|
||
os.path.join(STAGE, "launcher"),
|
||
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||
|
||
# --- иконка PNG -> ICO (ICO с PNG-полезной нагрузкой)
|
||
png_path = os.path.join(ROOT, "assets", "icon.png")
|
||
if os.path.isfile(png_path):
|
||
ico = os.path.join(STAGE, "launcher.ico")
|
||
make_ico(png_path, ico)
|
||
shutil.copy2(ico, os.path.join(BUILD, "launcher.ico"))
|
||
log("иконка: launcher.ico")
|
||
|
||
log("stage: %d файлов" % sum(len(f) for _r, _d, f in os.walk(STAGE)))
|
||
|
||
|
||
def make_ico(png_src, ico_dst):
|
||
png = open(png_src, "rb").read()
|
||
w, h = struct.unpack(">II", png[16:24])
|
||
entry = struct.pack("<BBBBHHII", w if w < 256 else 0,
|
||
h if h < 256 else 0, 0, 0, 1, 32,
|
||
len(png), 6 + 16)
|
||
with open(ico_dst, "wb") as f:
|
||
f.write(struct.pack("<HHH", 0, 1, 1))
|
||
f.write(entry)
|
||
f.write(png)
|
||
|
||
|
||
def compile_setup():
|
||
assert os.path.isfile(ISCC), "ISCC.exe не найден: " + ISCC
|
||
iss = os.path.join(ROOT, "installer.iss")
|
||
log("Компиляция %s ..." % os.path.basename(iss))
|
||
r = subprocess.run([ISCC, "/Q", iss], capture_output=True, text=True,
|
||
encoding="utf-8", errors="replace")
|
||
out = (r.stdout or "") + (r.stderr or "")
|
||
if r.returncode != 0:
|
||
print(out)
|
||
raise SystemExit("ISCC failed: %d" % r.returncode)
|
||
exe = os.path.join(ROOT, SETUP_NAME)
|
||
assert os.path.isfile(exe), "нет результата: " + exe
|
||
log("ГОТОВО: %s (%.1f МБ)" % (exe, os.path.getsize(exe) / 1e6))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if not os.path.isfile(os.path.join(ROOT, "assets", "icon.png")):
|
||
print("предупреждение: assets/icon.png нет — иконка не будет")
|
||
build_stage()
|
||
# smoke: стейдж самодостаточен (включая реальный Tk + SSL)
|
||
smoke = subprocess.run(
|
||
[os.path.join(STAGE, "python.exe"), "-c",
|
||
"import tkinter,ssl,urllib.request,json,zipfile;"
|
||
"r=tkinter.Tk(); r.withdraw(); r.destroy();"
|
||
"import launcher.config, launcher.http, launcher.manifest;"
|
||
"import importlib,importlib.util;"
|
||
"importlib.import_module('PIL.Image')"
|
||
" if importlib.util.find_spec('PIL') else None;"
|
||
"print('stage ok')"],
|
||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||
cwd=STAGE)
|
||
print((smoke.stdout or "").strip(), (smoke.stderr or "").strip()[-800:])
|
||
assert smoke.returncode == 0, "smoke стейджа упал"
|
||
compile_setup()
|