#!/usr/bin/env python3 """ build_client.py ---------------------------------- Собирает updater.exe. ТОЛЬКО для Windows - PyInstaller не умеет кросс-компилировать, так что запуск этого скрипта на Linux/macOS произвёл бы ELF-бинарник с расширением .exe, который не запустится ни на одной Windows-машине игрока. Запускайте это на настоящей Windows (или Windows VM / Windows CI-раннере). В отличие от quickstart.py (который живёт на сервере и генерирует ключи), этот скрипт ничего не генерирует - он тянет всё нужное с уже работающего сервера через /client_build_info.json: - access_keys (по клиенту) - signing_keys.public (публичный ключ подписи - не секрет) - connection.* (адрес/порт сервера, HTTPS) - updater_ui / paths / manifest / self_update / default_client Приватный ключ подписи (SIGNING_PRIVATE_KEY.txt) в этот список НЕ входит и никогда не будет отдан сервером - он должен существовать только на машине, где генерируются манифесты (см. generate_manifest.py). Эта Windows build-машина его не получает и не должна. Usage: # первый раз - указываете адрес сервера, он запоминается локально python build_client.py --server-url https://example.com:443/ # дальше можно просто: python build_client.py # выбрать другой сервер / переписать закэшированный адрес: python build_client.py --server-url https://staging.example.com:8443/ --save-server-url Результат - updater.exe в dist/ рядом со скриптом. Что делать с ним дальше (положить в клиентские папки, опубликовать как self-update) решаете сами на стороне сервера, например через: python generate_updater_manifest.py dist/updater.exe game_client --version N """ import os import re import sys import json import shutil import argparse import subprocess import urllib.request import urllib.error _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) _UPDATER_PY_PATH = os.path.join(_SCRIPT_DIR, "updater.py") _LOCAL_SETTINGS_PATH = os.path.join(_SCRIPT_DIR, "build_client_settings.json") CLIENT_BUILD_INFO_PATH = "client_build_info.json" def log(msg): print(f"[build_client] {msg}") def _check_windows(): if os.name != "nt": raise SystemExit( "build_client.py only makes sense on Windows: PyInstaller cannot " "cross-compile, so an exe built here would not run on players' " "Windows machines. Run this script on an actual Windows install, " "VM, or CI runner instead." ) def load_local_settings(): if not os.path.exists(_LOCAL_SETTINGS_PATH): return {} try: with open(_LOCAL_SETTINGS_PATH, "r", encoding="utf-8") as f: return json.load(f) except (OSError, json.JSONDecodeError): return {} def save_local_settings(settings): with open(_LOCAL_SETTINGS_PATH, "w", encoding="utf-8") as f: json.dump(settings, f, indent=4) f.write("\n") def resolve_server_url(cli_url, save_it): """ Where to fetch client_build_info.json from. Precedence: 1. --server-url on the command line, if given. 2. Previously saved value in build_client_settings.json. 3. Neither -> error asking for --server-url once. A URL given on the command line is only written back to the local settings file if --save-server-url was also passed - otherwise a one-off override (e.g. testing against staging) doesn't silently become the new default for next time. """ settings = load_local_settings() url = cli_url or settings.get("server_url") if not url: raise SystemExit( "No server URL known yet. Run once with --server-url, e.g.:\n" " python build_client.py --server-url https://example.com:443/" ) if not url.endswith("/"): url += "/" if cli_url and save_it: settings["server_url"] = url save_local_settings(settings) log(f"Saved server URL to {os.path.basename(_LOCAL_SETTINGS_PATH)} for next time.") return url def fetch_client_build_info(server_url): info_url = server_url + CLIENT_BUILD_INFO_PATH log(f"Fetching build info from {info_url} ...") try: with urllib.request.urlopen(info_url, timeout=15) as resp: raw = resp.read() except urllib.error.URLError as e: raise SystemExit( f"Couldn't reach {info_url} ({e}). Is server.py running and reachable " "from this machine? Check --server-url / firewall / HTTPS setup." ) try: return json.loads(raw.decode("utf-8")) except json.JSONDecodeError as e: raise SystemExit(f"Server response at {info_url} wasn't valid JSON: {e}") def validate_build_info(info): access_keys = info.get("access_keys", {}) if not access_keys or not any(access_keys.values()): raise SystemExit( "Server's client_build_info.json has no usable access_keys. " "Run quickstart.py on the server first to generate them." ) public_key = info.get("signing_keys", {}).get("public") if not public_key: raise SystemExit( "Server's client_build_info.json has no signing_keys.public. " "Run quickstart.py on the server first to generate the signing keypair." ) # Defense in depth: the server-side code never includes this, but if # someone hand-edits server.py or points this script at some other # JSON endpoint by mistake, refuse outright rather than silently # baking a private key into an exe that ships to players. if info.get("signing_keys", {}).get("private"): raise SystemExit( "SECURITY: the fetched build info contains a private signing key. " "Refusing to build - this should never happen against a real " "server.py; double-check --server-url points at the right server." ) # ------------------------------------------------------------ # Patch updater.py from the fetched build info and build the exe. # Same regex-based approach quickstart.py used to use server-side, just # fed from fetched JSON instead of a local config.json. # ------------------------------------------------------------ def _build_patch_rules(info): ui = info.get("updater_ui", {}) conn = info.get("connection", {}) paths = info.get("paths", {}) manifest = info.get("manifest", {}) self_update = info.get("self_update", {}) access_keys = info.get("access_keys", {}) signing = info.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(info.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(info.get("require_signature", True))}'), (r'^MAX_DECOMPRESSED_SIZE = .*$', f'MAX_DECOMPRESSED_SIZE = {int(max_mb)} * 1024 * 1024 # {max_mb} MiB, from server config'), (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(info): """ Returns the patched updater.py source as a string, WITHOUT writing it anywhere - caller decides where the patched copy goes. Never modifies the checked-in updater.py in place, so re-running this repeatedly can't accumulate stale patched cruft in the source you might also edit by hand for other reasons. """ with open(_UPDATER_PY_PATH, "r", encoding="utf-8") as f: source = f.read() for pattern, replacement in _build_patch_rules(info): new_source, count = re.subn(pattern, replacement, source, count=1, flags=re.M) if count == 0: raise SystemExit( f"build_client.py couldn't find a line matching {pattern!r} in updater.py. " "updater.py's structure may have changed - update _build_patch_rules() in " "build_client.py, or patch this value by hand before building." ) source = new_source return source def build_exe(info, pyinstaller_args=None): build_dir = os.path.join(_SCRIPT_DIR, "_build_client_work") if os.path.exists(build_dir): shutil.rmtree(build_dir) os.makedirs(build_dir) patched_source = patch_updater_source(info) 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 = info.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 build_client.py - " f"the built exe will show its 'banner not found' placeholder / default icon. " f"Copy {rel_name} next to build_client.py before building if you want it included.") icon_path = os.path.join(build_dir, icon_name) banner_path = os.path.join(build_dir, banner_name) cmd = [ sys.executable, "-m", "PyInstaller", "--onefile", "--noconsole", "--distpath", os.path.join(_SCRIPT_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};."] if os.path.exists(banner_path): cmd += ["--add-data", f"{banner_path};."] 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(_SCRIPT_DIR, "dist") built_exe = os.path.join(dist_dir, "updater.exe") 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 main(): _check_windows() parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--server-url", help="e.g. https://example.com:443/ - where to fetch client_build_info.json from.") parser.add_argument("--save-server-url", action="store_true", help="Remember --server-url locally for future runs (build_client_settings.json).") parser.add_argument("--pyinstaller-arg", action="append", dest="pyinstaller_args", help="Доп. аргумент для PyInstaller, можно указывать несколько раз.") args = parser.parse_args() server_url = resolve_server_url(args.server_url, args.save_server_url) info = fetch_client_build_info(server_url) validate_build_info(info) built_exe = build_exe(info, pyinstaller_args=args.pyinstaller_args) log(f"Done. {built_exe} is ready.") log("To publish it for self-update or fresh installs, run on the SERVER side:") log(f" python generate_updater_manifest.py {os.path.basename(built_exe)} [ ...]") if __name__ == "__main__": main()