Files
simple-updater/generate_updater_manifest.py
2026-09-12 03:11:49 +03:00

181 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <path-to-new-updater.exe> <client_dir> [<client_dir> ...] [--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 <exe> <client_dir> ... --version <n>"
)
publish(args.exe_path, args.version, args.client_dirs)