259 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			259 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
|   | import argparse | ||
|  | import zipfile | ||
|  | from io import BytesIO | ||
|  | 
 | ||
|  | import bsdiff4 | ||
|  | from datetime import datetime | ||
|  | import hashlib | ||
|  | import json | ||
|  | import logging | ||
|  | import os | ||
|  | import requests | ||
|  | import secrets | ||
|  | import shutil | ||
|  | import subprocess | ||
|  | from tkinter import messagebox | ||
|  | from typing import Any, Dict, Set | ||
|  | import urllib | ||
|  | import urllib.parse | ||
|  | 
 | ||
|  | import Utils | ||
|  | from .Constants import * | ||
|  | from . import SavingPrincessWorld | ||
|  | 
 | ||
|  | files_to_clean: Set[str] = { | ||
|  |     "D3DX9_43.dll", | ||
|  |     "data.win", | ||
|  |     "m_boss.ogg", | ||
|  |     "m_brainos.ogg", | ||
|  |     "m_coldarea.ogg", | ||
|  |     "m_escape.ogg", | ||
|  |     "m_hotarea.ogg", | ||
|  |     "m_hsis_dark.ogg", | ||
|  |     "m_hsis_power.ogg", | ||
|  |     "m_introarea.ogg", | ||
|  |     "m_malakhov.ogg", | ||
|  |     "m_miniboss.ogg", | ||
|  |     "m_ninja.ogg", | ||
|  |     "m_purple.ogg", | ||
|  |     "m_space_idle.ogg", | ||
|  |     "m_stonearea.ogg", | ||
|  |     "m_swamp.ogg", | ||
|  |     "m_zzz.ogg", | ||
|  |     "options.ini", | ||
|  |     "Saving Princess v0_8.exe", | ||
|  |     "splash.png", | ||
|  |     "gm-apclientpp.dll", | ||
|  |     "LICENSE", | ||
|  |     "original_data.win", | ||
|  |     "versions.json", | ||
|  | } | ||
|  | 
 | ||
|  | file_hashes: Dict[str, str] = { | ||
|  |     "D3DX9_43.dll": "86e39e9161c3d930d93822f1563c280d", | ||
|  |     "Saving Princess v0_8.exe": "cc3ad10c782e115d93c5b9fbc5675eaf", | ||
|  |     "original_data.win": "f97b80204bd9ae535faa5a8d1e5eb6ca", | ||
|  | } | ||
|  | 
 | ||
|  | 
 | ||
|  | class UrlResponse: | ||
|  |     def __init__(self, response_code: int, data: Any): | ||
|  |         self.response_code = response_code | ||
|  |         self.data = data | ||
|  | 
 | ||
|  | 
 | ||
|  | def get_date(target_asset: str) -> str: | ||
|  |     """Provided the name of an asset, fetches its update date""" | ||
|  |     try: | ||
|  |         with open("versions.json", "r") as versions_json: | ||
|  |             return json.load(versions_json)[target_asset] | ||
|  |     except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError): | ||
|  |         return "2000-01-01T00:00:00Z"  # arbitrary old date | ||
|  | 
 | ||
|  | 
 | ||
|  | def set_date(target_asset: str, date: str) -> None: | ||
|  |     """Provided the name of an asset and a date, sets it update date""" | ||
|  |     try: | ||
|  |         with open("versions.json", "r") as versions_json: | ||
|  |             versions = json.load(versions_json) | ||
|  |             versions[target_asset] = date | ||
|  |     except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError): | ||
|  |         versions = {target_asset: date} | ||
|  |     with open("versions.json", "w") as versions_json: | ||
|  |         json.dump(versions, versions_json) | ||
|  | 
 | ||
|  | 
 | ||
|  | def get_timestamp(date: str) -> float: | ||
|  |     """Parses a GitHub REST API date into a timestamp""" | ||
|  |     return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ").timestamp() | ||
|  | 
 | ||
|  | 
 | ||
|  | def send_request(request_url: str) -> UrlResponse: | ||
|  |     """Fetches status code and json response from given url""" | ||
|  |     response = requests.get(request_url) | ||
|  |     if response.status_code == 200:  # success | ||
|  |         try: | ||
|  |             data = response.json() | ||
|  |         except requests.exceptions.JSONDecodeError: | ||
|  |             raise RuntimeError(f"Unable to fetch data. (status code {response.status_code}).") | ||
|  |     else: | ||
|  |         data = {} | ||
|  |     return UrlResponse(response.status_code, data) | ||
|  | 
 | ||
