"""Windows native build: python build_windows.py [--iscc PATH]. Produces one offline installer.""" import argparse import hashlib import json import os import shutil import subprocess import sys import urllib.request import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent def download(url, target): if not isinstance(url, str) or not url.startswith( "https://github.com/adryfish/fingerprint-chromium/releases/download/" ): raise ValueError( "只允许用户确认的 fingerprint-chromium 官方项目 GitHub release 下载地址" ) target = Path(target) if target.is_file(): return temp = target.with_suffix(".part") try: with ( urllib.request.urlopen(url, timeout=120) as response, # noqa: S310 -- HTTPS upstream project allow-list above temp.open("wb") as stream, ): shutil.copyfileobj(response, stream) temp.replace(target) except (OSError, ValueError): temp.unlink(missing_ok=True) raise def sha256(path): with Path(path).open("rb") as stream: return hashlib.file_digest(stream, "sha256").hexdigest() def extract_browser(archive, target, prefix): target = Path(target).resolve() target.mkdir(parents=True, exist_ok=True) if ( not prefix or "/" in prefix or "\\" in prefix or ":" in prefix or prefix in (".", "..") ): raise ValueError("浏览器压缩包根目录无效") with zipfile.ZipFile(archive) as zipped: for item in zipped.infolist(): # ZipInfo normalizes backslashes on Windows; validate the original wire name too. original = item.orig_filename if ( not original.startswith(prefix + "/") or "\\" in original or "\x00" in original or ":" in original or (item.external_attr >> 16) & 0o170000 == 0o120000 ): raise RuntimeError("浏览器压缩包包含非法路径或链接") path = (target / item.filename[len(prefix) + 1 :]).resolve() if not path.is_relative_to(target): raise RuntimeError("浏览器压缩包包含路径穿越") if item.is_dir(): path.mkdir(parents=True, exist_ok=True) else: path.parent.mkdir(parents=True, exist_ok=True) with zipped.open(item) as source, path.open("wb") as destination: shutil.copyfileobj(source, destination) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--iscc", type=Path, help="Inno Setup 6 ISCC.exe 的路径") args = parser.parse_args() if sys.platform != "win32" or sys.maxsize <= 2**32: raise SystemExit("必须在 Windows x64 使用 Python 3.12+ 原生构建") if sys.version_info < (3, 12): raise SystemExit("构建要求 Python 3.12+;最终用户不需要 Python") os.chdir(ROOT) cache = ROOT / ".build-cache" cache.mkdir(exist_ok=True) lock = ROOT / "packaging/fingerprint-browser-lock.json" try: browser = json.loads(lock.read_text(encoding="utf-8")) except ValueError as exc: raise RuntimeError("浏览器构建锁损坏") from exc archive = cache / "fingerprint-chromium-win64.zip" download(browser["url"], archive) if sha256(archive) != browser["sha256"]: raise RuntimeError("指纹浏览器下载缓存校验失败,请清理对应 ZIP 后重试") for name, entry, windowed in [ ("DouyinAccounts", "accounts_app.py", True), ("DouyinCLI", "accounts_cli.py", False), ]: command = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--name", name, "--paths", "src", "--collect-all", "patchright", "--distpath", "dist", "--workpath", ".build-cache/pyinstaller", "--specpath", ".build-cache", "src/" + entry, ] if windowed: command.append("--windowed") subprocess.run(command, check=True) app = ROOT / "dist/DouyinAccounts" bundled_browser = app / "fingerprint-browser" extract_browser(archive, bundled_browser, browser["archive_root"]) shutil.copytree(ROOT / "dist/DouyinCLI", app / "cli", dirs_exist_ok=True) shutil.copy2(ROOT / "WINDOWS-README.txt", app / "使用说明.txt") shutil.copy2(ROOT / "requirements-desktop.txt", app / "runtime-versions.txt") # Keep the browser's shipped LICENSE/ABOUT/credits files, and Qt's bundled license material. licenses = app / "licenses" licenses.mkdir(exist_ok=True) shutil.copy2( ROOT / "packaging/fingerprint-chromium-LICENSE.txt", licenses / "fingerprint-chromium-LICENSE.txt", ) import importlib.metadata versions = {} for package in ( "PySide6", "PySide6_Essentials", "shiboken6", "patchright", "psutil", "PyInstaller", ): dist = importlib.metadata.distribution(package) versions[package] = dist.version for file in dist.files or []: if any( token in str(file).lower() for token in ("license", "copying", "notice") ): source = Path(dist.locate_file(file)) if source.is_file(): destination = licenses / package / str(file).replace("..", "_") destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) for python_license in ( Path(sys.base_prefix) / "LICENSE.txt", Path(sys.base_prefix) / "LICENSE", ): if python_license.is_file(): shutil.copy2(python_license, licenses / "Python-LICENSE.txt") break source_hashes = {p.as_posix(): sha256(p) for p in sorted(Path("src").glob("*.py"))} manifest = { "app_version": "0.1.8", "python": sys.version, "chrome": browser, "dependencies": versions, "source_sha256": source_hashes, "contains_account_data": False, } (app / "build-manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) report = cache / "smoke-packaged.json" report.unlink(missing_ok=True) subprocess.run( [str(app / "DouyinAccounts.exe"), "--smoke-test", str(report)], check=True, timeout=90, ) if not report.is_file(): raise RuntimeError("打包冒烟未生成报告,禁止发布") try: smoke = json.loads(report.read_text(encoding="utf-8")) except ValueError as exc: raise RuntimeError("打包冒烟报告无效") from exc if ( not all(smoke.get(k) for k in ("qt", "core", "sqlite", "patchright")) or smoke.get("accounts") != 0 ): raise RuntimeError("打包冒烟不通过,禁止发布") subprocess.run([str(app / "cli/DouyinCLI.exe"), "--help"], check=True, timeout=30) iscc = ( args.iscc or Path(os.environ.get("LOCALAPPDATA", "")) / "Programs/Inno Setup 6/ISCC.exe" ) if not iscc.is_file(): iscc = Path("C:/Program Files (x86)/Inno Setup 6/ISCC.exe") if not iscc.is_file(): raise SystemExit( "onedir 和离线冒烟已完成。安装 Inno Setup 6 后传 --iscc PATH 以生成最终单文件安装包。" ) subprocess.run([str(iscc), str(ROOT / "installer.iss")], check=True) installer = ROOT / "release/DouyinAccounts-0.1.8-Windows-x64-Setup.exe" (installer.parent / "SHA256SUMS.txt").write_text( sha256(installer) + " " + installer.name + "\n", encoding="ascii" ) print("RELEASE=" + str(installer)) if __name__ == "__main__": main()