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 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 = 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. # 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" # ============================================================ # 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_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=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 _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): 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()