Files
simple-updater/updater.py
T
2026-09-08 15:49:48 +03:00

278 lines
11 KiB
Python

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()