init
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 3232,
|
||||
"clients": {
|
||||
"d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0": "game_client",
|
||||
"aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1": "game_client_test"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user