modify: 台账更改插件
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# 打包分发
|
||||
|
||||
把 GUI 打成**免安装 exe**,发给厂商内部同事(商务/售后)。两种方式各一个脚本,
|
||||
都自带"私钥守卫 + 产物自检"。
|
||||
|
||||
```bash
|
||||
uv sync --group dev # 装打包工具(pyinstaller / nuitka)
|
||||
|
||||
uv run python packaging/build_pyinstaller.py # 方式一:PyInstaller(快,默认单文件)
|
||||
uv run python packaging/build_nuitka.py # 方式二:Nuitka(真编译,慢但更小更快)
|
||||
|
||||
# 产物
|
||||
dist/pyinstaller/chongming-license-gui.exe
|
||||
dist/nuitka/chongming-license-gui.exe
|
||||
```
|
||||
|
||||
## 该选哪个
|
||||
|
||||
下表为**本机实测值**(Windows 11 / Python 3.14 / Qt 6.11.2 / MSVC 14.5,单文件模式):
|
||||
|
||||
| | PyInstaller | Nuitka |
|
||||
| --- | --- | --- |
|
||||
| 构建耗时 | **约 2.5 分钟**(实测 149s) | **约 10 分钟**(首次实测 1140s,含一次编译器重试) |
|
||||
| 构建前提 | 无 | **需要 C 编译器**(MSVC 或 Nuitka 支持的 mingw64) |
|
||||
| 单文件体积 | 54.8 MB | **38.6 MB** |
|
||||
| 启动 + 跑完自检 | 2.7s | **1.8s** |
|
||||
| 反编译难度 | 打包的 .pyc 可被还原 | **编译成 C,难得多** |
|
||||
| 稳定性 | 生态成熟、踩坑资料多 | 需要针对 Python/Qt 版本调参(见下) |
|
||||
|
||||
**建议**:日常发给同事用 **Nuitka 单文件**(更小、启动更快、更难反编译);
|
||||
需要快速出包/临时验证用 **PyInstaller**(两分半就好,且不需要编译器)。
|
||||
|
||||
> ⚠️ **要 PostgreSQL 支持就用 PyInstaller。** Nuitka 在 Python 3.13+ 上只能用 MSVC
|
||||
> (`--mingw64` 被 Nuitka 明确拒绝),而 MSVC 编译 psycopg 的生成代码会
|
||||
> `fatal error C1002`(编译器堆空间不足)—— 这是编译器上限,不是代码问题。
|
||||
> Nuitka 脚本检测到这一点会**自动降级**为"不带 PG"的构建并告诉你;
|
||||
> 也可以显式 `--no-postgres`。不带 PG 的包里,界面会如实提示
|
||||
> 「未安装 PostgreSQL 驱动(可选功能)」,本地 SQLite 台账与签发校验全部照常。
|
||||
|
||||
## 常用参数
|
||||
|
||||
两个脚本参数一致:
|
||||
|
||||
| 参数 | 作用 |
|
||||
| --- | --- |
|
||||
| `--onedir` / `--standalone` | 打成目录而不是单文件。**启动更快**,适合放在固定办公机上;分发时整个目录一起拷 |
|
||||
| `--console` | 保留控制台窗口。默认是**无控制台**(纯 GUI);排查"双击没反应"时用得上 |
|
||||
| `--clean` | 构建前清理旧的 `dist/`(Nuitka 还会顺手删中间产物) |
|
||||
| `--lto=no`(仅 Nuitka) | 关掉链接期优化 —— **显著加快构建**(默认开着,链接阶段实测要 6 分钟以上) |
|
||||
| `--no-low-memory`(仅 Nuitka) | 关掉低内存模式。默认**开启**,见下方 C1002 说明 |
|
||||
| `--no-selftest` | 跳过构建后的产物自检(不建议) |
|
||||
| `--allow-sensitive-files` | 源码树里有私钥/`.lic`/台账库时仍继续构建(默认**中止**,见下) |
|
||||
| `--msvc` / `--mingw64`(仅 Nuitka) | 指定编译器;默认自动挑选 |
|
||||
|
||||
## 两道安全闸门(重要)
|
||||
|
||||
**第一道:构建前扫源码树。** 命中 `license_private*.pem`、`*_private.pem`、
|
||||
`*_private.key`、`*.lic`、`licenses.db` 一律**中止构建**并列出文件:
|
||||
|
||||
```
|
||||
构建中止:源码树里存在私钥/授权文件/台账库,它们绝不能进入分发包。
|
||||
- license_L20260921-A36014.lic
|
||||
```
|
||||
|
||||
原因很直接:这个工具**含签发能力**。私钥一旦随包发出去,拿到的人就能自己签发有效授权;
|
||||
`.lic` 与台账库则含客户名与机器指纹。确实只是临时放置时,可加
|
||||
`--allow-sensitive-files` 跳过(会打印警告并列出文件)。
|
||||
|
||||
**第二道:构建后再查产物目录**,避免 onedir 模式把工作目录里的散落文件一起拷进去。
|
||||
|
||||
## 构建后自检(为什么必须有)
|
||||
|
||||
冻结失败往往是**静默**的 —— 界面能开,但图标全空白、复选框点不动,或者签发时才抛异常。
|
||||
所以构建脚本会自动调用 ``<exe> --selftest`` 跑一遍真依赖,而且跑**两轮**:
|
||||
|
||||
| 轮次 | `QT_QPA_PLATFORM` | 验的是什么 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `offscreen` | 无头环境也能验;CI 里可跑 |
|
||||
| 2 | 系统原生(Windows 上是 `windows`) | **Qt 的 platforms 插件真的打进产物了** —— 少了它,开发机上一切正常,同事双击闪退 |
|
||||
|
||||
自检覆盖的正是打包最容易漏的东西:
|
||||
|
||||
- **QtSvg 模块**:少了它所有图标变空白(`QIcon` 是空的,按钮上什么都不画);
|
||||
- **svg 图片格式插件**:少了它样式表里的复选框/单选框指示器加载失败 → 勾选框变空白;
|
||||
- **cryptography 的 Ed25519 后端**:签发与验签全链路(含"异机指纹被拒绝");
|
||||
- **orjson**(编译扩展):申请码编解码往返;
|
||||
- **sqlite3 + 台账**:入库/检索/统计;
|
||||
- **PostgreSQL 驱动(可选功能)**:构建输出里会打印一行 `PostgreSQL 驱动:可用/不可用`;
|
||||
另有一个显式的 `CHONGMING_LICENSE_PG` 开关,给了才会**真连一次**库并跑一遍台账
|
||||
适配层(在事务里,跑完回滚,不留痕迹);
|
||||
- **tomllib + 路径处理**:插件自动发现;
|
||||
- **主窗口装配**:四个页签、表格 9 列、结论横幅、整窗渲染出非空位图。
|
||||
|
||||
自检**不碰真实数据**:QSettings 被重定向到临时 INI、台账库指向临时文件
|
||||
(否则 `RegistryTab` 初始化会去动你真实的 `~/.chongming/licenses.db`)。
|
||||
它也不会弹窗 —— 全程不调用 `show()`,只在内存里渲染。
|
||||
|
||||
手动跑:
|
||||
|
||||
```bash
|
||||
dist/pyinstaller/chongming-license-gui.exe --selftest --selftest-report 报告.txt
|
||||
CHONGMING_LICENSE_PG="postgresql://user:pw@host:5432/db" \
|
||||
dist/pyinstaller/chongming-license-gui.exe --selftest # 额外验一次真库
|
||||
```
|
||||
|
||||
## 踩过的坑:PATH 上的同名 DLL 会污染产物
|
||||
|
||||
**症状**:exe 能启动、界面正常,但 PostgreSQL 功能报
|
||||
`ImportError: no pq wrapper available`,往深看是
|
||||
`DLL load failed while importing _ssl: 找不到指定的程序`。
|
||||
|
||||
**原因**:Windows 上 `libssl-3-x64.dll` / `libcrypto-3-x64.dll` 这类**通用名 DLL**
|
||||
到处都是 —— Git for Windows、msys64、各种 SDK、厂商工具目录。它们都在 PATH 上。
|
||||
PyInstaller 解析二进制依赖时会搜 PATH,**搜到哪份就用哪份**,于是把
|
||||
`_ssl.pyd` 需要的 OpenSSL 换成了别的构建:
|
||||
|
||||
| 来源 | 大小 |
|
||||
| --- | --- |
|
||||
| Python 自带(正确) | 1,579,008 字节 |
|
||||
| **Git for Windows**(被误收集) | **930,460 字节** |
|
||||
| msys64 | 1,055,094 字节 |
|
||||
|
||||
版本对不上,`_ssl.pyd` 绑定符号失败 → 依赖 `_ssl` 的 psycopg 二进制实现整块不可用。
|
||||
|
||||
**修法**:`_common.build_env_overrides()` 在构建前清一遍 PATH ——
|
||||
凡是**含有与 Python 自带 DLL 同名文件**的目录一律临时摘掉(系统目录永远保留),
|
||||
并打印摘掉了哪些,两个构建脚本都会用。本机实测一次摘掉了 Git、msys64、
|
||||
`E:\polyos_qemu`、ShadowBot,以及 PySide6 自己的目录。
|
||||
|
||||
> 这类问题的教训:**报错点离原因很远**(psycopg 说"没有 pq 实现",
|
||||
> 实际是 OpenSSL 版本不对)。所以构建后自检必须验到"驱动能真的连上库",
|
||||
> 光验"模块能 import"是不够的。
|
||||
|
||||
## 实现要点
|
||||
|
||||
```
|
||||
packaging/
|
||||
├── launcher.py # 冻结入口(用绝对导入,不用 __main__.py 的相对导入)
|
||||
├── pyinstaller.spec # PyInstaller 配置(选项经环境变量传入)
|
||||
├── build_pyinstaller.py # 驱动 + 守卫 + 自检
|
||||
├── build_nuitka.py # 驱动 + 守卫 + 自检
|
||||
└── _common.py # 图标生成 / 私钥扫描 / PATH 清理 / 自检驱动(两个脚本共用)
|
||||
```
|
||||
|
||||
- **入口用 `launcher.py` 而不是 `__main__.py`**:后者是 `from .app import main` 这样的
|
||||
包内相对导入,被冻结工具当普通脚本编译时会因失去包上下文而失败。
|
||||
- **用源码树而不是 editable 安装**:spec 里 `pathex=src/`、Nuitka 里设 `PYTHONPATH=src/`。
|
||||
冻结工具对 `.pth` 式 editable 安装的解析并不可靠。
|
||||
- **图标是构建时生成的**:`_common.build_app_icon()` 把主题里的盾牌 SVG 渲染成
|
||||
7 个尺寸的 `.ico`(自己拼 ICO 容器 —— Qt 的 ICO 写入只支持单张图,小尺寸会糊),
|
||||
写完立刻回读校验。所以仓库里不需要存任何美术素材。
|
||||
- **关掉 UPX**:UPX 压缩容易触发杀软误报,也可能损坏 Qt DLL。
|
||||
- **裁掉用不到的 Qt 模块**:PySide6 装了整套 Addons(Qt3D/WebEngine/Charts…),
|
||||
spec 里显式排除了一批;Nuitka 侧由 `pyside6` 插件负责,另有 `--nofollow-import-to`。
|
||||
- **构建前清理 PATH**(见上一节):挡住同名 DLL 污染产物。
|
||||
- **Optional 依赖**:PostgreSQL 支持是 `[project.optional-dependencies] postgres`,
|
||||
但 dev 依赖组里也列了 psycopg —— 这样 `uv sync --group dev` 出来的环境打出来的包
|
||||
**自带 PG 支持**;不做 PG 的用户 `uv sync` 也不会被强迫装驱动。
|
||||
|
||||
## 分发提醒
|
||||
|
||||
- 本工具是**厂商侧工具**,含签发能力,只发厂商内部同事;**不要给客户**,也不要放进部署产物。
|
||||
- 打包产物里的界面截图、示例数据都来自 `tools/screenshot.py` 的演示数据,不含真实客户信息。
|
||||
- exe **未做代码签名**,同事首次运行可能遇到 SmartScreen 提示("更多信息 → 仍要运行")。
|
||||
要么让 IT 走内部白名单,要么买代码签名证书后给 exe 签名。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**双击没反应 / 闪退?**
|
||||
用 `--console` 重新打一个,命令行里运行看报错。多数是缺平台插件(自检能提前发现)。
|
||||
|
||||
**杀软报毒?**
|
||||
PyInstaller 单文件的自解压行为常被启发式判成可疑。改用 `--onedir`,或换 Nuitka。
|
||||
|
||||
**Nuitka 报找不到 C 编译器?**
|
||||
装 Visual Studio 的「使用 C++ 的桌面开发」工作负载;或用 `--mingw64`
|
||||
(需 Nuitka 支持的 mingw64 版本 —— msys2 自带的那个通常不被接受)。
|
||||
|
||||
**Nuitka 报 `fatal error C1002` / `LNK1257`(编译器堆空间不足)?**
|
||||
Nuitka 为每个模块生成一个上万行的 C 文件,MSVC 编译最大的那个时会耗尽编译器内部
|
||||
堆空间。本项目的踩坑记录:
|
||||
|
||||
- 不带 `--low-memory` 时崩在 `chongming_license_gui.registry_tab`;
|
||||
- 加上 `--low-memory` 后能过,但**引入 psycopg 后又会崩在 `psycopg.errors`**
|
||||
(该模块定义了几百个异常类,生成 C 文件 2.8 万行,MSVC 单文件上限)。
|
||||
|
||||
这是 MSVC 的编译上限,不是代码问题。对策:
|
||||
|
||||
1. **优先用 PyInstaller 出带 PostgreSQL 支持的包** —— 它不编译 C,没有这个限制,
|
||||
本项目已验证可用;
|
||||
2. 或者给 Nuitka 换编译器:`--mingw64`(Nuitka 会自己下载它支持的
|
||||
WinLibs mingw64,msys2 自带的那份不被接受);
|
||||
3. 只在不需要 PG 时才用默认的 MSVC + `--low-memory`。
|
||||
|
||||
脚本默认已带 `--low-memory`(不带必崩),检测到该错误码还会自动重试一次。
|
||||
|
||||
**Nuitka 提示 "Please install the English language pack for Visual Studio"?**
|
||||
中文版 VS 的报错文本是 GBK,Nuitka 解析不了(只影响日志可读性,不影响构建)。
|
||||
装个英文语言包,或忽略它、直接看 `packaging/build/nuitka-build.log`。
|
||||
|
||||
**Nuitka 提示 "Please install the English language pack for Visual Studio"?**
|
||||
中文版 VS 的报错文本是 GBK,Nuitka 解析不了(只影响日志可读性,不影响构建)。
|
||||
装个英文语言包,或忽略它、直接看 `packaging/build/nuitka-build.log`。
|
||||
|
||||
**体积能不能再小?**
|
||||
`--onedir` 后再自行压缩分发;或继续往 spec 的 `EXCLUDES` 里加确定用不到的模块。
|
||||
单文件模式会把所有东西解压到临时目录,体积换的是分发便利。
|
||||
|
||||
**Nuitka 构建太慢?**
|
||||
时间主要花在两处:一是 C 编译(Nuitka 的缓存目录会保留中间产物,第二次快很多),
|
||||
二是 **LTO 链接**(实测单这一步就 6 分钟以上)。加 `--lto=no` 可显著缩短,
|
||||
代价是产物略大、优化略少。另外 `.py` 文件改动越多,重新编译的文件越多。
|
||||
|
||||
**能不能打包 Linux / macOS 版?**
|
||||
可以,但要在目标系统上构建(冻结产物不跨平台)。`packaging/_common.py` 里的
|
||||
原生平台名已覆盖 `cocoa` / `xcb`,自检会自动按平台跑。注意本工具的硬件指纹
|
||||
来源全是 Linux 侧的,打包目标平台与授权对象设备无关。
|
||||
@@ -0,0 +1,404 @@
|
||||
"""打包脚本共用部分:图标生成、私钥守卫、冻结产物自检驱动。
|
||||
|
||||
两个打包脚本(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)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""用 Nuitka 打包(真编译成 C 再编译成 exe)。
|
||||
|
||||
用法::
|
||||
|
||||
uv run python packaging/build_nuitka.py # 单文件(默认)
|
||||
uv run python packaging/build_nuitka.py --standalone # 目录模式
|
||||
uv run python packaging/build_nuitka.py --console # 带控制台,排查启动问题
|
||||
uv run python packaging/build_nuitka.py --msvc # 指定 MSVC(默认自动挑)
|
||||
uv run python packaging/build_nuitka.py --low-memory # 编译器内存不够时用
|
||||
uv run python packaging/build_nuitka.py --clean
|
||||
uv run python packaging/build_nuitka.py --no-selftest
|
||||
|
||||
产物:``dist/nuitka/``
|
||||
|
||||
与 PyInstaller 的区别见 ``packaging/README.md``:Nuitka 构建慢得多、需要一个
|
||||
C 编译器,换来的是**启动更快、体积更小、更难被反编译**。
|
||||
|
||||
构建前后同样有"私钥守卫 + 产物自检"两道关卡(与 PyInstaller 脚本一致)。
|
||||
"""
|
||||
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
|
||||
|
||||
# 明显用不到的重型模块,让 Nuitka 不要跟进去(Qt 的裁减由 pyside6 插件负责)
|
||||
NOFOLLOW = [
|
||||
"tkinter",
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtWebEngineQuick",
|
||||
"PySide6.Qt3DCore",
|
||||
"PySide6.Qt3DRender",
|
||||
"PySide6.Qt3DExtras",
|
||||
"PySide6.QtCharts",
|
||||
"PySide6.QtDataVisualization",
|
||||
"PySide6.QtQuick",
|
||||
"PySide6.QtQml",
|
||||
"PySide6.QtMultimedia",
|
||||
"PySide6.QtDesigner",
|
||||
"PySide6.QtPdf",
|
||||
]
|
||||
|
||||
# PostgreSQL 驱动(可选功能)。在 Python 3.13+ 上 Nuitka 只能用 MSVC,而 MSVC
|
||||
# 编译 psycopg.errors 生成的 2.8 万行 C 文件时会 C1002(编译器堆空间不足)——
|
||||
# 这是编译器上限,不是代码问题。因此提供"不带 PG"的构建:界面会如实提示
|
||||
# "未安装 PostgreSQL 驱动(可选功能)",其余功能完全不受影响。
|
||||
POSTGRES_MODULES = ["psycopg", "psycopg_binary", "psycopg_c", "psycopg_pool"]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="用 Nuitka 打包 chongming 授权工具")
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--onefile", action="store_true", default=True,
|
||||
help="打成单个 exe(默认)")
|
||||
mode.add_argument("--standalone", action="store_true",
|
||||
help="打成目录(启动更快)")
|
||||
compiler = parser.add_mutually_exclusive_group()
|
||||
compiler.add_argument("--msvc", action="store_true",
|
||||
help="用 MSVC 编译(默认自动挑选编译器)")
|
||||
compiler.add_argument("--mingw64", action="store_true",
|
||||
help="用 MinGW64 编译(默认自动挑选编译器)")
|
||||
parser.add_argument("--jobs", type=int, default=0,
|
||||
help="并行编译任务数(默认交给 Nuitka 决定)")
|
||||
parser.add_argument("--low-memory", action="store_true", default=True,
|
||||
help="降低编译器内存占用(默认开启)")
|
||||
parser.add_argument("--no-low-memory", dest="low_memory", action="store_false",
|
||||
help="关闭低内存模式。默认开启是有原因的:本项目的代码经 Nuitka "
|
||||
"展开后,MSVC 编译最大的那个模块会因堆空间不足报 C1002/LNK1257")
|
||||
parser.add_argument("--lto", choices=("yes", "no"), default=None,
|
||||
help="链接期优化。默认交给 Nuitka(开着,链接很慢);"
|
||||
"--lto=no 可显著缩短构建时间,产物略大")
|
||||
parser.add_argument("--no-postgres", dest="postgres", action="store_false",
|
||||
default=True,
|
||||
help="构建不带 PostgreSQL 支持的版本。Python 3.13+ 上 "
|
||||
"Nuitka 只能用 MSVC,而 MSVC 编译 psycopg.errors 会 C1002;"
|
||||
"此时工具会提示「未安装驱动」,本地台账照常可用")
|
||||
parser.add_argument("--console", action="store_true",
|
||||
help="保留控制台窗口(默认无控制台)")
|
||||
parser.add_argument("--clean", action="store_true",
|
||||
help="构建前清理并删除中间产物")
|
||||
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 build_command(args: argparse.Namespace, *, icon: Path | None, onefile: bool,
|
||||
low_memory: bool, with_postgres: bool = True) -> list[str]:
|
||||
dist_dir = common.DIST / "nuitka"
|
||||
version = common.project_version()
|
||||
command = [
|
||||
sys.executable, "-m", "nuitka",
|
||||
f"--mode={'onefile' if onefile else 'standalone'}",
|
||||
# PySide6 插件负责把 Qt 的 DLL 与插件(platforms/styles/imageformats/
|
||||
# iconengines)收集齐全 —— 少了 imageformats 里的 svg,图标与复选框
|
||||
# 指示器会变空白(构建后的自检会验证这一点)
|
||||
"--enable-plugins=pyside6",
|
||||
# 注意:Nuitka 的值型选项必须写成 --opt=value,空格分隔会报
|
||||
# "requires an argument with '--output-dir='"
|
||||
f"--output-dir={dist_dir}",
|
||||
f"--output-filename={common.APP_NAME}.exe",
|
||||
f"--windows-console-mode={'force' if args.console else 'disable'}",
|
||||
# 不下载 ccache / depends.exe 之类就别想跑通,脚本里没法应答交互提示
|
||||
"--assume-yes-for-downloads",
|
||||
# exe 属性:公司/产品/版本(右键属性里看得到)
|
||||
"--company-name=chongming",
|
||||
f"--product-name={common.APP_NAME}",
|
||||
f"--file-version={version}",
|
||||
f"--product-version={version}",
|
||||
"--file-description=chongming 机器码授权工具(厂商侧)",
|
||||
]
|
||||
if icon:
|
||||
command.append(f"--windows-icon-from-ico={icon}")
|
||||
if args.msvc:
|
||||
command.append("--msvc=latest")
|
||||
elif args.mingw64:
|
||||
command.append("--mingw64")
|
||||
if args.jobs > 0:
|
||||
command.append(f"--jobs={args.jobs}")
|
||||
if args.lto:
|
||||
command.append(f"--lto={args.lto}")
|
||||
if low_memory:
|
||||
# Nuitka 生成的 C 文件极大(单个模块上万行),MSVC 会因堆空间不足报
|
||||
# C1002/LNK1257。这个开关让 Nuitka 少塞内联代码、把模块拆得更细。
|
||||
command.append("--low-memory")
|
||||
if args.clean:
|
||||
command.append("--remove-output")
|
||||
for module in NOFOLLOW:
|
||||
command.append(f"--nofollow-import-to={module}")
|
||||
if not with_postgres:
|
||||
for module in POSTGRES_MODULES:
|
||||
command.append(f"--nofollow-import-to={module}")
|
||||
command.append(str(common.PACKAGING / "launcher.py"))
|
||||
return command
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
common.require("nuitka", "nuitka")
|
||||
|
||||
onefile = not args.standalone
|
||||
dist_dir = common.DIST / "nuitka"
|
||||
log_path = common.PACKAGING / "build" / "nuitka-build.log"
|
||||
|
||||
print("=" * 60)
|
||||
print(f"Nuitka 打包({'单文件' if onefile else '目录模式'})")
|
||||
print("=" * 60)
|
||||
|
||||
# 1) 安全闸门
|
||||
common.guard_no_secrets(allow=args.allow_sensitive_files)
|
||||
|
||||
# 2) 图标
|
||||
icon = common.build_app_icon()
|
||||
|
||||
if args.clean and dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
print(f"已清理 {dist_dir.relative_to(common.ROOT)}")
|
||||
|
||||
# 3) 构建(首次很慢,日志落盘)
|
||||
env = dict(os.environ)
|
||||
# 让 Nuitka 能解析本包:直接给源码树,不依赖 editable 安装
|
||||
env["PYTHONPATH"] = str(common.ROOT / "src") + os.pathsep + env.get("PYTHONPATH", "")
|
||||
# 与 PyInstaller 同理:PATH 里的同名 DLL 会被误收集(见 _common 的说明)
|
||||
env.update(common.build_env_overrides(env))
|
||||
|
||||
low_memory = args.low_memory
|
||||
with_postgres = args.postgres
|
||||
command = build_command(args, icon=icon, onefile=onefile, low_memory=low_memory,
|
||||
with_postgres=with_postgres)
|
||||
print("\n$ " + " ".join(command))
|
||||
print("\n提示:Nuitka 是**真编译**,首次构建约 10 分钟起(后续有缓存会快很多)。")
|
||||
print(f"构建日志:{log_path.relative_to(common.ROOT)}\n")
|
||||
|
||||
started = time.monotonic()
|
||||
code, log_text = common.run_build(command, log_path, env)
|
||||
|
||||
# 兜底:万一还是撞上 MSVC 堆空间不足(例如用了 --no-low-memory),自动降内存重试
|
||||
if code != 0 and not low_memory and common.is_compiler_out_of_memory(log_text):
|
||||
print("检测到编译器堆空间不足(C1002/LNK1257)—— 自动加 --low-memory 重试一次…\n")
|
||||
low_memory = True
|
||||
command = build_command(args, icon=icon, onefile=onefile, low_memory=True)
|
||||
code, log_text = common.run_build(command, log_path, env)
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
if code != 0:
|
||||
print(f"❌ Nuitka 构建失败(退出码 {code})\n")
|
||||
print(common.log_tail(log_text, 30))
|
||||
print(f"\n完整日志:{log_path.relative_to(common.ROOT)}")
|
||||
if common.is_compiler_out_of_memory(log_text):
|
||||
print("失败原因是编译器内存不足。先用 --low-memory(默认已开);"
|
||||
"仍失败就改用 PyInstaller(build_pyinstaller.py),"
|
||||
"或用 --no-postgres 构建不带 PG 的版本。")
|
||||
else:
|
||||
print("常见原因:缺少 C 编译器。Windows 上装 Visual Studio 的「C++ 生成工具」,")
|
||||
print("或用 --mingw64(需 Nuitka 支持的 mingw64 版本,msys2 自带的那个不行)。")
|
||||
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, "Nuitka 产物"):
|
||||
print("\n❌ 产物自检未通过 —— 先别分发,见上方输出")
|
||||
return 1
|
||||
|
||||
common.report_success("Nuitka", artifact, elapsed)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,126 @@
|
||||
"""用 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``,无需美术素材。
|
||||
|
||||
构建后自动跑 ``<exe> --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())
|
||||
@@ -0,0 +1,10 @@
|
||||
"""冻结产物的入口脚本(PyInstaller 与 Nuitka 共用)。
|
||||
|
||||
不用 ``src/chongming_license_gui/__main__.py``:那个文件用的是包内相对导入
|
||||
(``from .app import main``),一旦被当作"普通脚本"编译(冻结工具正是这么做的),
|
||||
相对导入会因为失去包上下文而失败。这里改成绝对导入,冻结与直接运行都成立。
|
||||
"""
|
||||
from chongming_license_gui.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller 打包配置。
|
||||
|
||||
由 ``packaging/build_pyinstaller.py`` 调用 —— 选项通过**环境变量**传进来
|
||||
(spec 文件本身不接受命令行参数,这是 PyInstaller 的既定用法):
|
||||
|
||||
=========================== ==========================================
|
||||
环境变量 作用
|
||||
=========================== ==========================================
|
||||
``CLG_NAME`` 产物名(默认 chongming-license-gui)
|
||||
``CLG_ONEFILE`` ``1`` = 单文件(默认),``0`` = 目录模式
|
||||
``CLG_CONSOLE`` ``1`` = 带控制台窗口(排查启动问题用)
|
||||
``CLG_ICON`` .ico 路径(由 ``_common.build_app_icon()`` 生成)
|
||||
=========================== ==========================================
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(SPECPATH).resolve().parent # noqa: F821 - SPECPATH 由 PyInstaller 注入
|
||||
|
||||
NAME = os.environ.get("CLG_NAME", "chongming-license-gui")
|
||||
ONEFILE = os.environ.get("CLG_ONEFILE", "1") == "1"
|
||||
CONSOLE = os.environ.get("CLG_CONSOLE", "0") == "1"
|
||||
ICON = os.environ.get("CLG_ICON") or None
|
||||
|
||||
# PySide6 装了整套 Addons(Qt3D / WebEngine / Charts…),本工具一个都不用。
|
||||
# PyInstaller 的 PySide6 hook 只打包"被 import 到的" Qt 模块,但显式排除能兜住
|
||||
# 间接依赖,避免几百 MB 的模块被拖进来。
|
||||
# 注:排除不存在的模块是安全的(PyInstaller 只按名字匹配)。
|
||||
EXCLUDES = [
|
||||
# 3D / 图形 / 可视化
|
||||
"PySide6.Qt3DAnimation", "PySide6.Qt3DCore", "PySide6.Qt3DExtras",
|
||||
"PySide6.Qt3DInput", "PySide6.Qt3DLogic", "PySide6.Qt3DRender",
|
||||
"PySide6.QtCharts", "PySide6.QtDataVisualization", "PySide6.QtGraphs",
|
||||
"PySide6.QtGraphsWidgets", "PySide6.QtCanvasPainter",
|
||||
# Web / QML / Quick(体积最大的一批)
|
||||
"PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtWebEngineQuick", "PySide6.QtWebView", "PySide6.QtWebChannel",
|
||||
"PySide6.QtWebSockets", "PySide6.QtHttpServer",
|
||||
"PySide6.QtQml", "PySide6.QtQuick", "PySide6.QtQuick3D",
|
||||
"PySide6.QtQuickControls2", "PySide6.QtQuickWidgets", "PySide6.QtQuickTest",
|
||||
# 多媒体 / 设备 / 传感器
|
||||
"PySide6.QtMultimedia", "PySide6.QtMultimediaWidgets", "PySide6.QtSpatialAudio",
|
||||
"PySide6.QtTextToSpeech", "PySide6.QtBluetooth", "PySide6.QtNfc",
|
||||
"PySide6.QtPositioning", "PySide6.QtLocation", "PySide6.QtSensors",
|
||||
"PySide6.QtSerialPort", "PySide6.QtSerialBus", "PySide6.QtRemoteObjects",
|
||||
"PySide6.QtScxml", "PySide6.QtStateMachine", "PySide6.QtAxContainer",
|
||||
# 文档 / 设计器 / 测试 / 数据库 / 容器
|
||||
"PySide6.QtPdf", "PySide6.QtPdfWidgets", "PySide6.QtDesigner",
|
||||
"PySide6.QtUiTools", "PySide6.QtHelp", "PySide6.QtTest",
|
||||
"PySide6.QtSql", "PySide6.QtDBus", "PySide6.QtConcurrent",
|
||||
"PySide6.QtNetworkAuth", "PySide6.QtXml", "PySide6.QtSvgWidgets",
|
||||
"PySide6.QtOpenGL", "PySide6.QtOpenGLWidgets",
|
||||
# 与本工具无关的标准库大件
|
||||
"tkinter", "pydoc_data",
|
||||
]
|
||||
|
||||
a = Analysis( # noqa: F821
|
||||
[str(ROOT / "packaging" / "launcher.py")],
|
||||
# 用源码树解析本包,而不是依赖 editable 安装(冻结工具对 .pth 式
|
||||
# editable 安装的解析并不可靠)
|
||||
pathex=[str(ROOT / "src")],
|
||||
binaries=[],
|
||||
datas=[], # 无数据文件:图标与样式表都是运行时生成的
|
||||
# PySide6.QtSvg 是显式 import,正常能被扫到;这里再钉一遍 —— 少了它
|
||||
# 图标会全部变空白,而且不报错
|
||||
hiddenimports=["PySide6.QtSvg"],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=EXCLUDES,
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure) # noqa: F821
|
||||
|
||||
if ONEFILE:
|
||||
# 单文件:自解压到临时目录再运行(分发最省事,首次启动稍慢)
|
||||
exe = EXE( # noqa: F821
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name=NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
# UPX 压缩容易触发杀软误报,也会损坏部分 Qt DLL,明确关掉
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=CONSOLE,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=ICON,
|
||||
)
|
||||
else:
|
||||
# 目录模式:启动更快,适合放在固定办公机上长期使用
|
||||
exe = EXE( # noqa: F821
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name=NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=CONSOLE,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=ICON,
|
||||
)
|
||||
coll = COLLECT( # noqa: F821
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name=NAME,
|
||||
)
|
||||
Reference in New Issue
Block a user