Files
simple-updater/updater.py
T
2026-09-09 01:42:52 +03:00

365 lines
14 KiB
Python

import os
import sys
import json
import gzip
import hashlib
import threading
import urllib.request
import urllib.parse
import subprocess
import configparser
import tkinter as tk
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
# ============================================================
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"
# 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"
# 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"
# ============================================================
# 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)
SETTINGS_FILE = os.path.join(BASE_PATH, SETTINGS_FILENAME)
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}/"
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 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, entry) 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 != 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 = os.path.join(BASE_PATH, os.path.normpath(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 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 _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 or
download, so a bad/missing .gz on the server doesn't block the update.
"""
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")
data = gzip.decompress(compressed_data)
if self.get_md5_bytes(data) != entry["hash"]:
raise ValueError("decompressed file hash mismatch")
with open(local_path, "wb") as f:
f.write(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
urllib.request.urlretrieve(file_url, local_path)
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()