#!/usr/bin/env python3 """ generate_updater_manifest.py ---------------------------------- Публикует новую версию updater.exe для self-update. Запускается на build-машине после того, как вы пересобрали updater.exe (PyInstaller) с увеличенным UPDATER_VERSION внутри updater.py. Копирует exe в раздаваемую папку клиента(ов), считает MD5, подписывает манифест тем же Ed25519 приватным ключом, что и generate_manifest.py (оба читают его из одного .txt файла, см. config.json's signing_keys.private_key_file), и кладёт рядом updater_manifest.json + updater_manifest.json.sig. Usage: python generate_updater_manifest.py [ ...] [--version N] Если --version не указан, берётся self_update.updater_version из config.json - удобно, если вы просто хотите опубликовать то, что quickstart.py только что собрал. Example: python generate_updater_manifest.py dist/updater.exe game_client game_client_test python generate_updater_manifest.py dist/updater.exe game_client --version 3 Кладёт updater_manifest.json/.sig в КАЖДУЮ указанную client_dir - self-update проверяется через тот же access-key URL, что и обычный игровой манифест (см. UPDATER_MANIFEST_FILENAME в updater.py), так что файл должен лежать там же, где manifest.json. """ import os import sys import json import shutil import hashlib import argparse from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) _CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json") def _load_config(): if not os.path.exists(_CONFIG_PATH): return {} try: with open(_CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) except (OSError, json.JSONDecodeError): return {} def _read_private_signing_key(cfg): """ Same convention as generate_manifest.py: the private key lives in a standalone .txt file (see generate_key.py's write_private_key_file), never in config.json. Both scripts read the SAME file, named by config.json's signing_keys.private_key_file, so as long as that file exists in one place, the two scripts can't drift apart on which key they sign with. """ filename = cfg.get("signing_keys", {}).get("private_key_file", "SIGNING_PRIVATE_KEY.txt") key_path = os.path.join(_SCRIPT_DIR, filename) if not os.path.exists(key_path): return None try: with open(key_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("SIGNING_PRIVATE_KEY="): return line.split("=", 1)[1].strip() or None except OSError: return None return None _CFG = _load_config() _self_update_cfg = _CFG.get("self_update", {}) # Read from the same standalone .txt file generate_manifest.py uses - see # _read_private_signing_key above. Never stored in config.json itself. SIGNING_PRIVATE_KEY = _read_private_signing_key(_CFG) MANIFEST_FILENAME = _self_update_cfg.get("updater_manifest_filename", "updater_manifest.json") DOWNLOAD_FILENAME = _self_update_cfg.get("download_filename", "updater.exe") # Used as the default --version when the caller doesn't pass one - this is # the version quickstart.py would have baked into updater.py right before # building the exe, so "publish whatever config.json currently says" is a # safe and expected default for the common case of "I just built the exe # quickstart just produced." CONFIGURED_VERSION = _self_update_cfg.get("updater_version") def get_md5(filepath): hash_md5 = hashlib.md5() with open(filepath, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): hash_md5.update(chunk) return hash_md5.hexdigest() def sign_bytes(data: bytes) -> str: if not SIGNING_PRIVATE_KEY: raise SystemExit( "No private signing key found (expected in the .txt file named by " "config.json's signing_keys.private_key_file). Run " "'python generate_key.py --signing-keypair --save' first. An unsigned " "updater_manifest.json would let updater.py's REQUIRE_SIGNATURE " "check reject it outright (or, worse, get accepted if someone " "disabled that check) - refusing to publish an update that " "can't be verified as coming from you." ) private_key = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(SIGNING_PRIVATE_KEY)) return private_key.sign(data).hex() def publish(exe_path, version, client_dirs): if not os.path.isfile(exe_path): raise SystemExit(f"Error: '{exe_path}' not found.") exe_hash = get_md5(exe_path) exe_size = os.path.getsize(exe_path) manifest = { "version": version, "filename": DOWNLOAD_FILENAME, "hash": exe_hash, "size": exe_size, } manifest_bytes = json.dumps(manifest, indent=4, sort_keys=True).encode("utf-8") signature_hex = sign_bytes(manifest_bytes) for client_dir in client_dirs: if not os.path.isdir(client_dir): print(f"WARNING: '{client_dir}' is not a directory, skipping.") continue dest_exe = os.path.join(client_dir, DOWNLOAD_FILENAME) dest_manifest = os.path.join(client_dir, MANIFEST_FILENAME) dest_sig = dest_manifest + ".sig" shutil.copy2(exe_path, dest_exe) with open(dest_manifest, "wb") as f: f.write(manifest_bytes) with open(dest_sig, "w", encoding="utf-8") as f: f.write(signature_hex) print(f"Published v{version} to {client_dir}/ " f"({DOWNLOAD_FILENAME}, {MANIFEST_FILENAME}, {MANIFEST_FILENAME}.sig)") print() print(f"Version: {version}") print(f"MD5: {exe_hash}") print(f"Size: {exe_size:,} bytes") print() print("Reminder: players only pick this up if their currently-running") print("updater.exe has UPDATER_VERSION < the version you just published,") print("and ENABLE_SELF_UPDATE = True was set when THAT build was compiled.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Publish a new self-updating updater.exe build.") parser.add_argument("exe_path", help="Path to the newly built updater.exe") parser.add_argument("client_dirs", nargs="+", help="One or more client folders to publish into (same ones served by server.py)") parser.add_argument( "--version", type=int, default=CONFIGURED_VERSION, help=( "Integer version number, must be > UPDATER_VERSION baked into the new exe. " "Defaults to config.json's self_update.updater_version if omitted." ), ) args = parser.parse_args() if args.version is None: raise SystemExit( "No --version given and config.json has no self_update.updater_version set - " "pass one explicitly: generate_updater_manifest.py ... --version " ) publish(args.exe_path, args.version, args.client_dirs)