modify: 台账更改插件

This commit is contained in:
2026-09-22 16:08:12 +08:00
parent f468845f67
commit e1c02a9b80
37 changed files with 6377 additions and 380 deletions
+217
View File
@@ -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 侧的,打包目标平台与授权对象设备无关。
+404
View File
@@ -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)
+226
View File
@@ -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())
+126
View File
@@ -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())
+10
View File
@@ -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()
+130
View File
@@ -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,
)