This commit is contained in:
2026-09-12 03:11:49 +03:00
parent b184ea76f4
commit a71580462f
11 changed files with 2303 additions and 225 deletions
+338
View File
@@ -0,0 +1,338 @@
#!/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)} <client_dir> [<client_dir> ...]")
if __name__ == "__main__":
main()