370 lines
15 KiB
Python
370 lines
15 KiB
Python
#!/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:<dir>:连同子目录一起压;/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())
|