scripts/build_portable.py
Rama main · réplica de lectura del repositorio original.
"""Build a Windows portable DiceFrame package with bundled Python runtime."""
from __future__ import annotations
import argparse
import hashlib
import hmac
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
try:
from . import build_launcher, build_release
except ImportError: # Direct execution: python scripts/build_portable.py
import build_launcher
import build_release
ROOT = Path(__file__).resolve().parents[1]
DIST_DIR = ROOT / "dist"
BUILD_ROOT = DIST_DIR / "_portable_build"
CACHE_DIR = DIST_DIR / "_portable_cache"
DEFAULT_PYTHON_VERSION = "3.11.9"
DEFAULT_PYTHON_SHA256 = "009d6bf7e3b2ddca3d784fa09f90fe54336d5b60f0e0f305c37f400bf83cfd3b"
PIP_BOOTSTRAP_URL = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl"
PIP_BOOTSTRAP_SHA256 = "382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab"
PORTABLE_REQUIREMENTS = ROOT / "requirements-portable.lock"
# zip 内的顶层目录名。已安装的旧版本用自带的更新器把包解压到
# <安装目录>/data/_updater/extract-v<版本>/<顶层目录>/…,Windows 默认 260 字符上限,
# 顶层目录每短一个字符,旧版本能直接在应用内升级的安装目录就能长一个字符。
# 旧更新器(2.0.0 起)只要求恰好一个顶层目录、不认名字;更新器按 zip 文件名挑包,
# 文件名仍带版本号(见 main 里的 package_name)。
PACKAGE_DIR_NAME = "DiceFrame"
# zip 内最长成员路径(含顶层目录)的上限。旧更新器解压时还要在前面加上
# "<安装目录>\data\_updater\extract-v<版本>\";120 让常见的「下载目录里右键全部解压」
# 嵌套安装(约 100 字符)也能在 260 以内。超了就让打包失败,免得新依赖悄悄带进深路径。
MAX_ARCHIVE_PATH = 120
FORBIDDEN_PATTERNS = [
re.compile(r"(^|/)data/"),
re.compile(r"(^|/)\.env$"),
re.compile(r"(^|/)\.env\.(?!example$)"),
re.compile(r"(^|/)\.git/"),
re.compile(r"(^|/)\.codex/"),
re.compile(r"(^|/)\.claude/"),
re.compile(r"(^|/)node_modules/"),
re.compile(r"(^|/)tests/"),
re.compile(r"(^|/)frontend-v2/tests/"),
re.compile(r"(^|/)frontend-v2/e2e/"),
re.compile(r"(^|/)test-results/"),
re.compile(r"(^|/)playwright-report/"),
re.compile(r"\.(?:log|pyc|pyo|sqlite|db)$"),
]
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_sha256(path: Path, expected_sha256: str) -> None:
normalized = expected_sha256.strip().lower()
if not re.fullmatch(r"[0-9a-f]{64}", normalized):
raise RuntimeError(f"Missing or invalid SHA-256 for {path.name}")
actual = file_sha256(path)
if not hmac.compare_digest(actual, normalized):
raise RuntimeError(
f"SHA-256 mismatch for {path.name}: expected {normalized}, got {actual}"
)
def download(url: str, target: Path, expected_sha256: str) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() and target.stat().st_size > 0:
verify_sha256(target, expected_sha256)
return target
print(f"Downloading {url}", flush=True)
temporary = target.with_suffix(target.suffix + ".download")
temporary.unlink(missing_ok=True)
try:
urllib.request.urlretrieve(url, temporary)
verify_sha256(temporary, expected_sha256)
temporary.replace(target)
finally:
temporary.unlink(missing_ok=True)
return target
def run(cmd: list[str], cwd: Path) -> None:
print("> " + " ".join(str(part) for part in cmd), flush=True)
subprocess.run(cmd, cwd=str(cwd), check=True)
def remove_generated_tree(path: Path) -> None:
"""Remove a build-only directory, retrying Windows directory-not-empty races."""
for attempt in range(5):
if not path.exists():
return
shutil.rmtree(path, ignore_errors=True)
if not path.exists():
return
time.sleep(0.2 * (attempt + 1))
shutil.rmtree(path)
def python_embed_url(version: str) -> str:
return f"https://www.python.org/ftp/python/{version}/python-{version}-embed-amd64.zip"
def install_python_runtime(
runtime_dir: Path,
version: str,
python_url: str,
python_sha256: str,
pip_url: str,
pip_sha256: str,
) -> None:
if runtime_dir.exists():
remove_generated_tree(runtime_dir)
runtime_dir.mkdir(parents=True)
archive = download(python_url, CACHE_DIR / Path(python_url).name, python_sha256)
with zipfile.ZipFile(archive) as zf:
zf.extractall(runtime_dir)
patch_pth(runtime_dir)
pip_wheel = download(pip_url, CACHE_DIR / Path(pip_url).name, pip_sha256)
python_exe = runtime_dir / "python.exe"
run(
[
str(python_exe),
"-c",
(
"import sys; "
"sys.path.insert(0, sys.argv.pop(1)); "
"from pip._internal.cli.main import main; "
"raise SystemExit(main())"
),
str(pip_wheel),
"install",
"--no-cache-dir",
"--no-warn-script-location",
"--require-hashes",
"-r",
str(PORTABLE_REQUIREMENTS),
],
runtime_dir,
)
run(
[
str(python_exe),
"-c",
"import aiohttp, cryptography, edge_tts, PIL",
],
runtime_dir,
)
cleanup_runtime(runtime_dir)
def patch_pth(runtime_dir: Path) -> None:
pth_files = sorted(runtime_dir.glob("python*._pth"))
if not pth_files:
raise RuntimeError("Cannot find embedded Python ._pth file")
pth = pth_files[0]
lines = pth.read_text(encoding="utf-8").splitlines()
out: list[str] = []
has_site_packages = False
has_import_site = False
for line in lines:
stripped = line.strip()
if stripped == "Lib/site-packages":
has_site_packages = True
if stripped == "#import site":
out.append("import site")
has_import_site = True
continue
if stripped == "import site":
has_import_site = True
out.append(line)
if not has_site_packages:
out.insert(max(len(out) - 1, 0), "Lib/site-packages")
if not has_import_site:
out.append("import site")
pth.write_text("\n".join(out) + "\n", encoding="utf-8")
# 嵌入式 Python 忽略 PYTHONPATH(_pth 固定 sys.path),插件子进程靠 PYTHONPATH
# 找主程序 src/ 与插件目录会失败。写 sitecustomize 从环境变量补路径:
# host.py 给插件进程传 DICEFRAME_APP_ROOT(主程序根)与 DICEFRAME_PLUGIN_DIR(插件目录)。
site_packages = runtime_dir / "Lib" / "site-packages"
site_packages.mkdir(parents=True, exist_ok=True)
(site_packages / "sitecustomize.py").write_text(
"import os, sys\n"
"app = os.environ.get(\"DICEFRAME_APP_ROOT\", \"\")\n"
"plug = os.environ.get(\"DICEFRAME_PLUGIN_DIR\", \"\")\n"
"for p in (app, plug):\n"
" if p and p not in sys.path:\n"
" sys.path.insert(0, p)\n",
encoding="utf-8",
)
def cleanup_runtime(runtime_dir: Path) -> None:
for pattern in ("**/__pycache__", "**/*.pyc", "**/*.pyo"):
for path in runtime_dir.glob(pattern):
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
site_packages = runtime_dir / "Lib" / "site-packages"
if site_packages.exists():
removable_patterns = (
"*/tests",
"*/test",
# 给编码助手看的技能文档(如 pydantic_ai/.agents/skills/…),运行时不读。
# 它们是包里路径最深的文件:嵌套解压的安装目录下,旧版应用内更新解压到
# data/_updater/extract-*/… 时会超过 Windows 260 字符而失败。
"*/.agents",
"pip",
"pip-*.dist-info",
"setuptools",
"setuptools-*.dist-info",
"wheel",
"wheel-*.dist-info",
"packaging",
"packaging-*.dist-info",
)
for pattern in removable_patterns:
for path in site_packages.glob(pattern):
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
scripts_dir = runtime_dir / "Scripts"
if scripts_dir.exists():
shutil.rmtree(scripts_dir, ignore_errors=True)
def build_windows_launcher(package_dir: Path) -> None:
build_launcher.build_launcher(
package_dir / "DiceFrame.exe",
BUILD_ROOT / "DiceFrame.ico",
)
stage_webview2(build_launcher.ensure_webview2_sdk(), package_dir / "app")
def stage_webview2(sdk_dir: Path, app_dir: Path) -> None:
"""Ship the WebView2 SDK with the version payload (app/launcher/webview2/).
启动器只是一个 exe,应用内更新只替换它本身;把 SDK 放进 app/,旧版更新器、新版更新器
和覆盖升级都会整体搬运它,每个版本都带着与自己启动器匹配的 SDK。
"""
target = app_dir / "launcher" / "webview2"
target.mkdir(parents=True, exist_ok=True)
for name in build_launcher.WEBVIEW2_SDK_FILES.values():
shutil.copy2(sdk_dir / name, target / name)
def validate_zip(output_zip: Path) -> None:
with zipfile.ZipFile(output_zip) as zf:
names = zf.namelist()
infos = zf.infolist()
bad = [name for name in names if any(pattern.search(name.replace("\\", "/")) for pattern in FORBIDDEN_PATTERNS)]
if bad:
raise RuntimeError("Portable zip contains forbidden paths:\n" + "\n".join(bad[:20]))
longest = max(names, key=len, default="")
if len(longest) > MAX_ARCHIVE_PATH:
raise RuntimeError(
f"Portable zip path is too long for older in-app updaters "
f"({len(longest)} > {MAX_ARCHIVE_PATH}): {longest}"
)
required = [
"/DiceFrame.exe",
"/python/python.exe",
"/app/web_server.py",
"/app/static-v2/index.html",
"/app/launcher/webview2/Microsoft.Web.WebView2.Core.dll",
"/app/launcher/webview2/Microsoft.Web.WebView2.WinForms.dll",
"/app/launcher/webview2/WebView2Loader.dll",
"/app/launcher/webview2/LICENSE.txt",
]
for suffix in required:
if not any(name.endswith(suffix) for name in names):
raise RuntimeError(f"Portable zip is missing {suffix}")
for relative in build_release.REQUIRED_RUNTIME_SOURCE_FILES:
if not any(name.endswith("/app/" + relative) for name in names):
raise RuntimeError(f"Portable zip is missing app/{relative}")
if any(name.endswith("/Start DiceFrame.bat") for name in names):
raise RuntimeError("Portable zip should not contain Start DiceFrame.bat")
if any(name.lower().endswith(".bat") for name in names):
raise RuntimeError("Portable zip should not contain batch launchers")
if not any("/app/static-v2/assets/" in name and name.endswith(".js") for name in names):
raise RuntimeError("Portable zip is missing built frontend assets")
if any("/app/frontend-v2/" in name for name in names):
raise RuntimeError("Portable zip should not contain frontend source files")
build_release.validate_avatar_payload(infos, require_source=False)
build_release.validate_background_payload(infos, require_source=False)
required_runtime_packages = {
"aiohttp": "/python/Lib/site-packages/aiohttp/",
"cryptography": "/python/Lib/site-packages/cryptography/",
"edge-tts": "/python/Lib/site-packages/edge_tts/",
"Pillow": "/python/Lib/site-packages/PIL/",
}
for package, marker in required_runtime_packages.items():
if not any(marker in name for name in names):
raise RuntimeError(f"Portable zip is missing {package}")
def make_zip(package_dir: Path, output_zip: Path) -> Path:
output_zip.parent.mkdir(parents=True, exist_ok=True)
if output_zip.exists():
try:
output_zip.unlink()
except PermissionError:
output_zip = build_release.available_zip_name(output_zip)
shutil.make_archive(str(output_zip.with_suffix("")), "zip", root_dir=package_dir.parent, base_dir=package_dir.name)
validate_zip(output_zip)
return output_zip
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build a Windows portable DiceFrame package.")
parser.add_argument("--version", default=build_release.app_version(), help="Release version, default: src/version.py")
parser.add_argument("--output-dir", type=Path, default=DIST_DIR, help="Directory for generated zip")
parser.add_argument("--python-version", default=DEFAULT_PYTHON_VERSION, help="Embedded Python version")
parser.add_argument("--python-url", default="", help="Override embedded Python zip URL")
parser.add_argument("--python-sha256", default=DEFAULT_PYTHON_SHA256, help="Expected embedded Python zip SHA-256")
parser.add_argument("--pip-url", default=PIP_BOOTSTRAP_URL, help="Override bootstrap pip wheel URL")
parser.add_argument("--pip-sha256", default=PIP_BOOTSTRAP_SHA256, help="Expected bootstrap pip wheel SHA-256")
parser.add_argument("--allow-dirty", action="store_true", help="Allow packaging with uncommitted git changes")
return parser.parse_args()
def main() -> int:
args = parse_args()
if build_release.git_dirty() and not args.allow_dirty:
print(
"Working tree has uncommitted changes. Commit first, or rerun with --allow-dirty for a local test package.",
file=sys.stderr,
)
return 2
version = args.version.lstrip("v")
package_name = f"DiceFrame-v{version}-windows-portable"
package_dir = BUILD_ROOT / PACKAGE_DIR_NAME
app_dir = package_dir / "app"
runtime_dir = package_dir / "python"
output_zip = args.output_dir.resolve() / f"{package_name}.zip"
if BUILD_ROOT.exists():
remove_generated_tree(BUILD_ROOT)
build_release.prepare_package_tree(app_dir, include_cloudflared=False)
(app_dir / "web_ui.bat").unlink(missing_ok=True)
build_release.build_frontend(app_dir)
remove_generated_tree(app_dir / "frontend-v2")
install_python_runtime(
runtime_dir,
args.python_version,
args.python_url or python_embed_url(args.python_version),
args.python_sha256,
args.pip_url,
args.pip_sha256,
)
build_windows_launcher(package_dir)
output_zip = make_zip(package_dir, output_zip)
print(f"\nPortable package created: {output_zip}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as exc:
raise SystemExit(exc.returncode)