From 19d8e1111a6f8a5cd9db61c6848bf0fad6345a1b Mon Sep 17 00:00:00 2001 From: poison-flower Date: Tue, 8 Sep 2026 09:24:35 +0300 Subject: [PATCH] init --- README.md | 142 +++++++++++++++++++++++++++ config.json | 8 ++ generate_key.py | 16 ++++ generate_manifest.py | 96 +++++++++++++++++++ server.py | 47 +++++++++ updater.py | 221 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 530 insertions(+) create mode 100644 README.md create mode 100644 config.json create mode 100644 generate_key.py create mode 100644 generate_manifest.py create mode 100644 server.py create mode 100644 updater.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..efba611 --- /dev/null +++ b/README.md @@ -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": { + "": "game_client", + "": "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://://...`. + +### 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": "", + "Test": "", +} +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. diff --git a/config.json b/config.json new file mode 100644 index 0000000..d7e1b32 --- /dev/null +++ b/config.json @@ -0,0 +1,8 @@ +{ + "host": "0.0.0.0", + "port": 3232, + "clients": { + "d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0": "game_client", + "aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1": "game_client_test" + } +} diff --git a/generate_key.py b/generate_key.py new file mode 100644 index 0000000..1774015 --- /dev/null +++ b/generate_key.py @@ -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) diff --git a/generate_manifest.py b/generate_manifest.py new file mode 100644 index 0000000..e3e04ce --- /dev/null +++ b/generate_manifest.py @@ -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) diff --git a/server.py b/server.py new file mode 100644 index 0000000..ad60228 --- /dev/null +++ b/server.py @@ -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() diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..93c47f6 --- /dev/null +++ b/updater.py @@ -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()