365 lines
14 KiB
Python
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()
|