#!/usr/bin/env python3 """打包编排器:把 svfs_studio / svfs_client 打成独立可执行文件。 两个后端二选一: * **PyInstaller**(推荐,快、Qt 钩子成熟):使用本目录下的 ``*.spec``; * **Nuitka**(真编译,启动快/更难反编译,但构建慢、需要 C 编译器)。 用法(在仓库根目录):: # 环境准备(一次性) uv sync --extra gui uv pip install pyinstaller # 或: uv pip install nuitka # PyInstaller(目录版,推荐分发方式) uv run python packaging/build.py --tool pyinstaller --app both # PyInstaller 单文件版 uv run python packaging/build.py --tool pyinstaller --app both --onefile # Nuitka(首次运行会自动下载 MinGW64,或自备 MSVC) uv run python packaging/build.py --tool nuitka --app both uv run python packaging/build.py --tool nuitka --app client --dry-run # 只看命令 # 已有 .dist 只做瘦身(裁剪 + UPX),不重新编译 uv run python packaging/build.py --postprocess build/nuitka/run_client.dist 产物:: dist/SVFS-Studio/… 或 dist/SVFS-Studio.exe dist/SVFS-Client/… 或 dist/SVFS-Client.exe Nuitka 路线打包完成后会自动对 .dist 做"瘦身":删掉插件客户端用不到的 DevTools/调试资源、无人使用的 .qm 翻译(~420M → ~269M),再对整目录做 CompactOS 透明压缩(~269M → ~119M 磁盘占用)。依据与细节见本文件 ``prune_nuitka_dist`` 上方的注释;压缩不用 UPX 的原因也记在那里。 冻结环境下的资源路径适配见 ``src/svfs_studio/sample.py`` 的 ``repo_root()`` (PyInstaller 在 sys._MEIPASS、Nuitka 在可执行文件同级目录找 resources/)。 """ import argparse import glob import importlib.metadata import os import subprocess import sys import tempfile PACKAGING_DIR = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.dirname(PACKAGING_DIR) SRC = os.path.join(REPO, "src") APPS = { "studio": { "package": "svfs_studio", "exe": "SVFS-Studio", "runner": os.path.join(PACKAGING_DIR, "run_studio.py"), "spec": os.path.join(PACKAGING_DIR, "svfs_studio.spec"), "needs_resources": True, }, "client": { "package": "svfs_client", "exe": "SVFS-Client", "runner": os.path.join(PACKAGING_DIR, "run_client.py"), "spec": os.path.join(PACKAGING_DIR, "svfs_client.spec"), "needs_resources": False, }, } DIST_DIR = os.path.join(REPO, "dist") WORK_DIR = os.path.join(REPO, "build") # ---------------------------------------------------------------------- # Nuitka 产物瘦身(打包完成后对 .dist 目录执行;也可 --postprocess 单独跑) # # 裁剪依据(与 svfs_client.spec 的 PyInstaller 白名单交叉验证过): # # * qtwebengine_devtools_resources*.pak(~89M):DevTools 调试资源,仅开发用; # * *.debug.pak(~5M):Chromium 调试符号 pak; # * PySide6/translations(~57M):代码从未安装 QTranslator,.qm 全是死重, # 只留 Chromium 内置 UI(右键菜单/表单提示)要用的 zh-CN、en-US locale; # * qt6pdf.dll / qt6svg.dll / QtSvg.pyd(~7M):客户端 import 链不含 # QtPdf/QtSvg,PyInstaller 白名单版(实测可用)就没有这几个文件。 # # 压缩只用 Windows CompactOS(NTFS 透明压缩,LZX),对 .dist 整目录生效, # 磁盘占用约 2.5:1,加载/读取时由系统透明解压。**不要换回 UPX**——实测三条 # 死路(2026-09-16,别再试): # 1) Qt 的 DLL 带 GUARD_CF(Control Flow Guard),UPX 必须 --force 才肯压; # 2) qt6webenginecore.dll 压上去就废:LZMA 下 DllMain 初始化失败 # (WinError 1114),换 NRV 加载直接段错误; # 3) 更隐蔽:即便只压其余 qt6*.dll,Chromium 会深挖这些 DLL 的内部结构, # "记住登录"自动进入主窗口 → WebEngine 初始化的路径照样 AV 崩溃 # (Qt6WebEngineCore.dll 内 0xc0000005),而且"只到登录窗"的冒烟测试 # 完全暴露不了——测试必须覆盖自动登录后的主窗口链路。 # CompactOS 只改磁盘占用,文件逻辑大小不变;非 NTFS 卷自动原样,无副作用。 # ---------------------------------------------------------------------- _WEBENGINE_LOCALE_KEEP = {"en-US.pak", "zh-CN.pak"} _CLIENT_UNUSED_DLLS = ("qt6pdf.dll", "qt6svg.dll", os.path.join("PySide6", "QtSvg.pyd")) def prune_nuitka_dist(dist_dir): """按上面的清单裁剪 .dist,返回释放的字节数。 WebEngine 相关项只在产物真带了 WebEngine(存在 qt6webenginecore.dll) 时才删,所以对 studio 产物同样安全(顶多删掉没人用的 .qm)。 """ total = 0 def _remove(path): nonlocal total if os.path.isfile(path): total += os.path.getsize(path) os.remove(path) is_webengine = os.path.isfile(os.path.join(dist_dir, "qt6webenginecore.dll")) for pattern in ("qtwebengine_devtools_resources*.pak", "*.debug.pak"): for path in glob.glob(os.path.join(dist_dir, pattern)): _remove(path) translations = os.path.join(dist_dir, "PySide6", "translations") if os.path.isdir(translations): locales_dir = os.path.join(translations, "qtwebengine_locales") for path in glob.glob(os.path.join(translations, "**", "*"), recursive=True): if not os.path.isfile(path): continue if (is_webengine and os.path.dirname(path) == locales_dir and os.path.basename(path) in _WEBENGINE_LOCALE_KEEP): continue _remove(path) if is_webengine: for name in _CLIENT_UNUSED_DLLS: _remove(os.path.join(dist_dir, name)) print(f"裁剪 .dist:释放 {total / 1048576:.1f} MB") return total def compact_compress_dist(dist_dir): """对整个 .dist 目录做 CompactOS(NTFS 透明压缩,LZX)。 只改磁盘占用,文件的逻辑大小与内容字节完全不变(加载/读取时由系统 透明解压),没有 UPX 那类兼容性风险(UPX 的实测死路见上方注释)。 非 NTFS 卷或 compact.exe 不可用时跳过,保持原样。 """ compact = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "System32", "compact.exe") if not os.path.isfile(compact): print("提示: 找不到 compact.exe,跳过 CompactOS 压缩") return before = _disk_usage(dist_dir) # /s::连同子目录一起压;/f:已压缩的也强制重压(幂等重跑安全)。 # compact 输出是本机 ANSI 代码页(中文系统为 GBK),按 mbcs 解码 proc = subprocess.run([compact, "/c", "/exe:lzx", "/f", "/q", f"/s:{dist_dir}"], capture_output=True, encoding="mbcs", errors="replace") if proc.returncode != 0: detail = (proc.stderr or proc.stdout or "").strip().splitlines() tail = detail[-1] if detail else f"exit {proc.returncode}" print(f"提示: CompactOS 压缩失败(保持原样,不影响功能): {tail}") return after = _disk_usage(dist_dir) print(f"CompactOS 压缩整目录:{before / 1048576:.1f} MB → " f"{after / 1048576:.1f} MB(磁盘占用)") def _disk_usage(path): """目录的真实磁盘占用(NTFS 压缩后按 allocated 计)。 os.path.getsize 对压缩文件返回逻辑大小,会高估,所以走 Win32 的 GetCompressedFileSizeW;取不到的文件退回逻辑大小。""" import ctypes kernel32 = ctypes.windll.kernel32 get_compressed = kernel32.GetCompressedFileSizeW get_compressed.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(ctypes.c_ulong)] get_compressed.restype = ctypes.c_ulong invalid = ctypes.c_ulong(0xFFFFFFFF).value total = 0 for root, _dirs, files in os.walk(path): for name in files: filepath = os.path.join(root, name) high = ctypes.c_ulong(0) size = get_compressed(filepath, ctypes.byref(high)) if size == invalid and kernel32.GetLastError() != 0: try: size = os.path.getsize(filepath) except OSError: continue total += size else: total += (high.value << 32) | size return total def _ascii_safe(path): """Windows 的 MSVC 链接器在含非 ASCII 字符的路径下会直接 LNK1104 (比如仓库放在"新建文件夹"里)。Nuitka 的输出目录必须 ASCII 安全。""" try: path.encode("ascii") return True except UnicodeEncodeError: return False def nuitka_output_dir(explicit=None): if explicit: return explicit default = os.path.join(WORK_DIR, "nuitka") if _ascii_safe(default): return default # 仓库路径不 ASCII:退到临时目录(用户名含中文时再退到盘根) fallback = os.path.join(tempfile.gettempdir(), "nuitka-build") if not _ascii_safe(fallback): fallback = r"C:\nuitka-build" print(f"提示: 输出目录 {default} 含非 ASCII 字符,改用 {fallback}") return fallback def run(cmd, dry_run=False, env=None): printable = " ".join(f'"{c}"' if " " in c else c for c in cmd) print(f"+ {printable}", flush=True) if dry_run: return 0 return subprocess.run(cmd, env=env, cwd=REPO).returncode def build_pyinstaller(app_key, onefile=False): """PyInstaller:配置都在 spec 里,这里只负责调用与输出目录。""" app = APPS[app_key] env = dict(os.environ) if onefile: env["SVFS_ONEFILE"] = "1" cmd = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--distpath", DIST_DIR, "--workpath", os.path.join(WORK_DIR, "pyinstaller", app_key), app["spec"], ] return run(cmd, env=env) def build_nuitka(app_key, dry_run=False, output_dir=None, no_compress=False): """Nuitka standalone:pyside6 插件负责 Qt/QtWebEngine 的资源与运行时。 打包完成后自动做瘦身(prune_nuitka_dist + compact_compress_dist), ``no_compress=True`` 时只裁剪不压缩。 ⚠ 已知问题(2026-09 实测):Nuitka 4.2.1 + PySide6 6.11.2 的产物在运行期 报 "Unable to import Shiboken"——编译后的 shiboken6.Shiboken 无法导入。 最小 PySide6 程序即可复现,属工具链兼容问题,与本仓库代码无关。 解决途径:升级 Nuitka 等待修复,或临时降级 PySide6(< 6.10)再打包。 PyInstaller 路线不受影响,推荐使用。 """ app = APPS[app_key] out_dir = nuitka_output_dir(output_dir) cmd = [ sys.executable, "-m", "nuitka", "--standalone", "--enable-plugin=pyside6", "--lto=yes", "--clang", "--remove-output", "--python-flag=-OO", # WebEngine 的子进程与资源由插件收集;首次编译缺编译器时自动装 MinGW64 "--assume-yes-for-downloads", "--windows-console-mode=disable", "--include-windows-runtime-dlls=no", # Nuitka 的选项一律用 --opt=value 形式(部分版本不接受分开传值) f"--output-dir={out_dir}", f"--output-filename={app['exe']}{'.exe' if os.name == 'nt' else ''}", "--include-package=sqlite_vfs", f"--include-package={app['package']}", "--nofollow-import-to=tkinter", "--nofollow-import-to=unittest", ] icon = os.path.join(REPO, "resources", "ico", "logo.ico") if os.path.exists(icon): cmd.append(f"--windows-icon-from-ico={icon}") if app["needs_resources"]: # 数据文件落在产物目录下,冻结版 repo_root() 从可执行文件旁找 resources/ cmd.append(f"--include-data-dir={os.path.join(REPO, 'resources')}=resources") cmd.append(app["runner"]) code = run(cmd, dry_run=dry_run) if code == 0 and not dry_run: dist_name = os.path.splitext(os.path.basename(app["runner"]))[0] + ".dist" dist_dir = os.path.join(out_dir, dist_name) prune_nuitka_dist(dist_dir) if not no_compress: compact_compress_dist(dist_dir) print(f"Nuitka 产物: {dist_dir}") return code def main(argv=None): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--tool", choices=("pyinstaller", "nuitka"), default=None) parser.add_argument("--app", choices=("studio", "client", "both"), default="both") parser.add_argument("--onefile", action="store_true", help="仅 PyInstaller:打成单文件(启动较慢)") parser.add_argument("--output-dir", default=None, help="仅 Nuitka:产物输出目录(默认 build/nuitka;" "路径含非 ASCII 字符时自动退到临时目录)") parser.add_argument("--postprocess", metavar="DIST_DIR", default=None, help="不打包:对已有的 Nuitka .dist 目录做瘦身" "(裁剪 + UPX)") parser.add_argument("--no-compress", action="store_true", help="跳过 CompactOS 压缩(压缩后运行异常时用它排查)") parser.add_argument("--dry-run", action="store_true", help="只打印将要执行的命令") args = parser.parse_args(argv) if args.postprocess: if not os.path.isdir(args.postprocess): print(f"目录不存在: {args.postprocess}", file=sys.stderr) return 2 prune_nuitka_dist(args.postprocess) if not args.no_compress: compact_compress_dist(args.postprocess) return 0 if not args.tool: parser.error("需要 --tool(pyinstaller/nuitka)," "或用 --postprocess 对已有 .dist 瘦身") if not os.path.isdir(SRC): print(f"找不到 src/:请在仓库根目录运行(当前认定的仓库根: {REPO})", file=sys.stderr) return 2 try: importlib.metadata.version(args.tool) except importlib.metadata.PackageNotFoundError: if not args.dry_run: print(f"未安装 {args.tool}:uv pip install {args.tool}", file=sys.stderr) return 2 keys = ("studio", "client") if args.app == "both" else (args.app,) failed = [] for key in keys: if args.tool == "pyinstaller": code = build_pyinstaller(key, onefile=args.onefile) else: code = build_nuitka(key, dry_run=args.dry_run, output_dir=args.output_dir, no_compress=args.no_compress) if code != 0: failed.append(key) if failed: print(f"打包失败: {', '.join(failed)}", file=sys.stderr) return 1 if not args.dry_run: print(f"完成。产物在 {DIST_DIR}") return 0 if __name__ == "__main__": sys.exit(main())