This commit is contained in:
2026-09-08 09:24:35 +03:00
commit 19d8e1111a
6 changed files with 530 additions and 0 deletions
+221
View File
@@ -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()