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
+128 -153
View File
@@ -1,199 +1,174 @@
# Universal Game Updater
# simple-updater
A simple, self-hosted updater for distributing game client files. Consists of three parts:
Лёгкий self-hosted обновлятор для игры/приложения: HTTP-сервер, который раздаёт файлы клиентам, и Windows-обновлятор (`updater.py` → `updater.exe`), который сравнивает локальные файлы с манифестом на сервере и докачивает то, что изменилось.
- **`updater.py`** — the client-side app (compiled to `updater.exe`) that players run. Checks the server for changed files and downloads only what's needed.
- **`server.py`** — a lightweight HTTP server that serves client files and manifests.
- **`generate_manifest.py`** — a script you run on your build machine to generate the manifest the server distributes.
- **`generate_key.py`** — a helper to generate access keys (see [Access keys](#access-keys) below).
## Возможности
Works with any game — just fill in the configuration blocks at the top of each script.
- **Раздача нескольких версий клиента** с одного сервера (например `Main` и `Test`) через отдельные access-ключи в URL.
- **Подпись манифестов (Ed25519)** — сервер не может незаметно подменить список файлов или их хэши; обновлятор проверяет подпись публичным ключом перед тем, как довериться манифесту.
- **Сжатие файлов** — при генерации манифеста каждый файл сжимается gzip'ом; если сжатая копия меньше оригинала, обновлятор скачивает её и разжимает локально. Повторная генерация манифеста пересжимает только реально изменившиеся файлы (см. «Сжатие» ниже).
- **Self-update** — сам `updater.exe` умеет проверять и устанавливать собственные новые версии, без отдельной ручной раздачи.
- **Единый `config.json`** — все настройки (сервер, клиенты, ключи, вид окна, лимиты) в одном файле на сервере.
- **`quickstart.py`** — генерирует ключи и манифесты одной командой на сервере.
- **`build_client.py`** — отдельно собирает `updater.exe` на Windows, забирая всё нужное с сервера по сети, без доступа к серверному `config.json` целиком.
## How it works
## Архитектура: сервер и клиентская сборка — раздельно
1. You put your game's files in a folder on the server (e.g. `game_client/`).
2. You run `generate_manifest.py` on that folder. It walks the folder, hashes every file (MD5), and writes a `manifest.json` listing metadata for every file. It also gzip-compresses every file into a `<file>.gz` copy next to the original — if the compressed copy isn't actually smaller (common for already-compressed formats like `.jpg`, `.mp3`, `.zip`), the `.gz` copy is discarded and the manifest just records the original.
3. `server.py` serves that folder over HTTP, keyed by an access key defined in `config.json`.
4. Players run `updater.exe`. It downloads the server's `manifest.json`, compares hashes against their local files, and for anything missing or different: downloads the `.gz` copy if one exists, verifies it, decompresses it locally, and writes the result — or downloads the original directly if there's no compressed copy.
5. Once everything matches, the "Play" button launches the game executable.
PyInstaller не кросс-компилирует: собранный на Linux/macOS «updater.exe» — это бинарник для той ОС, на которой его собрали, просто с расширением `.exe`; на Windows он не запустится. Поэтому в проекте два независимых пайплайна:
## Setup
- **Серверная часть** (`quickstart.py`, `generate_key.py`, `generate_manifest.py`, `generate_updater_manifest.py`, `server.py`) — работает на любой ОС, обычно Linux. Генерирует ключи, манифесты, раздаёт файлы. Никогда не собирает exe.
- **Клиентская сборка** (`build_client.py`) — работает **только на Windows** (реальной машине, VM или CI-раннере). Ничего не генерирует сама: забирает access-ключи, публичный ключ подписи и остальные настройки с работающего сервера по HTTP(S) и собирает `updater.exe`.
### 1. Server
Приватный ключ подписи манифестов никогда не покидает сервер — `build_client.py` физически не может его получить (сервер его и не отдаёт, см. ниже).
Install Python 3 (no external dependencies needed — everything uses the standard library).
## Быстрый старт
Generate an access key for each client version you want to distribute:
**На сервере:**
```bash
python generate_key.py
```
pip install -r requirements.txt
This prints a random 64-character hex string. Generate one per client (e.g. one for `main`, one for `test`).
# сгенерировать ключи + положить файлы игры в game_client/ + собрать манифесты
python quickstart.py
Edit `config.json`:
```json
{
"host": "0.0.0.0",
"port": 3232,
"clients": {
"<your-generated-hex-key>": "game_client",
"<another-generated-hex-key>": "game_client_test"
}
}
```
- `host` / `port` — what the server listens on.
- `clients` — maps an access key to the folder (relative to `server.py`) that key serves.
Put your game files in the folders referenced above (`game_client/`, etc.), then generate the manifest for each:
```bash
python generate_manifest.py game_client
python generate_manifest.py game_client_test
```
Re-run this after every update to the client files, then start (or restart) the server:
```bash
# запустить сервер
python server.py
```
Files become available at `http://<server-address>:<port>/<access-key>/...`.
Первый запуск `quickstart.py` создаст пустые папки клиентов (`game_client`, `game_client_test` — имена берутся из `config.json`'s `clients`), сгенерирует access-ключи и пару ключей для подписи манифестов. Положите файлы игры в `game_client/`, запустите `quickstart.py` ещё раз — манифест сгенерируется и подпишется автоматически.
### 2. Updater (client)
Edit the configuration block at the top of `updater.py`:
```python
GAME_NAME = "My Game"
UPDATER_NAME = "Launcher"
WINDOW_WIDTH = 450
WINDOW_HEIGHT = 300
BANNER_WIDTH = 430
BANNER_HEIGHT = 140
SERVER_ADDRESS = "update.mygame.com" # domain or IP
SERVER_PORT = "443" # 80/443 for HTTP/HTTPS, or a custom port for direct IP
USE_HTTPS = True
CLIENT_KEYS = {
"Main": "<same-hex-key-as-in-server-config.json>",
"Test": "<same-hex-key-as-in-server-config.json>",
}
DEFAULT_CLIENT = "Main"
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
BANNER_RELATIVE_PATH = "banner.png"
ICON_RELATIVE_PATH = "icon.ico"
```
- `SERVER_ADDRESS` accepts either a domain name or a raw IP — nothing else needs to change.
- `SERVER_PORT` / `USE_HTTPS` should match how your server is actually reachable. If nginx force-redirects port 80 to 443, set `SERVER_PORT = "443"` and `USE_HTTPS = True` directly rather than relying on the redirect — the updater downloads files one request at a time, so an extra 80→443 redirect on every file adds real overhead on large clients.
- `CLIENT_KEYS` maps a friendly name shown in the UI to the actual access key sent in the request URL. Keys must match `config.json` on the server exactly.
- `GAME_EXE_RELATIVE_PATH` is where the updater looks for the game's executable, relative to the folder the updater itself is placed in.
- `BANNER_WIDTH` / `BANNER_HEIGHT` — the banner image is always scaled to exactly this size, so it doesn't matter what dimensions the source `banner.png` actually has; the layout won't shift.
Banner scaling uses [Pillow](https://pypi.org/project/Pillow/) if it's installed (`pip install Pillow`), for proper resizing. If Pillow isn't available, the updater falls back to Tkinter's built-in (cruder, integer-only) zoom/subsample — it'll still work, just less precisely scaled. Install Pillow for best results, and make sure it's bundled when compiling (see below).
Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/):
**На Windows-машине для сборки клиента:**
```bash
pyinstaller --onefile --noconsole --icon=icon.ico --add-data "banner.png;." --add-data "icon.ico;." updater.py
pip install -r requirements-client.txt
python build_client.py --server-url https://example.com:443/ --save-server-url
```
Ship the resulting `updater.exe` alongside the game folder.
Скрипт скачает нужные настройки с сервера и соберёт `updater.exe` в `dist/`. При повторных запусках `--server-url` можно не указывать — адрес запоминается локально. Готовый exe нужно вручную скопировать в клиентские папки на сервере (или сразу опубликовать как self-update — см. «Ручной запуск отдельных шагов»).
## Settings
## config.json (сервер)
On first run, the updater creates a `settings.ini` file next to itself with the selected client version:
Все настройки серверной части живут здесь, вложенными секциями:
```ini
[updater]
client = Main
| Секция | Что настраивает |
|---|---|
| `server` | host, port, лимит соединений на IP |
| `clients` | имя клиента → папка с файлами (`"Main": "game_client"`) |
| `access_keys` | имя клиента → access-ключ (генерируется автоматически) |
| `default_client` | какой клиент выбран в обновляторе по умолчанию |
| `signing_keys` | публичный ключ подписи + имя файла с приватным ключом |
| `require_signature` | обязательна ли подпись манифеста (по умолчанию да) |
| `connection` | адрес и порт сервера, HTTPS вкл/выкл — то, к чему подключается обновлятор |
| `updater_ui` | название игры, размеры окна и баннера |
| `paths` | путь к exe игры, баннеру, иконке, файлу настроек |
| `manifest` | имя файла манифеста, настройки сжатия, лимит размера файла |
| `ignore` | какие файлы/папки/расширения не попадают в манифест |
| `self_update` | включён ли self-update, текущая версия обновлятора |
Чтобы что-то поменять — правьте `config.json` и перезапускайте `quickstart.py` (для ключей/манифестов) или `server.py` (для настроек сервера). Ручное редактирование `updater.py`/`generate_manifest.py` не нужно.
### Приватный ключ подписи
Единственный настоящий секрет в проекте — приватный ключ подписи манифестов. Он **не хранится в `config.json`**, а лежит в отдельном файле `SIGNING_PRIVATE_KEY.txt` на сервере (имя настраивается через `signing_keys.private_key_file`). Публичный ключ — в `config.json`, его можно раскрывать сколько угодно, зашивать в exe и т.д.
Держите `SIGNING_PRIVATE_KEY.txt` только на сервере — не копируйте его вместе с остальным проектом, добавьте в `.gitignore`, если используете git. `build_client.py` его никогда не запрашивает и не получает: сервер отдаёт клиентским сборкам только `client_build_info.json` (см. ниже), где приватного ключа нет по построению.
### /client_build_info.json
`server.py` отдаёт этот путь без access-ключа — это весь `config.json`, кроме секции `server` (host/port/лимиты — внутренняя топология сервера, не нужна сборке). Всё остальное содержимое всё равно окажется зашитым в `updater.exe` открытым текстом, так что прятать его за access-ключом смысла нет. Приватного ключа подписи там нет ни при каких обстоятельствах — он физически не входит в `config.json`.
## Сжатие (.gz)
При генерации манифеста каждый файл пробуется сжать gzip'ом; если сжатая копия меньше оригинала, она попадает в манифест и раздаётся вместо исходного файла.
- **Отдельная папка для `.gz`** — по умолчанию `manifest.compressed_dir` в `config.json` равен `"_gz"`, то есть сжатые копии лежат в `game_client/_gz/...`, а не рядом с оригиналами. `updater.py` при этом не меняется: он как и раньше запрашивает `system/game.exe.gz` напрямую — `server.py` сам подставляет реальный путь из `_gz/`, если файла нет на «прямом» месте. Поставьте `compressed_dir` в `""`, чтобы вернуть старое поведение (файлы рядом с оригиналом).
- **Кэш сжатия** — повторный запуск `generate_manifest.py` на той же папке пересжимает только файлы, у которых изменился хэш содержимого с прошлого раза (сверяется со старым `manifest.json`). Не изменившиеся файлы просто переиспользуют старые `compressed_hash`/`compressed_size` без повторного gzip. Это делает частую регенерацию манифеста на большом клиенте быстрой.
- **Принудительное пересжатие** — `python generate_manifest.py game_client --force-recompress` игнорирует кэш и пересжимает всё заново (например, после смены `compression_level` в `config.json`).
- **Очистка осиротевших `.gz`** — после каждой генерации манифеста автоматически удаляются `.gz`-файлы, которые больше ни на что не ссылаются: оригинал удалили/переименовали, файл перестал быть выгодным для сжатия, или сменили `compressed_dir` (старые копии в прежнем месте подчищаются). Без этого `.gz`-файлы только копились бы на диске. Отключить можно флагом `--no-clean-orphans`, если по какой-то причине это нежелательно.
## Как всё это работает вместе
```
СЕРВЕР:
config.json ──┬──> generate_key.py ────────> access-ключи в config.json
│ приватный ключ в SIGNING_PRIVATE_KEY.txt
│ публичный ключ в config.json
│
├──> generate_manifest.py ───> game_client/manifest.json + .sig
│ game_client/_gz/... (сжатые копии)
│
├──> generate_updater_manifest.py ──> game_client/updater_manifest.json + .sig
│
└──> quickstart.py ──> запускает все шаги выше по порядку
server.py ──> раздаёт клиентские папки по HTTP(S) под access-ключом в URL
──> отдаёт /client_build_info.json (без access-ключа, без приватного ключа)
WINDOWS (отдельная машина):
build_client.py ──> тянет /client_build_info.json ──> патчит updater.py ──> updater.exe
ИГРОК:
updater.py / updater.exe ──> скачивает манифест, проверяет подпись,
докачивает изменившиеся файлы, запускает игру
```
Whenever the player changes the client version dropdown, it's saved immediately so the choice persists across restarts. If `settings.ini` is missing, corrupted, or contains an unrecognized client name, the updater silently falls back to `DEFAULT_CLIENT` and continues — it never crashes over a bad settings file.
## Ручной запуск отдельных шагов
This is currently the only setting. To add more later, extend `load_settings()` / `save_settings()` in `updater.py` with additional keys under the same `[updater]` section.
Если `quickstart.py`/`build_client.py` не подходят, каждый скрипт можно запускать по отдельности.
## Access keys
На сервере:
Client folders are addressed by a long random hex key instead of a plain name like `main`, so the update path isn't obvious or easy to stumble on by guessing.
```bash
# access-ключ для клиента, сохранить в config.json
python generate_key.py --client Main --save
**This is obscurity, not real security.** The key has to live inside `updater.exe` for it to work, and anyone motivated enough to disassemble the executable or inspect network traffic can recover it. Don't rely on it to gate anything sensitive.
# пара ключей для подписи манифестов
python generate_key.py --signing-keypair --save
If a key leaks and you want to rotate it:
# сгенерировать и подписать манифест для одной клиентской папки
python generate_manifest.py game_client
1. Generate a new key with `generate_key.py`.
2. Update it in the server's `config.json`.
3. Update it in `updater.py`'s `CLIENT_KEYS`.
4. Rebuild and redistribute `updater.exe`.
# то же самое, но пересжать вообще все файлы, игнорируя кэш
python generate_manifest.py game_client --force-recompress
No folder renaming or manifest regeneration needed.
# сгенерировать манифест, но не трогать осиротевшие .gz файлы на диске
python generate_manifest.py game_client --no-clean-orphans
## Ignoring files during manifest generation
# опубликовать exe (уже собранный на Windows) как новую self-update версию
python generate_updater_manifest.py updater.exe game_client --version 2
Edit the configuration block at the top of `generate_manifest.py`:
```python
IGNORE_FILES = {
MANIFEST_FILENAME,
"updater.exe",
"Option.ini",
"WindowsInfo.ini",
}
IGNORE_FOLDERS = {
"screenshots",
"logs",
"replays",
}
IGNORE_EXTENSIONS = {
".log",
}
# запустить сервер
python server.py
```
Add any player-specific files (settings, saves, screenshots, logs) here so the updater never overwrites or deletes them.
На Windows:
## Compression
```bash
# первый раз — указать сервер и запомнить адрес
python build_client.py --server-url https://example.com:443/ --save-server-url
`generate_manifest.py` gzip-compresses every distributable file into a `<file>.gz` copy alongside the original, unconditionally — no file-type filtering. If the compressed copy ends up the same size or larger than the original (common for already-compressed formats), it's discarded automatically and that file is just served uncompressed; there's nothing to configure per file type.
The manifest records both the original file's hash/size and, when a compressed copy exists, its hash/size too:
```json
{
"script.txt": {
"hash": "d3650c70e7d942736c3ca77daf381262",
"size": 60000,
"compressed": true,
"compressed_size": 177,
"compressed_hash": "852ab2df07a4c65cddd6ed3ec0282301"
},
"texture.bin": {
"hash": "1b6d041423b9863dee54e87fed1f0cdd",
"size": 20000,
"compressed": false
}
}
# дальше — просто
python build_client.py
```
`updater.py` uses this to download the smaller `.gz` copy when one is available, verifies its hash, decompresses it in memory, and verifies the decompressed result against the original hash before writing it to disk. If anything about the compressed copy fails (missing, corrupted, hash mismatch), it automatically falls back to downloading the uncompressed original — a bad `.gz` on the server never blocks an update.
## Требования
Two settings control this, and must be kept in sync between `generate_manifest.py` and `updater.py`:
Сервер:
- `COMPRESSED_SUFFIX` (default `.gz`) — the suffix used for compressed copies.
- `COMPRESSION_LEVEL` (default `6`, in `generate_manifest.py` only) — gzip level 1 (fastest, larger) to 9 (slowest, smallest).
```bash
pip install -r requirements.txt
```
Re-run `generate_manifest.py` after any change to the client files — it regenerates both the manifest and the `.gz` copies. Compressed output is generated with a fixed mtime so re-running it without any real file changes produces byte-identical `.gz` files and stable hashes.
`cryptography` — подпись/проверка манифестов (Ed25519).
## Notes / limitations
Клиентская сборка (Windows):
- File integrity is checked with MD5 — fine for detecting changed files, not intended as a cryptographic guarantee.
- Compression roughly doubles the disk space needed on the server for each client version (original + `.gz` copies), in exchange for faster downloads for compressible files.
- The server has no built-in rate limiting or HTTPS; put it behind a reverse proxy (nginx, Caddy) if you need either.
- The server never serves directory listings — a bare `/`, an unknown key, or a path that tries to escape the client folder (`..`) all get a 404, including the server's own script/config files.
- `updater.py` uses Tkinter for the UI, which ships with most standard Python installs on Windows.
```bash
pip install -r requirements-client.txt
```
`cryptography` — то же самое, но нужно уже внутри собранного exe (он сам проверяет подпись манифестов). `Pillow` — опционален, используется `updater.py` для качественного масштабирования баннера; без него баннер всё равно отрисуется, просто грубее. `pyinstaller` — сам сборщик exe.