443 lines
19 KiB
Python
443 lines
19 KiB
Python
#!/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()
|