"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。 两个打包脚本(PyInstaller / Nuitka)只有"怎么调工具"不同,其余诉求一样: 1. **别把私钥打进去** —— 构建前扫一遍源码树,发现私钥/授权文件/台账库立即中止; 2. **给 exe 一个像样的图标** —— 图标是运行时由主题生成的,这里把它渲染成多尺寸 .ico(所以仓库里不需要存美术素材); 3. **构建完必须验证** —— 调用 `` --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(" 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]: """运行 `` --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)