chore: 三包一起分发、桌面应用打包脚本与 CI,README 对齐实现
tests / ubuntu-latest / py3.13 (push) Waiting to run
tests / windows-latest / py3.13 (push) Waiting to run
tests / ubuntu-latest / py3.13 (pull_request) Has been cancelled
tests / windows-latest / py3.13 (pull_request) Has been cancelled

* pyproject:wheel 打三个包(sqlite_vfs + svfs_studio + svfs_client)——两个桌面
  应用的 console script 都声明在同一个发行版里,只打核心包会让 pip 装完
  `svfs-client` 命令存在而模块缺失(跑起来是 ImportError);extras 拆成
  client(完整 PySide6,WebEngine 必需)/ studio(PySide6-Essentials 够用)/
  gui(两者合集,保留旧写法)。实测重建 wheel 三个包齐全、纯标准库层在无
  PySide6 的环境里可导入;
* packaging/:PyInstaller spec(白名单裁剪 Qt 体积,DevTools/QML/opengl32sw 的
  裁剪依据都记在注释里)与 build.py 编排;Nuitka 路线的已知问题(编译后
  Shiboken 无法导入)写在脚本注释里,推荐走 PyInstaller;
* .github/workflows/tests.yml:windows + ubuntu 双跑。文件头注明了两个尚未在真
  runner 上验证过的点——依赖索引(pyproject 默认指清华镜像,CI 里用
  UV_DEFAULT_INDEX 覆盖回 PyPI)与 Linux 上 Qt 需要的系统库;
* README:按实现重新对齐——登录与认证(凭据存储、会话失效反馈、离线宽限)、
  可靠性与诊断(客户端自更新、网络超时/重试/代理/CA、日志落盘)、plugin.json
  的 net_hosts、数据目录(credentials.dat / logs)、桥 API、安装方式与 extras。

