#!/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()