Files
chongming-license-gui/packaging/_common.py
T
2026-09-22 16:08:12 +08:00

405 lines
17 KiB
Python
Raw 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.
"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。
两个打包脚本(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)