major fixes test

This commit is contained in:
2026-09-09 12:30:40 +03:00
parent b184ea76f4
commit bcd200b1e8
9 changed files with 1785 additions and 226 deletions
+442
View File
@@ -0,0 +1,442 @@
#!/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()