This commit is contained in:
2026-09-08 09:24:35 +03:00
commit 19d8e1111a
6 changed files with 530 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# Universal Game Updater
A simple, self-hosted updater for distributing game client files. Consists of three parts:
- **`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.
## 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 `relative/path: hash` for every file.
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 downloads anything missing or different.
5. Once everything matches, the "Play" button launches the game executable.
## Setup
### 1. Server
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
```
This prints a random 64-character hex string. Generate one per client (e.g. one for `main`, one for `test`).
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>/...`.
### 2. Updater (client)
Edit the configuration block at the top of `updater.py`:
```python
GAME_NAME = "My Game"
SERVER_ADDRESS = "update.mygame.com" # domain or IP
SERVER_PORT = "3232"
USE_HTTPS = False
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.
- `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.
Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/):
```bash
pyinstaller --onefile --noconsole --icon=icon.ico --add-data "banner.png;." --add-data "icon.ico;." updater.py
```
Ship the resulting `updater.exe` alongside the game folder.
## 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.
**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.
If a key leaks and you want to rotate it:
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`.
No folder renaming or manifest regeneration needed.
## Ignoring files during manifest generation
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",
}
```
Add any player-specific files (settings, saves, screenshots, logs) here so the updater never overwrites or deletes them.
## Notes / limitations
- File integrity is checked with MD5 — fine for detecting changed files, not intended as a cryptographic guarantee.
- The server has no built-in rate limiting or HTTPS; put it behind a reverse proxy (nginx, Caddy) if you need either.
- `updater.py` uses Tkinter for the UI, which ships with most standard Python installs on Windows.
+8
View File
@@ -0,0 +1,8 @@
{
"host": "0.0.0.0",
"port": 3232,
"clients": {
"d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0": "game_client",
"aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1": "game_client_test"
}
}
+16
View File
@@ -0,0 +1,16 @@
import secrets
import argparse
# Generates a long random hex key suitable for use as a client access key.
# Run this once per client (main/test/etc.) and put the result into:
# - server-side config.json (as the dict key, mapped to the folder)
# - updater.py CLIENT_KEYS (as the value, mapped to the display name)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate a random hex access key for a client.")
parser.add_argument("--bytes", type=int, default=32,
help="Number of random bytes (default 32 -> 64 hex chars).")
args = parser.parse_args()
key = secrets.token_hex(args.bytes)
print(key)
+96
View File
@@ -0,0 +1,96 @@
import os
import json
import hashlib
import argparse
# ============================================================
# CONFIGURATION - edit these values for your game/project
# ============================================================
MANIFEST_FILENAME = "manifest.json"
# Files the updater must never touch (launcher itself, local settings, etc.)
IGNORE_FILES = {
MANIFEST_FILENAME,
"updater.exe", # Your compiled updater executable
"Option.ini", # Example: graphics/sound settings
"WindowsInfo.ini", # Example: window layout settings
}
# Folders the updater must never scan or update
IGNORE_FOLDERS = {
"screenshots",
"logs",
"replays",
}
# File extensions to always skip (lowercase, without the dot doesn't matter, include the dot)
IGNORE_EXTENSIONS = {
".log",
}
# ============================================================
# END CONFIGURATION
# ============================================================
def get_md5(filepath):
"""Computes the MD5 hash of a file."""
hash_md5 = hashlib.md5()
try:
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
except IOError:
return None
def generate(client_dir):
if not os.path.exists(client_dir) or not os.path.isdir(client_dir):
print(f"Error: directory '{client_dir}' not found!")
return
manifest = {}
manifest_path = os.path.join(client_dir, MANIFEST_FILENAME)
print(f"Scanning client folder: {client_dir}")
for root, dirs, files in os.walk(client_dir):
# Filter out ignored folders (case-insensitive)
dirs[:] = [d for d in dirs if d.lower() not in IGNORE_FOLDERS]
for file in files:
if file in IGNORE_FILES:
continue
if any(file.lower().endswith(ext) for ext in IGNORE_EXTENSIONS):
continue
filepath = os.path.join(root, file)
# Path relative to the client folder (this is what the updater expects)
rel_path = os.path.relpath(filepath, client_dir)
# Convert Windows-style separators to web-style
rel_path_web = rel_path.replace("\\", "/")
file_hash = get_md5(filepath)
if file_hash:
manifest[rel_path_web] = file_hash
try:
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=4)
print(f"Done! Manifest saved to: {manifest_path}")
print(f"Total files in manifest: {len(manifest)}")
except IOError as e:
print(f"Error saving manifest: {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Universal manifest generator for game updaters.")
parser.add_argument("client_path", help="Path to the client files folder")
args = parser.parse_args()
generate(args.client_path)
+47
View File
@@ -0,0 +1,47 @@
import os
import json
import posixpath
import urllib.parse
from http.server import SimpleHTTPRequestHandler, HTTPServer
CONFIG_FILENAME = "config.json"
def load_config():
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILENAME)
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
CONFIG = load_config()
HOST = CONFIG.get("host", "0.0.0.0")
PORT = CONFIG.get("port", 3232)
# Maps a URL access key (long hex string, see generate_key.py) -> physical
# folder on the server holding that client's files. Using a random hex key
# instead of a plain name like "main" just makes the files harder to stumble
# upon by guessing; it does not stop someone who deliberately extracts the
# key from the updater executable. If a key ever leaks, generate a new one
# and update it here plus in the updater's CLIENT_KEYS - no need to touch
# folder names or anything else.
CLIENTS = CONFIG.get("clients", {})
class MultiClientHandler(SimpleHTTPRequestHandler):
def translate_path(self, path):
path = urllib.parse.urlparse(path).path
parts = [p for p in path.split('/') if p]
if len(parts) >= 1 and parts[0] in CLIENTS:
client_key = parts[0]
base_dir = CLIENTS[client_key]
rel_path = posixpath.join(*parts[1:]) if len(parts) > 1 else ""
return os.path.join(base_dir, rel_path)
return super().translate_path(path)
if __name__ == '__main__':
server_address = (HOST, PORT)
httpd = HTTPServer(server_address, MultiClientHandler)
print(f"Server running on {HOST}:{PORT}...")
print(f"Available client keys: {', '.join(CLIENTS.keys()) if CLIENTS else '(none configured)'}")
httpd.serve_forever()
+221
View File
@@ -0,0 +1,221 @@
import os
import sys
import json
import hashlib
import threading
import urllib.request
import urllib.parse
import subprocess
import tkinter as tk
from tkinter import ttk, messagebox
# ============================================================
# CONFIGURATION - edit these values for your game/project
# ============================================================
GAME_NAME = "My Game" # Shown in the window title
WINDOW_WIDTH = 450
WINDOW_HEIGHT = 280
# Server address. Use either a domain or an IP - whichever you set SERVER_ADDRESS to.
# Examples: "update.mygame.com" or "123.456.78.90"
SERVER_ADDRESS = "update.mygame.com"
SERVER_PORT = "3232"
USE_HTTPS = False # Set True if your server is behind HTTPS/SSL
# Available client versions: display name -> access key.
# The access key is a long random hex string (generate with generate_key.py)
# and must match a key in the server's config.json "clients" dict.
# This just makes the update folder harder to stumble upon by guessing a
# short name in the URL. It is NOT real security: anyone willing to
# disassemble/decompile this exe or sniff the network traffic can recover
# the key, since the program has to know it to work. If a key ever leaks,
# generate a new one, update it here and in the server's config.json, and
# rebuild the exe - no need to rename anything else.
CLIENT_KEYS = {
"Main": "d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0",
"Test": "aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1",
}
DEFAULT_CLIENT = "Main"
# Relative path (from the game folder) to the executable that launches the game
GAME_EXE_RELATIVE_PATH = os.path.join("system", "game.exe")
# Resource files bundled with the updater (banner/icon), relative to the script/exe
BANNER_RELATIVE_PATH = "banner.png"
ICON_RELATIVE_PATH = "icon.ico"
MANIFEST_FILENAME = "manifest.json"
# ============================================================
# END CONFIGURATION
# ============================================================
def get_resource_path(relative_path):
if getattr(sys, 'frozen', False):
return os.path.join(sys._MEIPASS, relative_path)
return os.path.join(os.path.abspath("."), relative_path)
def get_base_path():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
BASE_PATH = get_base_path()
BANNER_FILE = get_resource_path(BANNER_RELATIVE_PATH)
ICON_FILE = get_resource_path(ICON_RELATIVE_PATH)
GAME_EXE = os.path.join(BASE_PATH, GAME_EXE_RELATIVE_PATH)
def build_server_base_url():
scheme = "https" if USE_HTTPS else "http"
return f"{scheme}://{SERVER_ADDRESS}:{SERVER_PORT}/"
class UpdaterApp:
def __init__(self, root):
self.root = root
self.root.title(f"{GAME_NAME} Updater")
self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
self.root.resizable(False, False)
try:
self.root.iconbitmap(ICON_FILE)
except Exception:
pass
try:
self.bg_image = tk.PhotoImage(file=BANNER_FILE)
tk.Label(root, image=self.bg_image).pack(pady=5)
except Exception:
tk.Label(root, text="[ Banner not found ]", bg="gray", width=60, height=6).pack(pady=5)
# Client version selector
mode_frame = tk.Frame(root)
mode_frame.pack(pady=5)
tk.Label(mode_frame, text="Client version:").pack(side="left")
self.mode_var = tk.StringVar(value=DEFAULT_CLIENT)
self.mode_combo = ttk.Combobox(
mode_frame, textvariable=self.mode_var,
values=list(CLIENT_KEYS.keys()), state="readonly", width=15
)
self.mode_combo.pack(side="left", padx=5)
# Status text
self.status_var = tk.StringVar(value="Select a version and click Check for Updates...")
tk.Label(root, textvariable=self.status_var).pack(pady=5)
# Progress bar
self.progress = ttk.Progressbar(root, orient="horizontal", length=400, mode="determinate")
self.progress.pack(pady=5)
# Buttons
btn_frame = tk.Frame(root)
btn_frame.pack(pady=10)
self.btn_update = tk.Button(btn_frame, text="Check for Updates", command=self.start_update, width=20)
self.btn_update.pack(side="left", padx=10)
btn_state = "normal" if os.path.exists(GAME_EXE) else "disabled"
self.btn_play = tk.Button(btn_frame, text="Play", command=self.play_game, width=20, state=btn_state)
self.btn_play.pack(side="left", padx=10)
def update_ui(self, text=None, prog_val=None, prog_max=None):
if text: self.root.after(0, lambda: self.status_var.set(text))
if prog_max: self.root.after(0, lambda: self.progress.config(maximum=prog_max))
if prog_val is not None: self.root.after(0, lambda: self.progress.config(value=prog_val))
def get_md5(self, filepath):
if not os.path.exists(filepath):
return None
hash_md5 = hashlib.md5()
try:
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
except IOError:
return None
def start_update(self):
self.btn_update.config(state="disabled")
self.btn_play.config(state="disabled")
self.mode_combo.config(state="disabled") # Lock the selector during update
threading.Thread(target=self.update_process, daemon=True).start()
def update_process(self):
# Build URLs dynamically based on the selected client version.
# The display name is only for the UI - the actual URL uses the
# hex access key so the folder name isn't guessable from the UI text.
display_name = self.mode_var.get()
access_key = CLIENT_KEYS[display_name]
current_server_url = build_server_base_url() + f"{access_key}/"
manifest_url = current_server_url + MANIFEST_FILENAME
try:
self.update_ui(text=f"Connecting to server ({display_name})...")
req = urllib.request.urlopen(manifest_url, timeout=10)
server_manifest = json.loads(req.read().decode('utf-8'))
files_to_download = []
total_files = len(server_manifest)
self.update_ui(prog_max=total_files, prog_val=0)
for i, (rel_path, server_hash) in enumerate(server_manifest.items()):
self.update_ui(text=f"Checking: {rel_path}", prog_val=i)
local_path = os.path.join(BASE_PATH, os.path.normpath(rel_path))
local_hash = self.get_md5(local_path)
if local_hash != server_hash:
files_to_download.append(rel_path)
if files_to_download:
total_dl = len(files_to_download)
self.update_ui(prog_max=total_dl, prog_val=0)
for i, rel_path in enumerate(files_to_download):
self.update_ui(text=f"Downloading ({i + 1}/{total_dl}): {rel_path}", prog_val=i)
safe_url_path = urllib.parse.quote(rel_path, safe='/')
file_url = current_server_url + safe_url_path
local_path = os.path.join(BASE_PATH, os.path.normpath(rel_path))
os.makedirs(os.path.dirname(local_path), exist_ok=True)
urllib.request.urlretrieve(file_url, local_path)
self.update_ui(text=f"Client '{display_name}' is up to date! Ready to play.", prog_val=self.progress['maximum'])
self.root.after(0, lambda: self.btn_play.config(state="normal"))
self.root.after(0, lambda: self.btn_update.config(state="normal"))
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
except Exception as e:
self.update_ui(text=f"Error: {str(e)}")
self.root.after(0, lambda: self.btn_update.config(state="normal"))
self.root.after(0, lambda: self.mode_combo.config(state="readonly"))
def play_game(self):
if os.path.exists(GAME_EXE):
game_dir = os.path.dirname(GAME_EXE)
game_exe_name = os.path.basename(GAME_EXE)
os.chdir(game_dir)
try:
os.startfile(game_exe_name)
except AttributeError:
subprocess.Popen([os.path.join(".", game_exe_name)])
self.root.destroy()
else:
messagebox.showerror("Error", f"Game executable not found:\n{GAME_EXE}")
if __name__ == "__main__":
root = tk.Tk()
app = UpdaterApp(root)
root.mainloop()