Files
douyin-pc/build_windows.py

220 lines
7.9 KiB
Python

"""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()