Files
PixelLauncher/make_installer.py
T

195 lines
7.9 KiB
Python
Raw Permalink 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.
# -*- 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()