Files
simple-updater/quickstart.py
T
2026-09-09 12:30:40 +03:00

443 lines
19 KiB
Python
Raw 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
"""
quickstart.py
----------------------------------
Одна команда вместо ручного дёргания generate_key.py / generate_manifest.py /
PyInstaller / generate_updater_manifest.py по отдельности.
Что делает, по порядку, всё с оглядкой на config.json:
1. Для каждого клиента в config.json's "clients", у которого нет
access_keys - генерирует его и сохраняет в config.json.
2. Если приватный ключ подписи (файл, заданный signing_keys.private_key_file
в config.json) отсутствует - генерирует новую Ed25519 keypair:
приватный ключ сохраняется в отдельный .txt файл, публичный - в
config.json (signing_keys.public).
3. Создаёт папки клиентов (из "clients"), если их ещё нет на диске,
рядом с этим скриптом.
4. Генерирует manifest.json + .sig для каждой клиентской папки, у которой
есть хоть один файл (пустые папки пропускаются с предупреждением -
скорее всего, вы просто ещё не положили туда файлы игры).
5. (Опционально, --build-exe) Патчит updater.py итоговыми значениями из
config.json (адрес сервера, access keys, публичный ключ подписи,
размеры окна и т.д.) и собирает updater.exe через PyInstaller.
6. (Опционально, тот же --build-exe) Публикует собранный exe как новую
self-update версию во все клиентские папки, если self_update.enabled.
Каждый шаг идемпотентен и безопасен перезапускать повторно: уже
сгенерированные ключи не трогаются (кроме как через generate_key.py
--save напрямую, если вы явно хотите их сменить), уже существующие папки
клиентов не перезаписываются, манифесты просто перегенерируются с
текущим содержимым папки.
Usage:
python quickstart.py # ключи + манифесты, без сборки exe
python quickstart.py --build-exe # + собрать updater.exe и опубликовать self-update
python quickstart.py --skip-manifests --build-exe # только пересобрать exe
"""
import os
import re
import sys
import json
import shutil
import argparse
import subprocess
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CONFIG_PATH = os.path.join(_SCRIPT_DIR, "config.json")
_UPDATER_PY_PATH = os.path.join(_SCRIPT_DIR, "updater.py")
def log(msg):
print(f"[quickstart] {msg}")
def load_config():
if not os.path.exists(_CONFIG_PATH):
raise SystemExit(f"config.json not found at {_CONFIG_PATH}")
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def save_config(cfg):
with open(_CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=4)
f.write("\n")
# ------------------------------------------------------------
# Step 1: access keys per client
# ------------------------------------------------------------
def ensure_access_keys(cfg):
import secrets
clients = cfg.get("clients", {})
if not clients:
log("WARNING: config.json has no 'clients' configured - nothing to generate access keys for.")
return cfg
cfg.setdefault("access_keys", {})
changed = False
for name in clients:
if cfg["access_keys"].get(name):
log(f"Access key for '{name}' already set, leaving it alone.")
continue
key = secrets.token_hex(32)
cfg["access_keys"][name] = key
changed = True
log(f"Generated access key for '{name}'.")
if changed:
save_config(cfg)
return cfg
# ------------------------------------------------------------
# Step 2: Ed25519 signing keypair
# ------------------------------------------------------------
def ensure_signing_keypair(cfg):
"""
Delegates the actual key generation/writing to generate_key.py so
there's exactly one place (generate_key.py's write_private_key_file)
that decides how the private key is stored - here we just decide
WHETHER to generate one.
The private half is written to a standalone .txt file next to the
scripts (never into config.json - see generate_key.py's module
docstring for why); only the public half and the key filename land in
config.json.
"""
sys.path.insert(0, _SCRIPT_DIR)
import generate_key
importlib_reload(generate_key)
signing = cfg.setdefault("signing_keys", {"public": "", "private_key_file": "SIGNING_PRIVATE_KEY.txt"})
signing.setdefault("private_key_file", "SIGNING_PRIVATE_KEY.txt")
key_path, _ = generate_key._private_key_file_path(cfg)
has_public = bool(signing.get("public"))
has_private_file = os.path.exists(key_path)
if has_public and has_private_file:
log("Signing keypair already set, leaving it alone.")
return cfg
if has_public or has_private_file:
raise SystemExit(
f"config.json has signing_keys.public set ({has_public}) but the private "
f"key file {'exists' if has_private_file else 'is missing'} ({key_path}) - "
"these are out of sync. Fix or clear both manually, then re-run quickstart.py."
)
private_hex, public_hex = generate_key.generate_signing_keypair()
generate_key.write_private_key_file(key_path, private_hex)
# Re-load in case write_private_key_file's caller (generate_key.py) has
# its own opinions about the file later - here we just need the dict
# we already have to carry the public half back to the caller.
cfg["signing_keys"]["public"] = public_hex
save_config(cfg)
log(f"Generated new Ed25519 signing keypair. Private key written to {os.path.basename(key_path)}.")
log(f"IMPORTANT: {os.path.basename(key_path)} contains a secret - do not commit or share it.")
return cfg
# ------------------------------------------------------------
# Step 3: client folders
# ------------------------------------------------------------
def ensure_client_folders(cfg):
clients = cfg.get("clients", {})
for name, folder in clients.items():
folder_path = os.path.join(_SCRIPT_DIR, folder)
if os.path.isdir(folder_path):
continue
os.makedirs(folder_path, exist_ok=True)
log(f"Created empty client folder '{folder}' for '{name}' - "
f"put game files in it before generating a real manifest.")
def _folder_has_files(folder_path):
for _root, _dirs, files in os.walk(folder_path):
if files:
return True
return False
# ------------------------------------------------------------
# Step 4: manifests
# ------------------------------------------------------------
def importlib_reload(module):
import importlib
importlib.reload(module)
def generate_manifests(cfg):
clients = cfg.get("clients", {})
if not clients:
return
sys.path.insert(0, _SCRIPT_DIR)
import generate_manifest
importlib_reload(generate_manifest) # pick up config.json changes from steps 1-2 above
for name, folder in clients.items():
folder_path = os.path.join(_SCRIPT_DIR, folder)
if not os.path.isdir(folder_path):
log(f"Skipping manifest for '{name}': folder '{folder}' doesn't exist.")
continue
if not _folder_has_files(folder_path):
log(f"Skipping manifest for '{name}': folder '{folder}' has no files yet.")
continue
log(f"Generating manifest for '{name}' ({folder})...")
generate_manifest.generate(folder_path)
# ------------------------------------------------------------
# Step 5: patch updater.py from config.json and build the exe
# ------------------------------------------------------------
def _build_patch_rules(cfg):
"""
Each entry: (regex matching the line to replace in updater.py,
replacement line). Matched with re.M anchored to line start, so only
the exact top-level assignment lines below are ever touched.
"""
ui = cfg.get("updater_ui", {})
conn = cfg.get("connection", {})
paths = cfg.get("paths", {})
manifest = cfg.get("manifest", {})
self_update = cfg.get("self_update", {})
access_keys = cfg.get("access_keys", {})
signing = cfg.get("signing_keys", {})
def py_str(s):
return json.dumps(s)
def py_dict_literal(d):
lines = ["{"]
for k, v in d.items():
lines.append(f" {py_str(k)}: {py_str(v)},")
lines.append("}")
return "\n".join(lines)
max_mb = manifest.get("max_decompressed_size_mb", 512)
public_key = signing.get("public") or ""
rules = [
(r'^GAME_NAME = .*$', f'GAME_NAME = {py_str(ui.get("game_name", "My Game"))}'),
(r'^UPDATER_NAME = .*$', f'UPDATER_NAME = {py_str(ui.get("updater_name", "Launcher"))}'),
(r'^WINDOW_WIDTH = .*$', f'WINDOW_WIDTH = {int(ui.get("window_width", 450))}'),
(r'^WINDOW_HEIGHT = .*$', f'WINDOW_HEIGHT = {int(ui.get("window_height", 280))}'),
(r'^BANNER_WIDTH = .*$', f'BANNER_WIDTH = {int(ui.get("banner_width", 430))}'),
(r'^BANNER_HEIGHT = .*$', f'BANNER_HEIGHT = {int(ui.get("banner_height", 140))}'),
(r'^SERVER_ADDRESS = .*$', f'SERVER_ADDRESS = {py_str(conn.get("server_address", "example.com"))}'),
(r'^SERVER_PORT = .*$', f'SERVER_PORT = {py_str(str(conn.get("server_port", "443")))}'),
(r'^USE_HTTPS = .*$', f'USE_HTTPS = {bool(conn.get("use_https", True))}'),
(r'^CLIENT_KEYS = \{[^}]*\}', f'CLIENT_KEYS = {py_dict_literal(access_keys)}'),
(r'^DEFAULT_CLIENT = .*$', f'DEFAULT_CLIENT = {py_str(cfg.get("default_client", "Main"))}'),
(r'^SIGNING_PUBLIC_KEY = .*$', f'SIGNING_PUBLIC_KEY = {py_str(public_key) if public_key else "None"}'),
(r'^REQUIRE_SIGNATURE = .*$', f'REQUIRE_SIGNATURE = {bool(cfg.get("require_signature", True))}'),
(r'^MAX_DECOMPRESSED_SIZE = .*$',
f'MAX_DECOMPRESSED_SIZE = {int(max_mb)} * 1024 * 1024 # {max_mb} MiB, from config.json'),
(r'^GAME_EXE_RELATIVE_PATH = .*$',
f'GAME_EXE_RELATIVE_PATH = os.path.normpath({py_str(paths.get("game_exe_relative_path", "system/game.exe"))})'),
(r'^BANNER_RELATIVE_PATH = .*$', f'BANNER_RELATIVE_PATH = {py_str(paths.get("banner_relative_path", "banner.png"))}'),
(r'^ICON_RELATIVE_PATH = .*$', f'ICON_RELATIVE_PATH = {py_str(paths.get("icon_relative_path", "icon.ico"))}'),
(r'^MANIFEST_FILENAME = "manifest\.json"$',
f'MANIFEST_FILENAME = {py_str(manifest.get("manifest_filename", "manifest.json"))}'),
(r'^COMPRESSED_SUFFIX = .*$', f'COMPRESSED_SUFFIX = {py_str(manifest.get("compressed_suffix", ".gz"))}'),
(r'^SETTINGS_FILENAME = .*$', f'SETTINGS_FILENAME = {py_str(paths.get("settings_filename", "updater.ini"))}'),
(r'^UPDATER_VERSION = .*$', f'UPDATER_VERSION = {int(self_update.get("updater_version", 1))}'),
(r'^UPDATER_MANIFEST_FILENAME = .*$',
f'UPDATER_MANIFEST_FILENAME = {py_str(self_update.get("updater_manifest_filename", "updater_manifest.json"))}'),
(r'^ENABLE_SELF_UPDATE = .*$', f'ENABLE_SELF_UPDATE = {bool(self_update.get("enabled", True))}'),
]
return rules
def patch_updater_source(cfg):
"""
Returns the patched updater.py source as a string, WITHOUT writing it
anywhere - caller decides where the patched copy goes. This never
modifies the checked-in updater.py in place: quickstart always builds
from a fresh in-memory copy, so re-running it repeatedly can't
accumulate stale patched cruft in the source you edit by hand.
"""
with open(_UPDATER_PY_PATH, "r", encoding="utf-8") as f:
source = f.read()
for pattern, replacement in _build_patch_rules(cfg):
new_source, count = re.subn(pattern, replacement, source, count=1, flags=re.M)
if count == 0:
raise SystemExit(
f"quickstart.py couldn't find a line matching {pattern!r} in updater.py. "
"updater.py's structure may have changed - update _build_patch_rules() in "
"quickstart.py, or patch this value by hand before building."
)
source = new_source
return source
def build_exe(cfg, pyinstaller_args=None):
signing = cfg.get("signing_keys", {})
if not signing.get("public"):
raise SystemExit("signing_keys.public is empty in config.json - run the key-generation steps first.")
access_keys = cfg.get("access_keys", {})
if not access_keys or not any(access_keys.values()):
raise SystemExit("No access_keys configured in config.json - run the key-generation steps first.")
build_dir = os.path.join(_SCRIPT_DIR, "_quickstart_build")
if os.path.exists(build_dir):
shutil.rmtree(build_dir)
os.makedirs(build_dir)
patched_source = patch_updater_source(cfg)
build_updater_py = os.path.join(build_dir, "updater.py")
with open(build_updater_py, "w", encoding="utf-8") as f:
f.write(patched_source)
paths_cfg = cfg.get("paths", {})
banner_name = os.path.basename(paths_cfg.get("banner_relative_path", "banner.png"))
icon_name = os.path.basename(paths_cfg.get("icon_relative_path", "icon.ico"))
for rel_name in (banner_name, icon_name):
src = os.path.join(_SCRIPT_DIR, rel_name)
if os.path.exists(src):
shutil.copy2(src, os.path.join(build_dir, rel_name))
else:
log(f"WARNING: resource '{rel_name}' not found next to updater.py - "
f"the built exe will show its 'banner not found' placeholder / default icon.")
icon_path = os.path.join(build_dir, icon_name)
banner_path = os.path.join(build_dir, banner_name)
sep = ";" if os.name == "nt" else ":"
cmd = [
sys.executable, "-m", "PyInstaller",
"--onefile", "--noconsole",
"--distpath", os.path.join(build_dir, "dist"),
"--workpath", os.path.join(build_dir, "work"),
"--specpath", build_dir,
]
if os.path.exists(icon_path):
cmd += ["--icon", icon_path, "--add-data", f"{icon_path}{sep}."]
if os.path.exists(banner_path):
cmd += ["--add-data", f"{banner_path}{sep}."]
if pyinstaller_args:
cmd += pyinstaller_args
cmd.append(build_updater_py)
log("Running PyInstaller (this can take a minute)...")
log(" ".join(cmd))
result = subprocess.run(cmd, cwd=build_dir)
if result.returncode != 0:
raise SystemExit(f"PyInstaller failed with exit code {result.returncode}.")
dist_dir = os.path.join(build_dir, "dist")
expected_name = "updater.exe" if os.name == "nt" else "updater"
built_exe = os.path.join(dist_dir, expected_name)
if not os.path.exists(built_exe):
candidates = os.listdir(dist_dir) if os.path.isdir(dist_dir) else []
if not candidates:
raise SystemExit(f"PyInstaller reported success but no output found in {dist_dir}.")
built_exe = os.path.join(dist_dir, candidates[0])
log(f"Built: {built_exe}")
return built_exe
def publish_self_update(cfg, built_exe):
self_update = cfg.get("self_update", {})
if not self_update.get("enabled", True):
log("self_update.enabled is false in config.json - skipping self-update publish.")
return
clients = cfg.get("clients", {})
client_dirs = [
os.path.join(_SCRIPT_DIR, folder)
for folder in clients.values()
if os.path.isdir(os.path.join(_SCRIPT_DIR, folder))
]
if not client_dirs:
log("No existing client folders to publish the self-update into - skipping.")
return
sys.path.insert(0, _SCRIPT_DIR)
import generate_updater_manifest
importlib_reload(generate_updater_manifest)
version = self_update.get("updater_version", 1)
generate_updater_manifest.publish(built_exe, version, client_dirs)
def copy_exe_into_clients(cfg, built_exe):
"""
Drops a copy of the freshly built exe into each client folder as a
convenience for fresh installs (so "here's your game_client folder,
updater.exe included" is literally true right after quickstart runs).
This is separate from publish_self_update: that one is about *existing*
installs picking up a newer build; this is about what a brand new
player downloading the client folder for the first time gets.
"""
clients = cfg.get("clients", {})
download_name = cfg.get("self_update", {}).get("download_filename", "updater.exe")
for name, folder in clients.items():
folder_path = os.path.join(_SCRIPT_DIR, folder)
if not os.path.isdir(folder_path):
continue
dest = os.path.join(folder_path, download_name)
shutil.copy2(built_exe, dest)
log(f"Copied built exe into {folder}/{download_name}.")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--skip-keys", action="store_true", help="Не генерировать access keys / signing keypair.")
parser.add_argument("--skip-manifests", action="store_true", help="Не генерировать manifest.json для клиентских папок.")
parser.add_argument("--build-exe", action="store_true", help="Собрать updater.exe через PyInstaller и опубликовать self-update.")
parser.add_argument("--pyinstaller-arg", action="append", dest="pyinstaller_args",
help="Доп. аргумент для PyInstaller, можно указывать несколько раз.")
args = parser.parse_args()
cfg = load_config()
if not args.skip_keys:
cfg = ensure_access_keys(cfg)
cfg = ensure_signing_keypair(cfg)
else:
log("Skipping key generation (--skip-keys).")
ensure_client_folders(cfg)
if not args.skip_manifests:
generate_manifests(cfg)
else:
log("Skipping manifest generation (--skip-manifests).")
if args.build_exe:
cfg = load_config() # re-load in case key generation above changed it
built_exe = build_exe(cfg, pyinstaller_args=args.pyinstaller_args)
copy_exe_into_clients(cfg, built_exe)
publish_self_update(cfg, built_exe)
else:
log("Skipping exe build (pass --build-exe to compile updater.exe).")
log("Done.")
if __name__ == "__main__":
main()