|  | 
 | ||
|  | def update(target_asset: str, url: str) -> bool: | ||
|  |     """
 | ||
|  |     Returns True if the data was fetched and installed | ||
|  |         (or it was already on the latest version, or the user refused the update) | ||
|  |     Returns False if rate limit was exceeded | ||
|  |     """
 | ||
|  |     try: | ||
|  |         logging.info(f"Checking for {target_asset} updates.") | ||
|  |         response = send_request(url) | ||
|  |         if response.response_code == 403:  # rate limit exceeded | ||
|  |             return False | ||
|  |         assets = response.data[0]["assets"] | ||
|  |         for asset in assets: | ||
|  |             if target_asset in asset["name"]: | ||
|  |                 newest_date: str = asset["updated_at"] | ||
|  |                 release_url: str = asset["browser_download_url"] | ||
|  |                 break | ||
|  |         else: | ||
|  |             raise RuntimeError(f"Failed to locate {target_asset} amongst the assets.") | ||
|  |     except (KeyError, IndexError, TypeError, RuntimeError): | ||
|  |         update_error = f"Failed to fetch latest {target_asset}." | ||
|  |         messagebox.showerror("Failure", update_error) | ||
|  |         raise RuntimeError(update_error) | ||
|  |     try: | ||
|  |         update_available = get_timestamp(newest_date) > get_timestamp(get_date(target_asset)) | ||
|  |         if update_available and messagebox.askyesnocancel(f"New {target_asset}", | ||
|  |                                                           "Would you like to install the new version now?"): | ||
|  |             # unzip and patch | ||
|  |             with urllib.request.urlopen(release_url) as download: | ||
|  |                 with zipfile.ZipFile(BytesIO(download.read())) as zf: | ||
|  |                     zf.extractall() | ||
|  |             patch_game() | ||
|  |             set_date(target_asset, newest_date) | ||
|  |     except (ValueError, RuntimeError, urllib.error.HTTPError): | ||
|  |         update_error = f"Failed to apply update." | ||
|  |         messagebox.showerror("Failure", update_error) | ||
|  |         raise RuntimeError(update_error) | ||
|  |     return True | ||
|  | 
 | ||
|  | 
 | ||
|  | def patch_game() -> None: | ||
|  |     """Applies the patch to data.win""" | ||
|  |     logging.info("Proceeding to patch.") | ||
|  |     with open(PATCH_NAME, "rb") as patch: | ||
|  |         with open("original_data.win", "rb") as data: | ||
|  |             patched_data = bsdiff4.patch(data.read(), patch.read()) | ||
|  |         with open("data.win", "wb") as data: | ||
|  |             data.write(patched_data) | ||
|  |         logging.info("Done!") | ||
|  | 
 | ||
|  | 
 | ||
|  | def is_install_valid() -> bool: | ||
|  |     """Checks that the mandatory files that we cannot replace do exist in the current folder""" | ||
|  |     for file_name, expected_hash in file_hashes.items(): | ||
|  |         if not os.path.exists(file_name): | ||
|  |             return False | ||
|  |         with open(file_name, "rb") as clean: | ||
|  |             current_hash = hashlib.md5(clean.read()).hexdigest() | ||
|  |         if not secrets.compare_digest(current_hash, expected_hash): | ||
|  |             return False | ||
|  |     return True | ||
|  | 
 | ||
|  | 
 | ||
|  | def install() -> None: | ||
|  |     """Extracts all the game files into the mod installation folder""" | ||
|  |     logging.info("Mod installation missing or corrupted, proceeding to reinstall.") | ||
|  |     # get the cab file and extract it into the installation folder | ||
|  |     with open(SavingPrincessWorld.settings.exe_path, "rb") as exe: | ||
|  |         # find the cab header | ||
|  |         logging.info("Looking for cab archive inside exe.") | ||
|  |         cab_found: bool = False | ||
|  |         while not cab_found: | ||
|  |             cab_found = exe.read(1) == b'M' and exe.read(1) == b'S' and exe.read(1) == b'C' and exe.read(1) == b'F' | ||
|  |         exe.read(4)  # skip reserved1, always 0 | ||
|  |         cab_size: int = int.from_bytes(exe.read(4), "little")  # read size in bytes | ||
|  |         exe.seek(-12, 1)  # move the cursor back to the start of the cab file | ||
|  |         logging.info(f"Archive found at offset {hex(exe.seek(0, 1))}, size: {hex(cab_size)}.") | ||
|  |         logging.info("Extracting cab archive from exe.") | ||
|  |         with open("saving_princess.cab", "wb") as cab: | ||
|  |             cab.write(exe.read(cab_size)) | ||
|  | 
 | ||
