405 lines
17 KiB
Python
405 lines
17 KiB
Python
"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。
|
||
|
||
两个打包脚本(PyInstaller / Nuitka)只有"怎么调工具"不同,其余诉求一样:
|
||
|
||
1. **别把私钥打进去** —— 构建前扫一遍源码树,发现私钥/授权文件/台账库立即中止;
|
||
2. **给 exe 一个像样的图标** —— 图标是运行时由主题生成的,这里把它渲染成多尺寸
|
||
.ico(所以仓库里不需要存美术素材);
|
||
3. **构建完必须验证** —— 调用 ``<exe> --selftest`` 实际跑一遍依赖与渲染,
|
||
而且要在 offscreen 与系统原生两个平台插件下各跑一次(后者才能证明
|
||
Qt 的平台插件真的被打进产物里了)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import struct
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import tomllib
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
PACKAGING = ROOT / "packaging"
|
||
DIST = ROOT / "dist"
|
||
APP_NAME = "chongming-license-gui"
|
||
|
||
# 绝不允许进入产物的东西
|
||
SENSITIVE_PATTERNS = (
|
||
"license_private*.pem",
|
||
"*_private.pem",
|
||
"*_private.key",
|
||
"*.lic",
|
||
"licenses.db",
|
||
)
|
||
_SKIP_DIRS = {".venv", ".git", "dist", "build", "__pycache__", ".zcode",
|
||
".idea", ".vscode", "node_modules"}
|
||
|
||
# 图标尺寸(Windows 会按需挑选;16/32 用于任务栏与文件列表)
|
||
ICON_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 前置检查
|
||
# ---------------------------------------------------------------------------
|
||
def require(module: str, package: str) -> None:
|
||
"""确认打包工具已安装,否则给出一句能直接照抄的安装命令。"""
|
||
try:
|
||
__import__(module)
|
||
except ImportError:
|
||
raise SystemExit(
|
||
f"未安装 {package}。请先安装开发依赖:\n"
|
||
f" uv sync --group dev\n"
|
||
f"(或单独安装:uv add --group dev {package})"
|
||
) from None
|
||
|
||
|
||
def project_version() -> str:
|
||
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||
return str(data.get("project", {}).get("version", "0.0.0"))
|
||
|
||
|
||
def find_sensitive_files() -> list[Path]:
|
||
"""扫描源码树里的私钥 / 授权文件 / 台账库。"""
|
||
found: list[Path] = []
|
||
for path in ROOT.rglob("*"):
|
||
if not path.is_file():
|
||
continue
|
||
if any(part in _SKIP_DIRS for part in path.parts):
|
||
continue
|
||
if any(path.match(pattern) for pattern in SENSITIVE_PATTERNS):
|
||
found.append(path)
|
||
return found
|
||
|
||
|
||
def guard_no_secrets(*, allow: bool = False) -> None:
|
||
"""构建前的安全闸门:这些文件一个都不该出现在仓库里,更不该进产物。
|
||
|
||
``allow=True``(对应 ``--allow-sensitive-files``)只降级为警告并列出文件 ——
|
||
用在"文件确实只是暂时放在这里、我清楚它不会随产物分发"的场景。
|
||
私钥进产物 = 拿到的人能自己签发授权,所以默认是硬中止。
|
||
"""
|
||
found = find_sensitive_files()
|
||
if not found:
|
||
return
|
||
listing = "\n".join(f" - {p.relative_to(ROOT)}" for p in found)
|
||
if allow:
|
||
print("[警告] 源码树里存在敏感文件,但已显式允许继续构建:")
|
||
print(listing)
|
||
print("[警告] 它们不会被打进产物(本工具不使用 datas),但请勿随目录一起分发。\n")
|
||
return
|
||
raise SystemExit(
|
||
"构建中止:源码树里存在私钥/授权文件/台账库,它们绝不能进入分发包。\n"
|
||
f"{listing}\n\n"
|
||
"请先移走(私钥放离线介质、台账库与 .lic 移到个人目录),确认无误后再重新构建。\n"
|
||
"确实只是临时放置、不影响分发时,可加 --allow-sensitive-files 跳过本检查。"
|
||
)
|
||
|
||
|
||
def guard_artifact_no_secrets(artifact: Path) -> list[str]:
|
||
"""构建后再查一遍:产物目录里不该出现 .pem/.lic/.db 之类的散落文件。
|
||
|
||
onefile 是自解压单文件,这里查不到内部内容 —— 真正的防线是构建前的
|
||
:func:`guard_no_secrets`;这一步是为了兜住"onedir 模式下把工作目录里的
|
||
文件一起拷进去"这类意外。
|
||
"""
|
||
if not artifact.is_dir():
|
||
return []
|
||
hits = [
|
||
str(p.relative_to(artifact))
|
||
for p in artifact.rglob("*")
|
||
if p.is_file() and any(p.match(pat) for pat in SENSITIVE_PATTERNS)
|
||
]
|
||
return hits
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 图标:把主题里的盾牌渲染成多尺寸 .ico
|
||
# ---------------------------------------------------------------------------
|
||
def _render_icon_pngs() -> dict[int, bytes]:
|
||
"""渲染各尺寸的 PNG 数据(用 QSvgRenderer,无需美术素材)。"""
|
||
sys.path.insert(0, str(ROOT / "src"))
|
||
from PySide6.QtCore import QBuffer, QIODevice
|
||
from PySide6.QtGui import QGuiApplication, QImage, QPainter
|
||
from PySide6.QtSvg import QSvgRenderer
|
||
|
||
from chongming_license_gui import icons, theme
|
||
|
||
# 只渲染图像,不建窗口 —— QGuiApplication 就够
|
||
app = QGuiApplication.instance() or QGuiApplication([])
|
||
del app
|
||
|
||
svg = icons.svg_markup("shield", theme.ACCENT)
|
||
renderer = QSvgRenderer(svg.encode("utf-8"))
|
||
if not renderer.isValid():
|
||
raise SystemExit("图标渲染失败:盾牌 SVG 无效(icons.svg_markup 返回空?)")
|
||
|
||
pngs: dict[int, bytes] = {}
|
||
for size in ICON_SIZES:
|
||
image = QImage(size, size, QImage.Format.Format_ARGB32)
|
||
image.fill(0)
|
||
painter = QPainter(image)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||
renderer.render(painter)
|
||
painter.end()
|
||
buffer = QBuffer()
|
||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||
if not image.save(buffer, "PNG"):
|
||
raise SystemExit(f"图标渲染失败:{size}px 无法编码为 PNG")
|
||
pngs[size] = bytes(buffer.data())
|
||
return pngs
|
||
|
||
|
||
def _write_ico(target: Path, pngs: dict[int, bytes]) -> None:
|
||
"""按 ICO 规范拼装多尺寸图标。
|
||
|
||
Qt 的 ICO 写入只支持单张图,而小尺寸(16/32)靠 256px 缩放会糊,
|
||
所以这里自己拼容器:每张图用 PNG 编码嵌入(Vista 以后都支持)。
|
||
"""
|
||
entries = b""
|
||
blobs = b""
|
||
offset = 6 + 16 * len(pngs)
|
||
for size in sorted(pngs):
|
||
png = pngs[size]
|
||
# 宽高为 0 表示 256(一个字节放不下 256)
|
||
dim = 0 if size >= 256 else size
|
||
entries += struct.pack("<BBBBHHII", dim, dim, 0, 0, 1, 32, len(png), offset)
|
||
offset += len(png)
|
||
blobs += png
|
||
target.write_bytes(struct.pack("<HHH", 0, 1, len(pngs)) + entries + blobs)
|
||
|
||
|
||
def build_app_icon(out_dir: Path | None = None) -> Path | None:
|
||
"""生成 ``app.ico``;失败返回 None(没图标也能打包,不该因此中断构建)。"""
|
||
target_dir = out_dir or (PACKAGING / "build")
|
||
target = target_dir / "app.ico"
|
||
try:
|
||
target_dir.mkdir(parents=True, exist_ok=True)
|
||
_write_ico(target, _render_icon_pngs())
|
||
# 回读校验:写坏了会导致打包时静默无图标
|
||
from PySide6.QtGui import QImage
|
||
|
||
check = QImage(str(target))
|
||
if check.isNull():
|
||
print("[warn] 生成的 .ico 无法回读,本次构建将不带图标")
|
||
return None
|
||
print(f"应用图标:{target.relative_to(ROOT)}({len(ICON_SIZES)} 个尺寸,回读校验通过)")
|
||
return target
|
||
except Exception as exc: # noqa: BLE001 - 图标属于锦上添花,不阻断构建
|
||
print(f"[warn] 生成应用图标失败({exc}),本次构建将不带图标")
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 构建后验证
|
||
# ---------------------------------------------------------------------------
|
||
def find_artifact(dist_dir: Path) -> Path | None:
|
||
"""在 dist 目录里找打包出来的 exe(onefile 与 onedir 布局不同)。"""
|
||
direct = dist_dir / f"{APP_NAME}.exe"
|
||
if direct.is_file():
|
||
return direct
|
||
nested = dist_dir / APP_NAME / f"{APP_NAME}.exe"
|
||
if nested.is_file():
|
||
return nested
|
||
nested_dist = dist_dir / f"{APP_NAME}.dist" / f"{APP_NAME}.exe"
|
||
if nested_dist.is_file():
|
||
return nested_dist
|
||
for candidate in dist_dir.rglob("*.exe"):
|
||
return candidate
|
||
return None
|
||
|
||
|
||
# 系统原生平台插件名(用于"真的能在桌面上跑起来"那一轮验证)。
|
||
# 必须显式传:app.main() 里对 QT_QPA_PLATFORM 用的是 setdefault,只 unset 变量
|
||
# 会退回 offscreen,等于"原生那轮"根本没验到原生插件。
|
||
NATIVE_PLATFORM = {
|
||
"win32": "windows",
|
||
"darwin": "cocoa",
|
||
"linux": "xcb",
|
||
}.get(sys.platform)
|
||
|
||
|
||
def run_selftest(exe: Path, platform: str | None, timeout: int = 600) -> tuple[bool, str]:
|
||
"""运行 ``<exe> --selftest``,返回 (是否通过, 输出)。
|
||
|
||
窗口版 exe(--noconsole)没有可用的 stdout,所以结果以**报告文件**为准。
|
||
"""
|
||
env = dict(os.environ)
|
||
if platform:
|
||
env["QT_QPA_PLATFORM"] = platform
|
||
else:
|
||
env.pop("QT_QPA_PLATFORM", None)
|
||
|
||
report_dir = Path(tempfile.mkdtemp(prefix="clg-build-check-"))
|
||
report = report_dir / "selftest.txt"
|
||
try:
|
||
proc = subprocess.run(
|
||
[str(exe), "--selftest", "--selftest-report", str(report)],
|
||
capture_output=True, timeout=timeout, env=env, cwd=str(exe.parent),
|
||
# 冻结产物的输出不保证是 UTF-8(中文 Windows 上可能是 GBK),
|
||
# 用 replace 兜住,别让解码失败把整个构建判定成失败
|
||
encoding="utf-8", errors="replace",
|
||
)
|
||
output = (proc.stdout or "") + (proc.stderr or "")
|
||
code = proc.returncode
|
||
except subprocess.TimeoutExpired:
|
||
return False, f"自检超时(>{timeout}s)—— 产物可能卡在启动阶段"
|
||
|
||
if report.is_file():
|
||
output += "\n[报告文件]\n" + report.read_text(encoding="utf-8", errors="replace")
|
||
return code == 0, output
|
||
|
||
|
||
def verify_artifact(exe: Path, label: str) -> bool:
|
||
"""两轮自检:offscreen(无头可用)+ 系统原生平台插件。
|
||
|
||
第二轮是关键:只有它才能证明 Qt 的 **platforms 插件**(Windows 上是
|
||
``qwindows.dll``)真的被打进了产物 —— 少了它,开发机上一切正常,
|
||
同事双击 exe 却直接闪退。
|
||
"""
|
||
print(f"\n=== 验证 {label}:{exe} ===")
|
||
runs: list[tuple[str | None, str]] = [("offscreen", "offscreen(无头)")]
|
||
if NATIVE_PLATFORM:
|
||
runs.append((NATIVE_PLATFORM, f"系统原生平台 {NATIVE_PLATFORM}"))
|
||
else:
|
||
print(f" [跳过] 未知平台 {sys.platform},无法验证原生平台插件")
|
||
|
||
ok = True
|
||
for platform, tag in runs:
|
||
passed, output = run_selftest(exe, platform)
|
||
# 报告文件与 stdout 内容重复,摘取摘要行时去重(保留首次出现顺序)。
|
||
# 「PostgreSQL 驱动」那行是"可选功能是否真的打进产物"的唯一信号 ——
|
||
# 驱动缺失时 PG 功能整块不可用,必须让人在构建输出里一眼看到。
|
||
head: list[str] = []
|
||
for line in output.splitlines():
|
||
if line.startswith(("平台插件", "PostgreSQL 驱动", "[FAIL]", "❌", "✅")) \
|
||
and line not in head:
|
||
head.append(line)
|
||
print(f" [{tag}] {'通过 ✅' if passed else '失败 ❌'}")
|
||
for line in head:
|
||
print(f" {line}")
|
||
if not passed:
|
||
ok = False
|
||
print(" ---- 完整输出 ----")
|
||
for line in output.splitlines():
|
||
print(f" {line}")
|
||
return ok
|
||
|
||
|
||
def artifact_size(path: Path) -> str:
|
||
"""人类可读的产物体积(onedir 递归求和)。"""
|
||
if path.is_file():
|
||
total = path.stat().st_size
|
||
else:
|
||
total = sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
|
||
for unit in ("B", "KB", "MB", "GB"):
|
||
if total < 1024 or unit == "GB":
|
||
return f"{total:.1f} {unit}" if unit != "B" else f"{total} B"
|
||
total /= 1024
|
||
return f"{total:.1f} GB"
|
||
|
||
|
||
def report_success(tool: str, artifact: Path, elapsed: float) -> None:
|
||
print(f"\n{'=' * 60}")
|
||
print(f"✅ {tool} 打包完成({elapsed:.0f}s)")
|
||
print(f" 产物:{artifact.relative_to(ROOT)}")
|
||
print(f" 体积:{artifact_size(artifact)}")
|
||
print(f" 自检:已通过(offscreen + 系统原生平台插件)")
|
||
print("\n分发提示:本工具是厂商侧工具,含签发能力,请只发给厂商内部的同事。")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 构建命令执行
|
||
# ---------------------------------------------------------------------------
|
||
def run_build(command: list[str], log_path: Path, env: dict[str, str]) -> tuple[int, str]:
|
||
"""跑构建命令,日志落文件后回读。
|
||
|
||
不回显到终端而是写文件:构建日志很长(Nuitka 动辄上万行),
|
||
落盘才能事后排查;返回值同时给调用方做失败特征匹配。
|
||
"""
|
||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with log_path.open("w", encoding="utf-8", errors="replace") as log:
|
||
proc = subprocess.run(
|
||
command, stdout=log, stderr=subprocess.STDOUT, env=env, cwd=str(ROOT)
|
||
)
|
||
return proc.returncode, log_path.read_text(encoding="utf-8", errors="replace")
|
||
|
||
|
||
def log_tail(text: str, lines: int = 25) -> str:
|
||
return "\n".join(text.splitlines()[-lines:])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 构建环境:挡住"抢注同名 DLL"的目录
|
||
# ---------------------------------------------------------------------------
|
||
# Windows 上的经典陷阱:Git for Windows / msys64 / 各种 SDK 都带
|
||
# libssl-3-x64.dll、libcrypto-3-x64.dll 这类通用名 DLL,而它们**都在 PATH 上**。
|
||
# PyInstaller 解析二进制依赖时会搜 PATH,一旦搜到就把 Python 自带的那份**替换掉** ——
|
||
# 产物里 _ssl 于是绑定了错误版本的 OpenSSL,运行时才炸:
|
||
#
|
||
# ImportError: DLL load failed while importing _ssl: 找不到指定的程序
|
||
#
|
||
# 而且报错点离原因极远(表现为 psycopg 报 "no pq wrapper available",因为
|
||
# psycopg 的二进制实现对 _ssl 有依赖)。本机实测:Git 自带的
|
||
# C:\Program Files\Git\mingw64\bin\libssl-3-x64.dll(930460 字节)就是这样混进产物的,
|
||
# 而 Python 的是 DLLs\libssl-3-x64.dll(1579008 字节)。
|
||
#
|
||
# 规则:构建时把"含有与 Python 自带 DLL 同名文件"的目录从 PATH 摘掉。
|
||
_WINDOWS_DIR = os.environ.get("SystemRoot", r"C:\Windows")
|
||
|
||
|
||
def _python_dll_names() -> set[str]:
|
||
"""Python 自带的 DLL 名字(用来判断某个目录是否会"抢注")。"""
|
||
names: set[str] = set()
|
||
for base in (Path(sys.base_prefix), Path(sys.base_prefix) / "DLLs",
|
||
Path(sys.base_prefix) / "Library" / "bin"):
|
||
try:
|
||
names |= {p.name.lower() for p in base.glob("*.dll")}
|
||
except OSError:
|
||
continue
|
||
return names
|
||
|
||
|
||
def build_env_overrides(base_env: dict[str, str] | None = None) -> dict[str, str]:
|
||
"""构建子进程的环境覆盖:清理 PATH 里的"同名 DLL 抢注"目录。
|
||
|
||
返回可直接 ``env.update(...)`` 的字典;同时打印被摘掉的目录,便于排查。
|
||
"""
|
||
env = base_env or os.environ
|
||
original = env.get("PATH", "")
|
||
if not original:
|
||
return {}
|
||
|
||
python_dlls = _python_dll_names()
|
||
kept: list[str] = []
|
||
dropped: list[str] = []
|
||
for entry in original.split(os.pathsep):
|
||
if not entry.strip():
|
||
continue
|
||
path = Path(entry)
|
||
# 系统目录永远保留(去掉了会直接构建失败)
|
||
if path.name.lower() == "system32" or str(path).lower().startswith(_WINDOWS_DIR.lower()):
|
||
kept.append(entry)
|
||
continue
|
||
try:
|
||
shadows = any(f.name.lower() in python_dlls for f in path.glob("*.dll"))
|
||
except OSError:
|
||
shadows = False
|
||
(dropped if shadows else kept).append(entry)
|
||
|
||
if not dropped:
|
||
return {}
|
||
print("构建环境:以下 PATH 目录里有与 Python 同名的 DLL(会在打包时被误收集),已临时摘除:")
|
||
for entry in dropped:
|
||
print(f" - {entry}")
|
||
return {"PATH": os.pathsep.join(kept)}
|
||
|
||
|
||
# MSVC 堆空间不足的特征串(C1002/LNK1257 在中文/英文环境下都是这两个码)
|
||
_OUT_OF_HEAP_MARKERS = ("C1002", "LNK1257", "out of heap space", "compiler heap")
|
||
|
||
|
||
def is_compiler_out_of_memory(log_text: str) -> bool:
|
||
"""是不是 MSVC 编译生成的 C 文件时堆空间不足(Nuitka 大模块常见)。"""
|
||
return any(marker in log_text for marker in _OUT_OF_HEAP_MARKERS)
|