# -*- mode: python ; coding: utf-8 -*- """PyInstaller 打包配置:SVFS Client(PySide6 + QtWebEngine 插件客户端)。 用法(或直接用 packaging/build.py 编排):: uv sync --extra gui uv pip install pyinstaller uv run pyinstaller --noconfirm packaging/svfs_client.spec # 目录版产物: dist/SVFS-Client/SVFS-Client.exe 单文件版(QtWebEngine 启动前要先解包到临时目录,明显更慢):: SVFS_ONEFILE=1 uv run pyinstaller --noconfirm packaging/svfs_client.spec QtWebEngine 的资源(icudtl.dat、qtwebengine_resources.pak、locale、 QtWebEngineProcess.exe)由 pyinstaller-hooks-contrib 的 PySide6 钩子自动收集, 本 spec 不需要手工列——但要保证 PyInstaller >= 6。 """ import os import re from PyInstaller.utils.hooks import collect_submodules # spec 文件在 /packaging/ 下,仓库根与 src 由此反推 REPO = os.path.dirname(os.path.dirname(os.path.abspath(SPEC))) SRC = os.path.join(REPO, "src") ONEFILE = os.environ.get("SVFS_ONEFILE", "") == "1" ICON = os.path.join(REPO, "resources", "ico", "logo.ico") a = Analysis( [os.path.join(REPO, "packaging", "run_client.py")], pathex=[SRC], binaries=[], datas=[], hiddenimports=[ # QtWebEngine 链条显式列出,确保 WebEngine 钩子必定触发 "PySide6.QtWebEngineWidgets", "PySide6.QtWebEngineCore", "PySide6.QtWebChannel", *collect_submodules("svfs_client"), *collect_submodules("sqlite_vfs"), ], hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=["tkinter"], noarchive=False, ) # ---------------------------------------------------------------------- # 体积裁剪(白名单制):PySide6 的钩子会把 wheel 里**所有** Qt 模块的 # DLL/pyd 整包搬进来(Quick3D 全家、QuickControls2、Pdf、OpenGL、Svg…… # 约 70M 的没人用的东西),黑名单删不干净,这里只留依赖链上的: # # * Qt6WebEngineCore 实测导入(objdump):Core/Gui/Network/Positioning/Qml/Quick/ # WebChannel;WebEngineWidgets 另需 PrintSupport/QuickWidgets/Widgets; # Qt6Quick 另需 Qt6OpenGL 与 QmlMeta/QmlModels —— 这些必须留。 # * pyd 只留 Python 层真实用到的(实测 import 链):QtCore/QtGui/QtWidgets/ # QtNetwork/QtPrintSupport/QtWebChannel/QtWebEngineCore/QtWebEngineWidgets; # 其余绑定(Qml/Quick/QuickWidgets/Positioning/OpenGL)没有 Python 调用者, # 它们的 DLL 由上面的 Qt6 白名单保留。 # * opengl32sw.dll(20M 软件渲染回退)删:Chromium 自带 SwiftShader, # 冻结包深度冒烟(--open 加载页面)通过。 # * DevTools 资源包(85M)、QML 组件目录(29M)、译文、虚拟键盘、qmltooling: # 生产分发用不到(DevTools 代价:插件页面里检查元素无资源,调试用开发环境)。 # 裁不动:Qt6WebEngineCore.dll ~195M(Chromium 本体)与 icudtl.dat 10M。 # ---------------------------------------------------------------------- KEEP_QT6_DLLS = { "Qt6Core", "Qt6Gui", "Qt6Widgets", "Qt6Network", "Qt6Positioning", "Qt6Qml", "Qt6QmlMeta", "Qt6QmlModels", "Qt6QmlWorkerScript", "Qt6Quick", "Qt6OpenGL", "Qt6WebChannel", "Qt6PrintSupport", "Qt6QuickWidgets", "Qt6WebEngineCore", "Qt6WebEngineWidgets", } KEEP_PYDS = {"QtCore", "QtGui", "QtWidgets", "QtNetwork", "QtPrintSupport", "QtWebChannel", "QtWebEngineCore", "QtWebEngineWidgets"} QT_DLL_RE = re.compile(r"PySide6[/\\](Qt6[^/\\]*\.dll)$", re.IGNORECASE) PYD_RE = re.compile(r"PySide6[/\\]([^/\\]*\.pyd)$", re.IGNORECASE) DROP_CLIENT = re.compile( r"PySide6[/\\](?:" r"resources[/\\]qtwebengine_devtools_resources[^/\\]*\.pak" r"|qml[/\\]" r"|translations[/\\]" r"|plugins[/\\]virtualkeyboard[/\\]" r"|plugins[/\\]qmltooling[/\\]" r"|opengl32sw\.dll" r")", re.IGNORECASE, ) def _keep(entry_name): if DROP_CLIENT.search(entry_name): return False match = QT_DLL_RE.search(entry_name) if match: return match.group(1).rsplit(".", 1)[0] in KEEP_QT6_DLLS match = PYD_RE.search(entry_name) if match: return match.group(1).rsplit(".", 1)[0] in KEEP_PYDS return True a.binaries = [entry for entry in a.binaries if _keep(entry[0])] a.datas = [entry for entry in a.datas if _keep(entry[0])] pyz = PYZ(a.pure) exe_kwargs = dict( name="SVFS-Client", console=False, # GUI 程序,不弹控制台 icon=ICON if os.path.exists(ICON) else None, upx=False, ) if ONEFILE: exe = EXE( pyz, a.scripts, a.binaries, a.datas, [], **exe_kwargs, ) else: exe = EXE( pyz, a.scripts, [], exclude_binaries=True, **exe_kwargs, ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=False, name="SVFS-Client", )