220 lines
7.9 KiB
Python
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.7",
|
|
"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.7-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()
|