fixes
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Simple Universal Game Updater
|
# Universal Game Updater
|
||||||
|
|
||||||
A simple, self-hosted updater for distributing game client files. Consists of three parts:
|
A simple, self-hosted updater for distributing game client files. Consists of three parts:
|
||||||
|
|
||||||
@@ -68,9 +68,15 @@ Edit the configuration block at the top of `updater.py`:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
GAME_NAME = "My Game"
|
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_ADDRESS = "update.mygame.com" # domain or IP
|
||||||
SERVER_PORT = "3232"
|
SERVER_PORT = "443" # 80/443 for HTTP/HTTPS, or a custom port for direct IP
|
||||||
USE_HTTPS = False
|
USE_HTTPS = True
|
||||||
|
|
||||||
CLIENT_KEYS = {
|
CLIENT_KEYS = {
|
||||||
"Main": "<same-hex-key-as-in-server-config.json>",
|
"Main": "<same-hex-key-as-in-server-config.json>",
|
||||||
@@ -84,8 +90,12 @@ ICON_RELATIVE_PATH = "icon.ico"
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `SERVER_ADDRESS` accepts either a domain name or a raw IP — nothing else needs to change.
|
- `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.
|
- `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.
|
- `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/):
|
Then compile with your favorite tool, e.g. [PyInstaller](https://pyinstaller.org/):
|
||||||
|
|
||||||
@@ -139,4 +149,5 @@ Add any player-specific files (settings, saves, screenshots, logs) here so the u
|
|||||||
|
|
||||||
- File integrity is checked with MD5 — fine for detecting changed files, not intended as a cryptographic guarantee.
|
- 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.
|
- 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.
|
- `updater.py` uses Tkinter for the UI, which ships with most standard Python installs on Windows.
|
||||||
|
|||||||
@@ -18,25 +18,82 @@ HOST = CONFIG.get("host", "0.0.0.0")
|
|||||||
PORT = CONFIG.get("port", 3232)
|
PORT = CONFIG.get("port", 3232)
|
||||||
|
|
||||||
# Maps a URL access key (long hex string, see generate_key.py) -> physical
|
# 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
|
# folder on the server holding that client's files. A random hex key instead
|
||||||
# instead of a plain name like "main" just makes the files harder to stumble
|
# of a plain name like "main" just makes the update path harder to stumble
|
||||||
# upon by guessing; it does not stop someone who deliberately extracts the
|
# 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
|
# 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
|
# and update it here plus in the updater's CLIENT_KEYS - no need to touch
|
||||||
# folder names or anything else.
|
# folder names or anything else.
|
||||||
CLIENTS = CONFIG.get("clients", {})
|
CLIENTS = CONFIG.get("clients", {})
|
||||||
|
|
||||||
|
# Absolute, resolved base directories for each client key, computed once at
|
||||||
|
# startup. Every request is checked against these to make sure the final
|
||||||
|
# path can never land outside the intended client folder.
|
||||||
|
CLIENT_BASE_DIRS = {key: os.path.realpath(folder) for key, folder in CLIENTS.items()}
|
||||||
|
|
||||||
|
# A path that is guaranteed not to exist, used to force a 404 through the
|
||||||
|
# normal http.server flow instead of ever falling back to serving files
|
||||||
|
# from the script's own working directory.
|
||||||
|
NONEXISTENT_PATH = os.path.join(os.path.abspath(os.sep), "__no_such_path__")
|
||||||
|
|
||||||
|
|
||||||
class MultiClientHandler(SimpleHTTPRequestHandler):
|
class MultiClientHandler(SimpleHTTPRequestHandler):
|
||||||
def translate_path(self, path):
|
"""
|
||||||
|
Serves files only under a URL path that starts with a known access key,
|
||||||
|
e.g. /<hex-key>/manifest.json or /<hex-key>/some/file.
|
||||||
|
|
||||||
|
Everything else gets a 404:
|
||||||
|
- a bare "/" (the directory listing that exposes server.py, config.json,
|
||||||
|
the raw client folders, etc.)
|
||||||
|
- an unknown or missing key
|
||||||
|
- any path that tries to escape the client folder (e.g. via "..")
|
||||||
|
- directory listings even *inside* a valid client folder
|
||||||
|
|
||||||
|
This is enforced by never letting execution fall through to the default
|
||||||
|
SimpleHTTPRequestHandler behavior, which serves the process's current
|
||||||
|
working directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _resolve_client_path(self, path):
|
||||||
path = urllib.parse.urlparse(path).path
|
path = urllib.parse.urlparse(path).path
|
||||||
|
path = urllib.parse.unquote(path)
|
||||||
parts = [p for p in path.split('/') if p]
|
parts = [p for p in path.split('/') if p]
|
||||||
if len(parts) >= 1 and parts[0] in CLIENTS:
|
|
||||||
client_key = parts[0]
|
if not parts:
|
||||||
base_dir = CLIENTS[client_key]
|
return None
|
||||||
rel_path = posixpath.join(*parts[1:]) if len(parts) > 1 else ""
|
|
||||||
return os.path.join(base_dir, rel_path)
|
key = parts[0]
|
||||||
return super().translate_path(path)
|
if key not in CLIENT_BASE_DIRS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_dir = CLIENT_BASE_DIRS[key]
|
||||||
|
rel_parts = parts[1:]
|
||||||
|
rel_path = posixpath.join(*rel_parts) if rel_parts else ""
|
||||||
|
|
||||||
|
full_path = os.path.realpath(os.path.join(base_dir, rel_path))
|
||||||
|
|
||||||
|
# Confirm the resolved path is still inside base_dir (blocks "..",
|
||||||
|
# symlink tricks, etc.). commonpath needs both paths to exist on the
|
||||||
|
# same drive/root, which realpath + os.path.join guarantees here.
|
||||||
|
try:
|
||||||
|
if os.path.commonpath([full_path, base_dir]) != base_dir:
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
def translate_path(self, path):
|
||||||
|
resolved = self._resolve_client_path(path)
|
||||||
|
if resolved is None:
|
||||||
|
return NONEXISTENT_PATH
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def list_directory(self, path):
|
||||||
|
# Never render a directory listing, even for a path inside a valid
|
||||||
|
# client folder - only exact file paths are servable.
|
||||||
|
self.send_error(404, "Not Found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+68
-11
@@ -9,19 +9,33 @@ import subprocess
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox
|
from tkinter import ttk, messagebox
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PIL = False
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# CONFIGURATION - edit these values for your game/project
|
# CONFIGURATION - edit these values for your game/project
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
GAME_NAME = "My Game" # Shown in the window title
|
GAME_NAME = "My Game" # Shown in the window title
|
||||||
|
UPDATER_NAME = "Launcher" # Updater, Launcher, etc.
|
||||||
WINDOW_WIDTH = 450
|
WINDOW_WIDTH = 450
|
||||||
WINDOW_HEIGHT = 280
|
WINDOW_HEIGHT = 300
|
||||||
|
|
||||||
|
# Banner image is scaled to exactly this size (pixels) regardless of the
|
||||||
|
# source file's actual dimensions, so the layout never shifts if someone
|
||||||
|
# drops in a differently-sized image. Keep this narrower than WINDOW_WIDTH
|
||||||
|
# and leave room for the controls below it.
|
||||||
|
BANNER_WIDTH = 430
|
||||||
|
BANNER_HEIGHT = 140
|
||||||
|
|
||||||
# Server address. Use either a domain or an IP - whichever you set SERVER_ADDRESS to.
|
# Server address. Use either a domain or an IP - whichever you set SERVER_ADDRESS to.
|
||||||
# Examples: "update.mygame.com" or "123.456.78.90"
|
# Examples: "update.mygame.com" or "123.456.78.90"
|
||||||
SERVER_ADDRESS = "update.mygame.com"
|
SERVER_ADDRESS = "example.com"
|
||||||
SERVER_PORT = "3232"
|
SERVER_PORT = "443" # 80/443 for HTTP/HTTPS or servcer port for direct ip
|
||||||
USE_HTTPS = False # Set True if your server is behind HTTPS/SSL
|
USE_HTTPS = True # Set True if your server is behind HTTPS/SSL
|
||||||
|
|
||||||
# Available client versions: display name -> access key.
|
# Available client versions: display name -> access key.
|
||||||
# The access key is a long random hex string (generate with generate_key.py)
|
# The access key is a long random hex string (generate with generate_key.py)
|
||||||
@@ -33,8 +47,8 @@ USE_HTTPS = False # Set True if your server is behind HTTPS
|
|||||||
# generate a new one, update it here and in the server's config.json, and
|
# generate a new one, update it here and in the server's config.json, and
|
||||||
# rebuild the exe - no need to rename anything else.
|
# rebuild the exe - no need to rename anything else.
|
||||||
CLIENT_KEYS = {
|
CLIENT_KEYS = {
|
||||||
"Main": "d5af3f9fd06b15fcfcd1e6204b10804edbb9936b094ece123ce38ee7d3382ee0",
|
"Main": "d5af3f9fd06b15fcfcd1e6204b10xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
"Test": "aa697a8988d49f161c9764c3bb54b9111ad016bca7470243ea3aa2ee4fba37c1",
|
"Test": "aa697a8988d49f161c9764c3bb54xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
}
|
}
|
||||||
DEFAULT_CLIENT = "Main"
|
DEFAULT_CLIENT = "Main"
|
||||||
|
|
||||||
@@ -78,7 +92,7 @@ def build_server_base_url():
|
|||||||
class UpdaterApp:
|
class UpdaterApp:
|
||||||
def __init__(self, root):
|
def __init__(self, root):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.root.title(f"{GAME_NAME} Updater")
|
self.root.title(f"{GAME_NAME} {UPDATER_NAME}")
|
||||||
self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
|
self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
|
||||||
self.root.resizable(False, False)
|
self.root.resizable(False, False)
|
||||||
|
|
||||||
@@ -86,11 +100,15 @@ class UpdaterApp:
|
|||||||
self.root.iconbitmap(ICON_FILE)
|
self.root.iconbitmap(ICON_FILE)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
|
||||||
self.bg_image = tk.PhotoImage(file=BANNER_FILE)
|
self.bg_image = self._load_banner()
|
||||||
|
if self.bg_image is not None:
|
||||||
tk.Label(root, image=self.bg_image).pack(pady=5)
|
tk.Label(root, image=self.bg_image).pack(pady=5)
|
||||||
except Exception:
|
else:
|
||||||
tk.Label(root, text="[ Banner not found ]", bg="gray", width=60, height=6).pack(pady=5)
|
tk.Label(
|
||||||
|
root, text="[ Banner not found ]", bg="gray",
|
||||||
|
width=60, height=BANNER_HEIGHT // 20
|
||||||
|
).pack(pady=5)
|
||||||
|
|
||||||
# Client version selector
|
# Client version selector
|
||||||
mode_frame = tk.Frame(root)
|
mode_frame = tk.Frame(root)
|
||||||
@@ -124,6 +142,45 @@ class UpdaterApp:
|
|||||||
self.btn_play = tk.Button(btn_frame, text="Play", command=self.play_game, width=20, state=btn_state)
|
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)
|
self.btn_play.pack(side="left", padx=10)
|
||||||
|
|
||||||
|
def _load_banner(self):
|
||||||
|
"""
|
||||||
|
Loads BANNER_FILE and scales it to exactly (BANNER_WIDTH, BANNER_HEIGHT).
|
||||||
|
Uses Pillow for proper resizing when available; falls back to
|
||||||
|
tk.PhotoImage's coarse integer zoom/subsample if Pillow isn't
|
||||||
|
installed, so the app still runs without the extra dependency.
|
||||||
|
Returns None if the banner file is missing or fails to load.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(BANNER_FILE):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if HAS_PIL:
|
||||||
|
try:
|
||||||
|
img = Image.open(BANNER_FILE).convert("RGBA")
|
||||||
|
img = img.resize((BANNER_WIDTH, BANNER_HEIGHT), Image.LANCZOS)
|
||||||
|
return ImageTk.PhotoImage(img)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pillow not available: best-effort scaling with tk.PhotoImage.
|
||||||
|
# zoom/subsample only take integer factors, so this can't hit the
|
||||||
|
# target size exactly - it just gets as close as possible.
|
||||||
|
try:
|
||||||
|
image = tk.PhotoImage(file=BANNER_FILE)
|
||||||
|
src_w, src_h = image.width(), image.height()
|
||||||
|
if src_w <= 0 or src_h <= 0:
|
||||||
|
return image
|
||||||
|
|
||||||
|
if src_w > BANNER_WIDTH or src_h > BANNER_HEIGHT:
|
||||||
|
factor = max(1, round(max(src_w / BANNER_WIDTH, src_h / BANNER_HEIGHT)))
|
||||||
|
image = image.subsample(factor, factor)
|
||||||
|
elif src_w < BANNER_WIDTH and src_h < BANNER_HEIGHT:
|
||||||
|
factor = max(1, round(min(BANNER_WIDTH / src_w, BANNER_HEIGHT / src_h)))
|
||||||
|
image = image.zoom(factor, factor)
|
||||||
|
|
||||||
|
return image
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def update_ui(self, text=None, prog_val=None, prog_max=None):
|
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 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_max: self.root.after(0, lambda: self.progress.config(maximum=prog_max))
|
||||||
|
|||||||
Reference in New Issue
Block a user