|  |     # clean up files from previous installations | ||
|  |     for file_name in files_to_clean: | ||
|  |         if os.path.exists(file_name): | ||
|  |             os.remove(file_name) | ||
|  | 
 | ||
|  |     logging.info("Extracting files from cab archive.") | ||
|  |     if Utils.is_windows: | ||
|  |         subprocess.run(["Extrac32", "/Y", "/E", "saving_princess.cab"]) | ||
|  |     else: | ||
|  |         if shutil.which("wine") is not None: | ||
|  |             subprocess.run(["wine", "Extrac32", "/Y", "/E", "saving_princess.cab"]) | ||
|  |         elif shutil.which("7z") is not None: | ||
|  |             subprocess.run(["7z", "e", "saving_princess.cab"]) | ||
|  |         else: | ||
|  |             error = "Could not find neither wine nor 7z.\n\nPlease install either the wine or the p7zip package." | ||
|  |             messagebox.showerror("Missing package!", f"Error: {error}") | ||
|  |             raise RuntimeError(error) | ||
|  |     os.remove("saving_princess.cab")  # delete the cab file | ||
|  | 
 | ||
|  |     shutil.copyfile("data.win", "original_data.win")  # and make a copy of data.win | ||
|  |     logging.info("Done!") | ||
|  | 
 | ||
|  | 
 | ||
|  | def launch(*args: str) -> Any: | ||
|  |     """Check args, then the mod installation, then launch the game""" | ||
|  |     name: str = "" | ||
|  |     password: str = "" | ||
|  |     server: str = "" | ||
|  |     if args: | ||
|  |         parser = argparse.ArgumentParser(description=f"{GAME_NAME} Client Launcher") | ||
|  |         parser.add_argument("url", type=str, nargs="?", help="Archipelago Webhost uri to auto connect to.") | ||
|  |         args = parser.parse_args(args) | ||
|  | 
 | ||
|  |         # handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost | ||
|  |         if args.url: | ||
|  |             url = urllib.parse.urlparse(args.url) | ||
|  |             if url.scheme == "archipelago": | ||
|  |                 server = f'--server="{url.hostname}:{url.port}"' | ||
|  |                 if url.username: | ||
|  |                     name = f'--name="{urllib.parse.unquote(url.username)}"' | ||
|  |                 if url.password: | ||
|  |                     password = f'--password="{urllib.parse.unquote(url.password)}"' | ||
|  |             else: | ||
|  |                 parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281") | ||
|  | 
 | ||
|  |     Utils.init_logging(CLIENT_NAME, exception_logger="Client") | ||
|  | 
 | ||
|  |     os.chdir(SavingPrincessWorld.settings.install_folder) | ||
|  | 
 | ||
|  |     # check that the mod installation is valid | ||
|  |     if not is_install_valid(): | ||
|  |         if messagebox.askyesnocancel(f"Mod installation missing or corrupted!", | ||
|  |                                      "Would you like to reinstall now?"): | ||
|  |             install() | ||
|  |         # if there is no mod installation, and we are not installing it, then there isn't much to do | ||
|  |         else: | ||
|  |             return | ||
|  | 
 | ||
|  |     # check for updates | ||
|  |     if not update(DOWNLOAD_NAME, DOWNLOAD_URL): | ||
|  |         messagebox.showinfo("Rate limit exceeded", | ||
|  |                             "GitHub REST API limit exceeded, could not check for updates.\n\n" | ||
|  |                             "This will not prevent the game from being played if it was already playable.") | ||
|  | 
 | ||
|  |     # and try to launch the game | ||
|  |     if SavingPrincessWorld.settings.launch_game: | ||
|  |         logging.info("Launching game.") | ||
|  |         try: | ||
|  |             subprocess.Popen(f"{SavingPrincessWorld.settings.launch_command} {name} {password} {server}") | ||
|  |         except FileNotFoundError: | ||
|  |             error = ("Could not run the game!\n\n" | ||
|  |                      "Please check that launch_command in options.yaml or host.yaml is set up correctly.") | ||
|  |             messagebox.showerror("Command error!", f"Error: {error}") | ||
|  |             raise RuntimeError(error) |