Files
simple-updater/updater.py
T
2026-09-09 12:30:40 +03:00

873 lines
38 KiB
Python

import os
import sys
import json
import gzip
import hashlib
import tempfile
import threading
import urllib.request
import urllib.parse
import subprocess
import configparser
import tkinter as tk
from tkinter import ttk, messagebox
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
try:
from PIL import Image, ImageTk
HAS_PIL = True
except ImportError:
HAS_PIL = False
# ============================================================
# CONFIGURATION - edit these values for your game/project
#
# These are the values baked into a compiled updater.exe. quickstart.py
# writes this whole block from config.json before running PyInstaller, so
# in normal use you edit config.json and re-run quickstart.py rather than
# touching this file by hand. See config.json / README for what each
# setting under updater_ui / connection / paths / manifest / self_update /
# signing_keys / access_keys maps to here.
#
# The block below is also read as a live fallback: if this script is run
# directly with `python updater.py` (not the compiled exe) and a
# config.json happens to sit next to it, values from config.json override
# these constants at import time - convenient for local testing without
# recompiling. A compiled exe never does this lookup (see
# CONFIG_OVERRIDES_APPLIED below) - the values below are simply what got
# baked in at build time, so a released exe never depends on a
# config.json existing on the player's machine.
# ============================================================
GAME_NAME = "My Game" # Shown in the window title
UPDATER_NAME = "Launcher" # Updater, Launcher, etc.
WINDOW_WIDTH = 450
WINDOW_HEIGHT = 280
# 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.
# Examples: "update.mygame.com" or "123.456.78.90"
SERVER_ADDRESS = "example.com"
SERVER_PORT = "443" # 80/443 for HTTP/HTTPS or servcer port for direct ip
USE_HTTPS = True # 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": "d5af3f9fd06b15fcfcd1e6204b10xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"Test": "aa697a8988d49f161c9764c3bb54xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
}
DEFAULT_CLIENT = "Main"
# Ed25519 PUBLIC key used to verify manifest.json's signature
# (manifest.json.sig). Must match the public half of the keypair whose
# PRIVATE half is used by generate_manifest.py's SIGNING_PRIVATE_KEY.
# Generate a keypair with:
# python generate_key.py --signing-keypair
#
# This is what actually protects players: even if the server is
# compromised, spoofed via DNS, or MITM'd over plain HTTP, the attacker
# cannot produce a manifest the updater will accept without the matching
# PRIVATE key - which never ships in this exe. Unlike a shared-secret
# (HMAC) scheme, this public key is not sensitive: decompiling updater.exe
# and recovering this value gives an attacker nothing they can use to
# forge a signature, only to verify one (which they could already do by
# just running the updater).
SIGNING_PUBLIC_KEY = None # e.g. "b3a1...64 hex chars (32 bytes)..."
# If True, the updater refuses to install anything from an unsigned or
# badly-signed manifest. Turning this off makes the whole signature
# mechanism pointless - only do this for local testing.
REQUIRE_SIGNATURE = True
# Hard cap on how large a single decompressed file is allowed to be before
# the updater gives up on the compressed copy and falls back to the plain
# download. Prevents a hostile/compromised server from serving a tiny .gz
# that decompresses into gigabytes and exhausts memory ("zip bomb").
MAX_DECOMPRESSED_SIZE = 512 * 1024 * 1024 # 512 MiB, adjust for your game
# 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"
SIGNATURE_FILENAME = MANIFEST_FILENAME + ".sig"
# Suffix used for precompressed files on the server (must match
# COMPRESSED_SUFFIX in generate_manifest.py).
COMPRESSED_SUFFIX = ".gz"
# Local settings file (created next to the updater on first run).
SETTINGS_FILENAME = "updater.ini"
# ------------------------------------------------------------
# Self-update (the updater updates itself, not just the game)
# ------------------------------------------------------------
# Bump this every time you rebuild updater.exe. Must match the "version"
# field you put in updater_manifest.json when you publish a new build.
# Purely a monotonically increasing integer - the updater does not try to
# parse semver or anything fancier than "is the server's number bigger
# than mine".
UPDATER_VERSION = 1
# Name of the self-update manifest on the server, fetched from the SAME
# access-key folder as the game manifest (so it inherits the same routing
# and the same signature verification - see _fetch_verified_json below).
# Deliberately a separate file from manifest.json: which updater build is
# current has nothing to do with which game-client version the player
# picked (Main/Test), so it shouldn't be duplicated per client folder or
# accidentally tied to game file hashes.
UPDATER_MANIFEST_FILENAME = "updater_manifest.json"
UPDATER_MANIFEST_SIGNATURE_FILENAME = UPDATER_MANIFEST_FILENAME + ".sig"
# If True, checks for a new updater.exe once per run, before checking game
# files. If a newer signed build is available, the updater replaces itself
# and relaunches - the game-file update is deferred to the *next* run of
# the new exe, so a self-update never happens in the middle of an
# in-progress game-file update.
ENABLE_SELF_UPDATE = True
# ============================================================
# END CONFIGURATION
# ============================================================
# ------------------------------------------------------------
# Optional live override from config.json - dev/test convenience only.
#
# This block NEVER runs inside a compiled exe (sys.frozen is True there,
# so it's skipped outright) - a released exe's behavior is fully decided
# by the constants above, baked in at build time by quickstart.py. This
# exists purely so `python updater.py` picks up config.json changes
# during local testing without needing a rebuild each time.
#
# Deliberately reads only signing_keys.public - config.json never
# contains a private key at all (it lives in a standalone .txt file that
# only generate_manifest.py / generate_updater_manifest.py touch), so
# there's no "private" field here to accidentally read even by mistake.
# This block only pulls the subset of config.json that's safe/meaningful
# for a client to know, mirroring exactly what quickstart.py bakes into a
# real build.
# ------------------------------------------------------------
CONFIG_OVERRIDES_APPLIED = False
if not getattr(sys, "frozen", False):
_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
if os.path.exists(_config_path):
try:
with open(_config_path, "r", encoding="utf-8") as _f:
_cfg = json.load(_f)
_ui = _cfg.get("updater_ui", {})
GAME_NAME = _ui.get("game_name", GAME_NAME)
UPDATER_NAME = _ui.get("updater_name", UPDATER_NAME)
WINDOW_WIDTH = _ui.get("window_width", WINDOW_WIDTH)
WINDOW_HEIGHT = _ui.get("window_height", WINDOW_HEIGHT)
BANNER_WIDTH = _ui.get("banner_width", BANNER_WIDTH)
BANNER_HEIGHT = _ui.get("banner_height", BANNER_HEIGHT)
_conn = _cfg.get("connection", {})
SERVER_ADDRESS = _conn.get("server_address", SERVER_ADDRESS)
SERVER_PORT = str(_conn.get("server_port", SERVER_PORT))
USE_HTTPS = _conn.get("use_https", USE_HTTPS)
_access_keys = _cfg.get("access_keys", {})
if _access_keys:
CLIENT_KEYS = {name: key for name, key in _access_keys.items() if key}
DEFAULT_CLIENT = _cfg.get("default_client", DEFAULT_CLIENT)
_signing = _cfg.get("signing_keys", {})
SIGNING_PUBLIC_KEY = _signing.get("public") or SIGNING_PUBLIC_KEY
REQUIRE_SIGNATURE = _cfg.get("require_signature", REQUIRE_SIGNATURE)
_paths = _cfg.get("paths", {})
GAME_EXE_RELATIVE_PATH = os.path.normpath(
_paths.get("game_exe_relative_path", GAME_EXE_RELATIVE_PATH)
)
BANNER_RELATIVE_PATH = _paths.get("banner_relative_path", BANNER_RELATIVE_PATH)
ICON_RELATIVE_PATH = _paths.get("icon_relative_path", ICON_RELATIVE_PATH)
SETTINGS_FILENAME = _paths.get("settings_filename", SETTINGS_FILENAME)
_manifest = _cfg.get("manifest", {})
MANIFEST_FILENAME = _manifest.get("manifest_filename", MANIFEST_FILENAME)
SIGNATURE_FILENAME = MANIFEST_FILENAME + ".sig"
COMPRESSED_SUFFIX = _manifest.get("compressed_suffix", COMPRESSED_SUFFIX)
MAX_DECOMPRESSED_SIZE = int(
_manifest.get("max_decompressed_size_mb", MAX_DECOMPRESSED_SIZE // (1024 * 1024))
) * 1024 * 1024
_self_update = _cfg.get("self_update", {})
ENABLE_SELF_UPDATE = _self_update.get("enabled", ENABLE_SELF_UPDATE)
UPDATER_VERSION = _self_update.get("updater_version", UPDATER_VERSION)
UPDATER_MANIFEST_FILENAME = _self_update.get(
"updater_manifest_filename", UPDATER_MANIFEST_FILENAME
)
UPDATER_MANIFEST_SIGNATURE_FILENAME = UPDATER_MANIFEST_FILENAME + ".sig"
CONFIG_OVERRIDES_APPLIED = True
except (OSError, json.JSONDecodeError, AttributeError, TypeError):
# Malformed config.json during dev testing shouldn't crash the
# app - just fall back to the constants above.
pass
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)
SETTINGS_FILE = os.path.join(BASE_PATH, SETTINGS_FILENAME)
class ManifestSecurityError(Exception):
"""Raised when the manifest fails signature verification or contains
an unsafe path. Always aborts the update - never partially applied."""
pass
def load_settings():
"""
Reads settings.ini next to the updater. If it doesn't exist yet (first
run) or is missing/invalid, creates it with default values and returns
those defaults. Unknown/corrupt values fall back to the default rather
than crashing the app.
"""
config = configparser.ConfigParser()
settings = {"client": DEFAULT_CLIENT}
if os.path.exists(SETTINGS_FILE):
try:
config.read(SETTINGS_FILE, encoding="utf-8")
client = config.get("updater", "client", fallback=DEFAULT_CLIENT)
if client in CLIENT_KEYS:
settings["client"] = client
except (configparser.Error, OSError):
pass # Fall back to defaults below and rewrite the file.
else:
save_settings(settings) # First run: create the file with defaults.
return settings
def save_settings(settings):
"""Writes updater.ini next to the updater. Failures are non-fatal."""
config = configparser.ConfigParser()
config["updater"] = {"client": settings.get("client", DEFAULT_CLIENT)}
try:
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
config.write(f)
except OSError:
pass # Not critical - the app just won't remember the choice.
def build_server_base_url():
scheme = "https" if USE_HTTPS else "http"
return f"{scheme}://{SERVER_ADDRESS}:{SERVER_PORT}/"
def verify_manifest_signature(manifest_bytes, signature_hex):
"""
Verifies manifest_bytes against signature_hex using Ed25519 and
SIGNING_PUBLIC_KEY. Returns True/False.
If SIGNING_PUBLIC_KEY isn't configured, returns True only when
REQUIRE_SIGNATURE is False (test/dev mode) - this keeps "no key
configured" from silently behaving as "always trusted" in a real
deployment.
Unlike an HMAC scheme, this key is asymmetric: SIGNING_PUBLIC_KEY
can only verify signatures, never produce them. So even a full
decompilation of this exe cannot yield anything usable to forge a
manifest - the private key needed for that never ships here.
"""
if not SIGNING_PUBLIC_KEY:
return not REQUIRE_SIGNATURE
if not signature_hex:
return False
try:
public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(SIGNING_PUBLIC_KEY))
public_key.verify(bytes.fromhex(signature_hex.strip()), manifest_bytes)
return True
except (InvalidSignature, ValueError):
return False
def sanitize_manifest_rel_path(rel_path):
"""
Validates a path taken from the (now-verified) manifest before it's
ever joined with BASE_PATH and written to disk. This is the fix for
the original path-traversal hole: previously os.path.normpath() alone
did NOT stop rel_path values like "../../whatever" or an absolute
path ("C:\\Windows\\..." or "/etc/...") from escaping the game folder
entirely, because normpath() only collapses redundant separators/dots -
it does not confine the result to any base directory.
Returns the normalized, safe, OS-specific relative path, or raises
ManifestSecurityError if the path tries to escape BASE_PATH in any way.
"""
if not rel_path or not isinstance(rel_path, str):
raise ManifestSecurityError(f"Invalid path entry: {rel_path!r}")
# Manifest paths are always forward-slash (see generate_manifest.py).
# Reject backslashes and drive letters outright - on Windows a value
# like "C:\\Windows\\System32\\x.dll" or "\\\\server\\share" must never
# reach os.path.join() as a "relative" path.
if "\\" in rel_path or ":" in rel_path:
raise ManifestSecurityError(f"Unsafe path in manifest: {rel_path!r}")
if rel_path.startswith("/"):
raise ManifestSecurityError(f"Absolute path in manifest: {rel_path!r}")
parts = rel_path.split("/")
if any(p in ("", ".", "..") for p in parts):
raise ManifestSecurityError(f"Path traversal attempt in manifest: {rel_path!r}")
local_path = os.path.normpath(os.path.join(BASE_PATH, *parts))
# Belt-and-braces: after normalization, confirm the result is still
# actually inside BASE_PATH. Catches anything the segment-level check
# above might have missed (e.g. platform-specific quirks).
base_real = os.path.realpath(BASE_PATH)
target_real = os.path.realpath(local_path)
try:
if os.path.commonpath([target_real, base_real]) != base_real:
raise ManifestSecurityError(f"Path escapes game folder: {rel_path!r}")
except ValueError:
# Different drives on Windows - definitely not inside BASE_PATH.
raise ManifestSecurityError(f"Path escapes game folder: {rel_path!r}")
return local_path
class UpdaterApp:
def __init__(self, root):
self.root = root
self.settings = load_settings()
self.root.title(f"{GAME_NAME} {UPDATER_NAME}")
self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
self.root.resizable(False, False)
try:
self.root.iconbitmap(ICON_FILE)
except Exception:
pass
self.bg_image = self._load_banner()
if self.bg_image is not None:
tk.Label(root, image=self.bg_image).pack(pady=5)
else:
tk.Label(
root, text="[ Banner not found ]", bg="gray",
width=60, height=BANNER_HEIGHT // 20
).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=self.settings["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)
self.mode_combo.bind("<<ComboboxSelected>>", self.on_client_changed)
# 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 _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 on_client_changed(self, event=None):
self.settings["client"] = self.mode_var.get()
save_settings(self.settings)
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 get_md5_bytes(self, data):
return hashlib.md5(data).hexdigest()
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 _fetch_verified_json(self, base_url, filename, signature_filename):
"""
Downloads <filename> + <signature_filename> from base_url, verifies
the Ed25519 signature over the raw manifest bytes, and only then
parses/returns it as JSON. Shared by both the game-file manifest
and the self-update manifest - same trust model, same failure mode
(raise ManifestSecurityError, never partially trust the content).
Raises ManifestSecurityError if verification fails (or is required
but the signature is missing). Raises normal exceptions (URLError,
json errors, ...) for ordinary network/parsing failures - callers
distinguish the two so a plain network hiccup doesn't get reported
as a security incident.
"""
data_url = base_url + filename
sig_url = base_url + signature_filename
req = urllib.request.urlopen(data_url, timeout=10)
raw_bytes = req.read()
signature_hex = None
try:
sig_req = urllib.request.urlopen(sig_url, timeout=10)
signature_hex = sig_req.read().decode("utf-8", errors="ignore").strip()
except urllib.error.HTTPError:
signature_hex = None # No .sig on server - handled below.
if not verify_manifest_signature(raw_bytes, signature_hex):
if SIGNING_PUBLIC_KEY and REQUIRE_SIGNATURE:
raise ManifestSecurityError(
f"{filename}: signature verification failed - refusing to use it. "
"The server may be compromised, or the signing keys are out of sync."
)
if REQUIRE_SIGNATURE:
raise ManifestSecurityError(
f"No SIGNING_PUBLIC_KEY configured but REQUIRE_SIGNATURE is True - refusing {filename}."
)
# REQUIRE_SIGNATURE is False and no key configured: dev/test mode.
return json.loads(raw_bytes.decode("utf-8"))
def _fetch_verified_manifest(self, current_server_url):
return self._fetch_verified_json(current_server_url, MANIFEST_FILENAME, SIGNATURE_FILENAME)
def _check_self_update(self, current_server_url):
"""
Checks updater_manifest.json (signed the same way as the game
manifest) for a newer build of this very updater.exe. If one is
available, downloads it, verifies its hash, and hands off to a
tiny helper script that does the actual file swap - because a
running Windows executable cannot overwrite or delete its own
.exe file while it's still executing.
Returns True if a self-update was launched (caller should stop
immediately and let the new process take over), False if there's
nothing to do (already current, feature disabled, or the check/
download failed - any failure here is non-fatal: it just means
we continue with the regular game-file update on this old build).
"""
if not ENABLE_SELF_UPDATE:
return False
if not getattr(sys, "frozen", False):
# Running as a plain .py script (dev mode) - there's no exe to
# replace, and re-launching "python updater.py" from a batch
# script would be more surprising than helpful. Self-update
# only makes sense for the compiled artifact.
return False
try:
self.update_ui(text="Checking for launcher updates...")
update_info = self._fetch_verified_json(
current_server_url, UPDATER_MANIFEST_FILENAME, UPDATER_MANIFEST_SIGNATURE_FILENAME
)
except ManifestSecurityError:
# Re-raise: an updater_manifest.json that exists but fails
# signature verification is treated as seriously as a game
# manifest that fails verification - it's the same signal
# (compromised/spoofed server) and should stop the update the
# same way, not be silently swallowed.
raise
except Exception:
# Ordinary network/parsing failure (server doesn't have the
# file yet, timeout, etc.) - not a security event, just skip
# self-update this run and continue with the game update.
return False
remote_version = update_info.get("version")
download_name = update_info.get("filename", "updater.exe")
expected_hash = update_info.get("hash")
if not isinstance(remote_version, int) or remote_version <= UPDATER_VERSION:
return False # Already current (or manifest is malformed - fail closed).
if not expected_hash:
return False # Malformed entry - fail closed rather than install unverified.
# download_name is attacker-influenced content (it comes from the
# server, same trust boundary as the game manifest's rel_path) -
# route it through the exact same sanitizer used for game files
# instead of trusting it to be a bare filename.
try:
sanitize_manifest_rel_path(download_name)
except ManifestSecurityError:
return False
try:
self.update_ui(text=f"Downloading launcher update v{remote_version}...")
current_exe = sys.executable
new_exe_path = current_exe + ".new"
exe_url = current_server_url + urllib.parse.quote(download_name, safe='/')
with urllib.request.urlopen(exe_url, timeout=120) as resp:
new_exe_data = resp.read(MAX_DECOMPRESSED_SIZE + 1)
if len(new_exe_data) > MAX_DECOMPRESSED_SIZE:
return False
if self.get_md5_bytes(new_exe_data) != expected_hash:
return False # Corrupt or tampered download - don't install it.
self._atomic_write(new_exe_path, new_exe_data)
self._launch_self_update_helper(current_exe, new_exe_path)
return True
except Exception:
# Any failure in the download/swap path - clean up and fall
# back to running the current build. Never leaves the update
# half-applied: the swap itself only happens in the helper
# script below, after this process has already exited.
try:
stray_new_path = sys.executable + ".new"
if os.path.exists(stray_new_path):
os.remove(stray_new_path)
except OSError:
pass
return False
def _launch_self_update_helper(self, current_exe, new_exe_path):
"""
Writes a small batch script next to the exe and launches it
detached, then this process should exit immediately (the caller
does that). The script:
1. Waits for this process to actually exit (a running Windows
exe can't be replaced in place - "del" on it fails with a
sharing violation until the handle is released), retrying
for a bounded number of attempts rather than looping forever.
2. Swaps updater.exe.new into place over updater.exe.
3. Relaunches the new exe.
4. Deletes itself (a batch file CAN delete itself as its last
action on Windows, since the OS only locks the file while a
command *within* it is being read, not the whole script).
This is Windows-only (matches the rest of the codebase's
os.startfile()/PyInstaller --onefile assumptions). On non-Windows
this is a no-op; self-update there would need a different
mechanism (e.g. exec() with a swapped path) that isn't
implemented here.
"""
if os.name != "nt":
return # Unsupported platform for this helper - caller treats as failure.
pid = os.getpid()
helper_path = current_exe + ".update_helper.bat"
# Quote paths for cmd.exe; exe paths under Program Files etc. can
# contain spaces.
script = (
"@echo off\r\n"
"setlocal\r\n"
f'set "CUR={current_exe}"\r\n'
f'set "NEW={new_exe_path}"\r\n'
f'set "PID={pid}"\r\n'
"set /a TRIES=0\r\n"
":waitloop\r\n"
"set /a TRIES+=1\r\n"
"if %TRIES% GTR 100 goto giveup\r\n"
# tasklist exits 0 and prints a row if the PID is still alive;
# findstr returns non-zero (errorlevel 1) if it finds nothing,
# which is the "process has exited, safe to proceed" case.
f'tasklist /FI "PID eq %PID%" 2^>nul | findstr /C:"%PID%" >nul\r\n'
"if %ERRORLEVEL%==0 (\r\n"
" timeout /t 1 /nobreak >nul\r\n"
" goto waitloop\r\n"
")\r\n"
'move /y "%NEW%" "%CUR%" >nul\r\n'
'start "" "%CUR%"\r\n'
"goto cleanup\r\n"
":giveup\r\n"
# Couldn't confirm the old process exited after ~100 tries -
# leave .new in place rather than risk swapping over a file
# that's still in use, and don't relaunch anything.
":cleanup\r\n"
'(goto) 2>nul & del "%~f0"\r\n'
)
with open(helper_path, "w", encoding="ascii", newline="") as f:
f.write(script)
# DETACHED_PROCESS + CREATE_NEW_PROCESS_GROUP so the helper survives
# this process exiting and isn't tied to our console/job.
DETACHED_PROCESS = 0x00000008
CREATE_NEW_PROCESS_GROUP = 0x00000200
subprocess.Popen(
["cmd.exe", "/c", helper_path],
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
close_fds=True,
)
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}/"
try:
self.update_ui(text=f"Connecting to server ({display_name})...")
if self._check_self_update(current_server_url):
# A new updater.exe is downloaded and the swap-and-relaunch
# helper is already launched (detached). This process must
# exit now without touching game files - the freshly
# relaunched exe will check game files on its own next.
self.update_ui(text="Launcher update found - restarting...")
self.root.after(300, self.root.destroy)
return
server_manifest = self._fetch_verified_manifest(current_server_url)
files_to_download = []
total_files = len(server_manifest)
self.update_ui(prog_max=total_files, prog_val=0)
for i, (rel_path, entry) in enumerate(server_manifest.items()):
self.update_ui(text=f"Checking: {rel_path}", prog_val=i)
# Validate BEFORE ever joining this into a filesystem path.
# A malicious/compromised server could otherwise use a key
# like "../../../Windows/System32/x.dll" to write outside
# the game folder entirely.
local_path = sanitize_manifest_rel_path(rel_path)
local_hash = self.get_md5(local_path)
if local_hash != entry["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)
entry = server_manifest[rel_path]
local_path = sanitize_manifest_rel_path(rel_path)
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self._download_file(current_server_url, rel_path, entry, 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 ManifestSecurityError as e:
# Security failures get their own message so they're visibly
# distinct from ordinary network hiccups - this should never
# be silently retried or ignored.
self.update_ui(text=f"Security error: {str(e)}")
self.root.after(0, lambda: messagebox.showerror("Update blocked", str(e)))
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 _atomic_write(self, local_path, data):
"""
Writes data to local_path via a temp file + os.replace() in the
same directory, so a crash or power loss mid-write can never leave
a half-written file sitting at the real path (which previously
could be picked up as "the file" on next launch, or briefly exist
in a corrupt state if the game happened to be running concurrently).
os.replace() is atomic on both Windows and POSIX when source and
destination are on the same filesystem, which they always are here
since the temp file is created right next to the target.
"""
directory = os.path.dirname(local_path) or "."
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp_upd_")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp_path, local_path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
def _download_file(self, base_url, rel_path, entry, local_path):
"""
Downloads one file. If the manifest marks it as compressed, fetches
the .gz copy, verifies its hash, and decompresses it into local_path.
Otherwise downloads the original file directly. Falls back to the
uncompressed original if the compressed copy fails verification,
download, or is too large once decompressed - so a bad/missing/
oversized .gz on the server doesn't block the update and can't be
used as a memory-exhaustion ("zip bomb") vector.
"""
if entry.get("compressed"):
gz_url = base_url + urllib.parse.quote(rel_path + COMPRESSED_SUFFIX, safe='/')
try:
with urllib.request.urlopen(gz_url, timeout=30) as resp:
compressed_data = resp.read()
if self.get_md5_bytes(compressed_data) != entry.get("compressed_hash"):
raise ValueError("compressed file hash mismatch")
# Guard against decompression bombs: decompress with a hard
# byte cap instead of gzip.decompress()'s unbounded
# "read everything into memory" behavior. If the real file
# is legitimately larger than the cap for your game, raise
# MAX_DECOMPRESSED_SIZE in the config block above.
expected_size = entry.get("size", 0)
if expected_size > MAX_DECOMPRESSED_SIZE:
raise ValueError("file exceeds MAX_DECOMPRESSED_SIZE, refusing to decompress")
decompressor = gzip.GzipFile(fileobj=__import__("io").BytesIO(compressed_data))
data = decompressor.read(MAX_DECOMPRESSED_SIZE + 1)
if len(data) > MAX_DECOMPRESSED_SIZE:
raise ValueError("decompressed data exceeded MAX_DECOMPRESSED_SIZE")
if self.get_md5_bytes(data) != entry["hash"]:
raise ValueError("decompressed file hash mismatch")
self._atomic_write(local_path, data)
return
except Exception:
# Fall through to downloading the uncompressed original below.
pass
safe_url_path = urllib.parse.quote(rel_path, safe='/')
file_url = base_url + safe_url_path
with urllib.request.urlopen(file_url, timeout=60) as resp:
data = resp.read(MAX_DECOMPRESSED_SIZE + 1)
if len(data) > MAX_DECOMPRESSED_SIZE:
raise ValueError(f"{rel_path} exceeds MAX_DECOMPRESSED_SIZE, aborting download")
if self.get_md5_bytes(data) != entry["hash"]:
raise ValueError(f"{rel_path}: downloaded file hash mismatch")
self._atomic_write(local_path, data)
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()