Files
chongming-license-gui/packaging/_common.py
T

405 lines
17 KiB
Python
Raw Normal View History

2026-09-22 16:08:12 +08:00
"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。
两个打包脚本(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)