181 lines
7.3 KiB
Python
181 lines
7.3 KiB
Python
#!/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)
|