Files
chakcy ae9fc8dbe4
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
chore: 三包一起分发、桌面应用打包脚本与 CI,README 对齐实现
* 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)。
2026-09-16 17:15:55 +08:00

370 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())