"""用 PyInstaller 打包成单文件 exe。 用法:: uv run python packaging/build_pyinstaller.py # 单文件(默认) uv run python packaging/build_pyinstaller.py --onedir # 目录模式,启动更快 uv run python packaging/build_pyinstaller.py --console # 带控制台,排查启动问题 uv run python packaging/build_pyinstaller.py --no-selftest # 跳过构建后自检 产物:``dist/pyinstaller/`` 构建前会先做两件事(任一失败即中止): 1. **私钥守卫**:源码树里若存在私钥 / ``*.lic`` / ``licenses.db`` 就停下 —— 这些东西绝不能进分发包; 2. **生成 exe 图标**:由主题里的盾牌 SVG 渲染多尺寸 ``.ico``,无需美术素材。 构建后自动跑 `` --selftest`` 两轮(offscreen + 系统原生平台插件), 验证冻结产物里的 Qt 插件、图标渲染、Ed25519 签发与验签链路都还在。 """ from __future__ import annotations import argparse import os import shutil import sys import time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import _common as common # noqa: E402 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="用 PyInstaller 打包 chongming 授权工具") mode = parser.add_mutually_exclusive_group() mode.add_argument("--onefile", action="store_true", default=True, help="打成单个 exe(默认;分发最省事)") mode.add_argument("--onedir", action="store_true", help="打成目录(启动更快,适合固定办公机)") parser.add_argument("--console", action="store_true", help="保留控制台窗口(默认无控制台;排查启动失败时有用)") parser.add_argument("--clean", action="store_true", help="构建前清掉旧的 dist/build") parser.add_argument("--no-selftest", action="store_true", help="跳过构建后的产物自检") parser.add_argument("--allow-sensitive-files", action="store_true", help="源码树里有私钥/.lic/台账库时仍继续构建(默认中止)") return parser.parse_args() def main() -> int: args = parse_args() common.require("PyInstaller", "pyinstaller") onefile = not args.onedir dist_dir = common.DIST / "pyinstaller" build_dir = common.PACKAGING / "build" / "pyinstaller" print("=" * 60) print(f"PyInstaller 打包({'单文件' if onefile else '目录模式'})") print("=" * 60) # 1) 安全闸门:私钥/授权文件/台账库一个都不能出现在产物链路里 common.guard_no_secrets(allow=args.allow_sensitive_files) # 2) 图标 icon = common.build_app_icon() if args.clean: for path in (dist_dir, build_dir): if path.exists(): shutil.rmtree(path) print(f"已清理 {path.relative_to(common.ROOT)}") # 3) 调用 PyInstaller(选项通过环境变量传给 spec) env = dict(os.environ) env.update({ "CLG_NAME": common.APP_NAME, "CLG_ONEFILE": "1" if onefile else "0", "CLG_CONSOLE": "1" if args.console else "0", "CLG_ICON": str(icon) if icon else "", }) # PATH 里若有"与 Python 同名的 DLL",PyInstaller 会误收集它们(见 _common 的说明) env.update(common.build_env_overrides(env)) command = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--distpath", str(dist_dir), "--workpath", str(build_dir), str(common.PACKAGING / "pyinstaller.spec"), ] log_path = common.PACKAGING / "build" / "pyinstaller-build.log" print("\n$ " + " ".join(command)) print(f"构建日志:{log_path.relative_to(common.ROOT)}\n") started = time.monotonic() code, log_text = common.run_build(command, log_path, env) elapsed = time.monotonic() - started if code != 0: print(f"❌ PyInstaller 构建失败(退出码 {code})\n") print(common.log_tail(log_text, 30)) print(f"\n完整日志:{log_path.relative_to(common.ROOT)}") return code # 4) 找到产物并验证 artifact = common.find_artifact(dist_dir) if artifact is None: print(f"\n❌ 构建结束但没在 {dist_dir} 里找到 exe") return 1 stray = common.guard_artifact_no_secrets(artifact.parent) if stray: print(f"\n❌ 产物目录里出现敏感文件,请检查后再分发:{stray}") return 1 if not args.no_selftest: if not common.verify_artifact(artifact, "PyInstaller 产物"): print("\n❌ 产物自检未通过 —— 先别分发,见上方输出") return 1 common.report_success("PyInstaller", artifact, elapsed) return 0 if __name__ == "__main__": sys.exit(main())