uv.lock 同步(dev 组与 extras)。
This commit is contained in:
2026-09-16 17:15:55 +08:00
parent f62704ca0e
commit ae9fc8dbe4
10 changed files with 1815 additions and 52 deletions
+134
View File
@@ -0,0 +1,134 @@
# 打包 svfs-studio / svfs-client 为独立可执行文件
把两个 PySide6 应用打成"发给谁都能双击运行"的形态。支持两个后端:
**PyInstaller**(推荐:构建快、Qt/QtWebEngine 钩子成熟)和
**Nuitka**(真编译:启动更快、更难反编译,代价是构建慢 + 需要 C 编译器)。
## 环境准备(一次性)
```bash
uv sync --extra gui # PySide6(完整版,含 WebEngine)
uv pip install pyinstaller # PyInstaller 路线
# 或
uv pip install nuitka # Nuitka 路线(还需 C 编译器,见下文)
```
## PyInstaller(推荐)
```bash
# 目录版(推荐分发方式:启动快;整个目录一起拷贝分发)
uv run python packaging/build.py --tool pyinstaller --app both
# 只打一个
uv run python packaging/build.py --tool pyinstaller --app client
uv run python packaging/build.py --tool pyinstaller --app studio
# 单文件版(SVFS_ONEFILE=1 传给 spec;QtWebEngine 启动前要先解包,明显更慢)
uv run python packaging/build.py --tool pyinstaller --app both --onefile
```
产物:
```text
dist/SVFS-Studio/SVFS-Studio.exe
dist/SVFS-Client/SVFS-Client.exe # --onefile 时是 dist/SVFS-Client.exe
```
要点:
* 配置在 `svfs_studio.spec` / `svfs_client.spec`(数据文件、隐藏导入都在那里声明);
* QtWebEngine 的运行时(`QtWebEngineProcess.exe`、icudtl.dat、资源包、locale)
由 pyinstaller-hooks-contrib 的 PySide6 钩子自动收集,**请保持 PyInstaller ≥ 6**;
* 入口不是 `src/*/__main__.py`(有相对导入),而是本目录的
`run_studio.py` / `run_client.py`(绝对导入);
* `svfs_studio` 的图标资源随包打进 `resources/`,
`sample.repo_root()` 已适配冻结环境(PyInstaller 找 `sys._MEIPASS`,
Nuitka 找可执行文件同级目录)。
## Nuitka
```bash
# 先看看会执行什么命令
uv run python packaging/build.py --tool nuitka --app client --dry-run
# 正式构建(首次缺 C 编译器时会自动下载 MinGW64;也可自备 MSVC)
uv run python packaging/build.py --tool nuitka --app both
```
产物在 `<输出目录>/run_<app>.dist/` 下(默认输出目录 `build/nuitka`;
**路径含非 ASCII 字符时自动退到系统临时目录**——Windows 的 MSVC 链接器
在中文路径下会 LNK1104)。
要点:
* `--enable-plugin=pyside6` 负责 Qt 全家桶(含 WebEngine 子进程与资源);
* standalone 模式 + `--windows-console-mode=disable`(GUI 程序不弹控制台);
* 构建慢(整包编译,几分钟到十几分钟),但产物启动比 PyInstaller 快、
没有解包临时目录的问题。
**⚠ 已知问题(2026-09 实测)**:Nuitka 4.2.1 + PySide6 6.11.2 的产物运行时报
`Unable to import Shiboken`(编译后的 `shiboken6.Shiboken` 无法导入)。
用最小 PySide6 程序即可复现,属工具链兼容问题,与本仓库代码无关;
`--include-package=shiboken6`、`--nofollow-import-to` + 数据目录两种绕法均无效。
解决途径:等 Nuitka 发版修复,或临时降级 PySide6(`uv pip install "PySide6<6.10"`)
后再构建。**PyInstaller 路线不受影响,当前请优先使用。**
## 体积裁剪
两个 spec 在 Analysis 之后按**白名单**过滤 `a.binaries` / `a.datas`
(PySide6 的钩子会把 wheel 里所有 Qt 模块的 DLL/pyd 整包搬进来,黑名单删不干净)。
实测效果(PyInstaller 6.22 + PySide6 6.11,Windows x64):
| 应用 | 原始 | 目录版(白名单) | 单文件版(LZMA) |
|---|---|---|---|
| SVFS-Studio | 116 MB | **66 MB** | **29 MB** |
| SVFS-Client | 559 MB | **300 MB** | **126 MB** |
Client 的白名单来自 objdump 实测的导入表(不是猜的):
* `Qt6WebEngineCore` 链 Core/Gui/Network/Positioning/Qml/Quick/WebChannel,
`WebEngineWidgets` 另需 PrintSupport/QuickWidgets/Widgets,`Qt6Quick` 另需
Qt6OpenGL 与 QmlMeta/QmlModels —— 这些 DLL 必须留;
* pyd 只留 Python 层实测 import 链用到的 8 个绑定(QtCore/QtGui/QtWidgets/
QtNetwork/QtPrintSupport/QtWebChannel/QtWebEngineCore/QtWebEngineWidgets),
注意 pyd 的导入链比 DLL 的依赖多两个(QtNetwork/QtPrintSupport);
* 删掉的:Quick3D 全家、QuickControls2 样式、Pdf、ShaderTools、Svg、
opengl32sw.dll(20M,Chromium 自带 SwiftShader 兜底)、DevTools 资源包(85M)、
QML 组件目录(29M)、Qt 译文、虚拟键盘、qmltooling。
裁不动的:`Qt6WebEngineCore.dll` ~195M(Chromium 本体)+ `icudtl.dat` 10M。
已知取舍:
* DevTools 资源包删了 → 插件页面里 F12 检查元素无资源,调试请用开发环境;
* opengl32sw 删了 → 极端无 GPU 环境若渲染异常,把它加回 spec 的保留例外重打;
* Qt 译文删了 → Qt 内建对话框退回英文,界面自有文案不受影响。
**UPX 实测无效**:对 Qt DLL 用 `--best` 只把 Client 目录版从 300M 压到 290M
(pyd 全部压缩失败),收益 3%,还要承担杀软误报——不推荐。
想要更小就用单文件版(上表第三列),代价是每次启动先解包到临时目录
(Client 单文件启动明显慢,QtWebEngine 尤甚)。
## 冒烟验证
```bash
# client:--help 在进入 GUI 之前返回(退出码 0 即装配成功)
./dist/SVFS-Client/SVFS-Client.exe --help
# client:真实加载一个插件页面(--open 启动后直开指定插件;
# 页面加载后 web/ 下会出现 Chromium 的 Local Storage/GPUCache 等痕迹)
./dist/SVFS-Client/SVFS-Client.exe --data <已装插件的数据目录> --open <插件id>
# studio:offscreen 截图模式,跑完自动退出并在目录里留下每页一张 PNG
./dist/SVFS-Studio/SVFS-Studio.exe --shots ./smoke-shots
```
## 常见问题
* **杀软误报**:PyInstaller 单文件版常见,目录版较少;Nuitka 编译产物基本不误报。
* **体积**:QtWebEngine 应用的目录版约 150~250 MB,属正常(Chromium 运行时)。
* **数据目录**:两个应用的用户数据都写在
`%LOCALAPPDATA%/<应用名>`(client)与 QSettings 里(studio 的窗口几何),
与安装位置无关,覆盖更新不需要迁移。
* **离线构建**:`--assume-yes-for-downloads` 会在缺编译器时下载 MinGW64;
全离线环境请预装 MSVC Build Tools 或自行提供 MinGW64。
+369
View File
@@ -0,0 +1,369 @@
#!/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())
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""SVFS Client 的打包入口(PyInstaller / Nuitka 共用)。
不能用 ``src/svfs_client/__main__.py`` 直接当主脚本——里面有相对导入
(``from .app import main``),作为顶层脚本运行会 ImportError。
冻结工具都从这里的绝对导入起步。
"""
import sys
from svfs_client.__main__ import main
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""SVFS Studio 的打包入口(PyInstaller / Nuitka 共用)。
不能用 ``src/svfs_studio/__main__.py`` 直接当主脚本——里面有相对导入
(``from .app import main``),作为顶层脚本运行会 ImportError。
冻结工具都从这里的绝对导入起步。
"""
import sys
from svfs_studio.__main__ import main
if __name__ == "__main__":
sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
# -*- 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 文件在 <repo>/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",
)
+126
View File
@@ -0,0 +1,126 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller 打包配置:SVFS Studio(PySide6 桌面工具,无 WebEngine)。
用法(或直接用 packaging/build.py 编排)::
uv sync --extra gui
uv pip install pyinstaller
uv run pyinstaller --noconfirm packaging/svfs_studio.spec
# 目录版产物: dist/SVFS-Studio/SVFS-Studio.exe
单文件版(启动稍慢,Qt 会先解包到临时目录)::
SVFS_ONEFILE=1 uv run pyinstaller --noconfirm packaging/svfs_studio.spec
"""
import os
import re
from PyInstaller.utils.hooks import collect_submodules
# spec 文件在 <repo>/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_studio.py")],
pathex=[SRC],
binaries=[],
datas=[
# app_icon() / repo_root() 在冻结环境下从包内找 resources/(见 sample.py)
(os.path.join(REPO, "resources", "ico"), "resources/ico"),
(os.path.join(REPO, "resources", "images"), "resources/images"),
],
hiddenimports=[
# 界面里通过 getattr(build_<key>) 取视图工厂,收集整个包最稳妥
*collect_submodules("svfs_studio"),
*collect_submodules("sqlite_vfs"),
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["tkinter"],
noarchive=False,
)
# ----------------------------------------------------------------------
# 体积裁剪(白名单制):PySide6 的钩子会把 wheel 里**所有** Qt 模块的
# DLL/pyd 整包搬进来,而本应用只用 QtCore/QtGui/QtWidgets:
# * 未使用的 Qt 模块 DLL(Qml/Quick/Quick3D/Pdf/Network/OpenGL/Svg/
# VirtualKeyboard/ShaderTools…,约 25M)
# * 未使用的 python 绑定 pyd
# * opengl32sw.dll(20M 软件渲染回退;Widgets 走光栅绘制用不到,
# 无显卡的机器出问题就把它加回 KEEP 外的例外)
# * Qt 自带译文(*.qm,6.7M;界面文案全部硬编码中文)
# * 依赖被删模块的插件(networkinformation/tls/qsvg)
# ----------------------------------------------------------------------
KEEP_QT6_DLLS = {"Qt6Core", "Qt6Gui", "Qt6Widgets"}
KEEP_PYDS = {"QtCore", "QtGui", "QtWidgets"}
QT_DLL_RE = re.compile(r"PySide6[/\\](Qt6[^/\\]*\.dll)$", re.IGNORECASE)
PYD_RE = re.compile(r"PySide6[/\\]([^/\\]*\.pyd)$", re.IGNORECASE)
DROP_STUDIO = re.compile(
r"PySide6[/\\](?:"
r"opengl32sw\.dll"
r"|d3dcompiler_47\.dll"
r"|translations[/\\]"
r"|plugins[/\\](?:networkinformation|tls)[/\\]"
r"|plugins[/\\]imageformats[/\\]qsvg\.dll"
r"|plugins[/\\]iconengines[/\\]qsvgicon\.dll"
r")",
re.IGNORECASE,
)
def _keep(entry_name):
if DROP_STUDIO.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-Studio",
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-Studio",
)