"""打出可分发的压缩包。 在仓库根目录运行: python scripts/package.py Windows 会编译界面和桌面程序,并拷贝本地已有的 Python 缓存、adb、ffmpeg 和 WebView2 离线安装包。 macOS、Linux 以后可以在对应函数里补上自己的运行时,其余步骤可以复用。 """ from __future__ import annotations import gzip import json import platform import shutil import subprocess import sys import urllib.request import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DIST = ROOT / "dist" CACHE = DIST / "cache" STAGE_NAME = "RaptDrama-Download-Tools" PYTHON_VERSION = "3.13.15" PYTHON_EMBED_URL = ( f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" ) GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" FFMPEG_URL = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-win32-x64.gz" WEBVIEW_INSTALLER = "MicrosoftEdgeWebView2RuntimeInstallerX64.exe" def log(message: str) -> None: print(message, flush=True) def run(command: list[str], cwd: Path) -> None: log("+ " + " ".join(command)) subprocess.run(command, cwd=cwd, check=True) def download(url: str, dest: Path) -> None: if dest.is_file() and dest.stat().st_size > 0: log(f"已有缓存 {dest.name}") return dest.parent.mkdir(parents=True, exist_ok=True) temporary = dest.with_suffix(dest.suffix + ".part") log(f"下载 {url}") request = urllib.request.Request(url, headers={"User-Agent": "RaptDrama"}) try: with urllib.request.urlopen(request, timeout=120) as response, temporary.open("wb") as handle: total = int(response.headers.get("Content-Length") or 0) done = 0 while True: chunk = response.read(1024 * 1024) if not chunk: break handle.write(chunk) done += len(chunk) if total: log(f" {dest.name} {done * 100 // total}%") except Exception: if temporary.exists(): temporary.unlink() raise temporary.replace(dest) def copy_tree(source: Path, dest: Path, ignore: shutil.IgnorePattern) -> None: if dest.exists(): shutil.rmtree(dest) shutil.copytree(source, dest, ignore=ignore) def build_ui() -> None: npm = "npm.cmd" if sys.platform == "win32" else "npm" run([npm, "run", "build"], ROOT / "ui") if not (DIST / "ui" / "index.html").is_file(): raise SystemExit("界面没有编译到 dist/ui") def build_desktop() -> Path: run(["cargo", "build", "--release"], ROOT / "src-tauri") meta = subprocess.check_output( ["cargo", "metadata", "--format-version", "1", "--no-deps"], cwd=ROOT / "src-tauri", text=True, ) target = Path(json.loads(meta)["target_directory"]) / "release" name = "raptdrama.exe" if sys.platform == "win32" else "raptdrama" exe = target / name if not exe.is_file(): raise SystemExit(f"没有找到桌面程序 {exe}") return exe def bundle_python(stage: Path) -> None: if sys.platform != "win32": raise SystemExit("当前只内置了 Windows 版 Python,其他平台的运行时还没接上") archive = CACHE / f"python-{PYTHON_VERSION}-embed-amd64.zip" download(PYTHON_EMBED_URL, archive) target = stage / "runtime" / "python" target.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(archive) as packed: packed.extractall(target) pth = next(target.glob("python*._pth")) lines = [] saw_site = False for line in pth.read_text(encoding="utf-8").splitlines(): if line.strip() == "#import site": lines.append("import site") saw_site = True else: lines.append(line) if line.strip() == "import site": saw_site = True if not any(item.strip().replace("\\", "/") == "Lib/site-packages" for item in lines): lines.append("Lib/site-packages") if not saw_site: lines.append("import site") pth.write_text("\n".join(lines) + "\n", encoding="utf-8") (target / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True) get_pip = CACHE / "get-pip.py" download(GET_PIP_URL, get_pip) python = target / "python.exe" run([str(python), str(get_pip)], target) run([str(python), "-m", "pip", "install", "-r", str(ROOT / "server" / "requirements.txt")], target) def bundle_adb(stage: Path) -> None: if sys.platform != "win32": raise SystemExit("当前只内置了 Windows 版 adb,其他平台的文件还没放进 vendor") source = ROOT / "vendor" / "platform-tools" dest = stage / "tools" / "platform-tools" if dest.exists(): shutil.rmtree(dest) required = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] missing = [name for name in required if not (source / name).is_file()] if missing: raise SystemExit("vendor/platform-tools 里没有 " + "、".join(missing)) shutil.copytree(source, dest) def bundle_ffmpeg(stage: Path) -> None: if sys.platform != "win32": raise SystemExit("当前只内置了 Windows 版 ffmpeg,其他平台的下载地址还没接上") dest_dir = stage / "tools" / "ffmpeg" dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / "ffmpeg.exe" existing = ROOT / "tools" / "ffmpeg" / "ffmpeg.exe" if existing.is_file(): shutil.copy2(existing, dest) return archive = CACHE / "ffmpeg-win32-x64.gz" download(FFMPEG_URL, archive) with gzip.open(archive, "rb") as packed: dest.write_bytes(packed.read()) def bundle_webview(stage: Path) -> None: if sys.platform != "win32": return source = ROOT / "vendor" / "webview2" / WEBVIEW_INSTALLER if not source.is_file(): raise SystemExit(f"vendor/webview2 里没有 {WEBVIEW_INSTALLER}") dest = stage / "vendor" / "webview2" dest.mkdir(parents=True, exist_ok=True) shutil.copy2(source, dest / source.name) def copy_server(stage: Path) -> None: copy_tree( ROOT / "server", stage / "server", shutil.ignore_patterns(".venv", "__pycache__", "*.pyc", "*.pyo"), ) def hide_server_source(stage: Path) -> None: server = stage / "server" python = stage / "runtime" / "python" / "python.exe" if not python.is_file(): raise SystemExit("还没有内置 Python,不能编译服务端") run( [ str(python), "-m", "compileall", "-b", "-q", "-s", str(server), "-p", "server", str(server), ], server, ) for path in server.rglob("*.py"): path.unlink() for path in list(server.rglob("__pycache__")): if path.is_dir(): shutil.rmtree(path) if any(server.rglob("*.py")): raise SystemExit("服务端源码没有从分发包里去掉") if not any(server.rglob("*.pyc")): raise SystemExit("服务端没有编译出字节码") def make_zip(stage: Path) -> Path: suffix = "windows-x64" if sys.platform == "win32" else platform.system().lower() archive = DIST / f"{STAGE_NAME}-{suffix}.zip" temporary = archive.with_name(archive.stem + ".writing.zip") if temporary.exists(): temporary.unlink() log(f"压缩 {archive}") with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as packed: for path in stage.rglob("*"): if not path.is_file() or path.name.lower() == "readme.md": continue packed.write(path, Path(STAGE_NAME) / path.relative_to(stage)) try: if archive.exists(): archive.unlink() temporary.replace(archive) except PermissionError: held = archive.with_name(archive.stem + "-new.zip") if held.exists(): held.unlink() temporary.replace(held) raise SystemExit(f"{archive.name} 正在被占用,新的压缩包写在 {held}") return archive def main() -> None: if sys.platform != "win32": raise SystemExit("目前只完成了 Windows 打包。脚本按平台拆开了,macOS 和 Linux 可以以后补运行时。") CACHE.mkdir(parents=True, exist_ok=True) build_ui() exe = build_desktop() stage = DIST / STAGE_NAME if stage.exists(): shutil.rmtree(stage) stage.mkdir(parents=True) shutil.copy2(exe, stage / exe.name) copy_server(stage) ui_dest = stage / "dist" / "ui" shutil.copytree(DIST / "ui", ui_dest) bundle_python(stage) hide_server_source(stage) bundle_adb(stage) bundle_ffmpeg(stage) bundle_webview(stage) archive = make_zip(stage) log(f"完成 {archive} ({archive.stat().st_size / 1024 / 1024:.1f} MB)") if __name__ == "__main__": main()