Files
Modernization-Tool/remote_packages.py
T

256 lines
8.9 KiB
Python

import hashlib
import json
import os
import shutil
import tempfile
import urllib.error
import urllib.request
import zipfile
USER_AGENT = "Modernization-Tool/1.0 (+https://github.com/Dusk-92/Modernization-Tool)"
GITHUB_API = "https://api.github.com"
NETWORK_TIMEOUT = 30
class RemotePackageError(RuntimeError):
pass
def _request(url, accept=None):
headers = {"User-Agent": USER_AGENT}
if accept:
headers["Accept"] = accept
return urllib.request.Request(url, headers=headers)
def _get_json(url):
try:
with urllib.request.urlopen(_request(url, "application/vnd.github+json"), timeout=NETWORK_TIMEOUT) as response:
return json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as exc:
raise RemotePackageError(f"GitHub request failed: {exc}") from exc
def _latest_release(repo):
data = _get_json(f"{GITHUB_API}/repos/{repo}/releases/latest")
if data.get("draft") or data.get("prerelease"):
raise RemotePackageError(f"{repo}: latest release is not a stable release.")
return data
def _find_asset(release, exact_name=None, predicate=None):
for asset in release.get("assets", []):
name = asset.get("name", "")
if exact_name is not None and name == exact_name:
return asset
if predicate is not None and predicate(name):
return asset
wanted = exact_name or "matching release asset"
raise RemotePackageError(f"Could not find {wanted} in release {release.get('tag_name', '?')}.")
def _download(url, suffix="", expected_digest=None):
fd, temp_path = tempfile.mkstemp(prefix="modernization_", suffix=suffix)
os.close(fd)
try:
digest = hashlib.sha256()
with urllib.request.urlopen(_request(url), timeout=NETWORK_TIMEOUT) as response, open(temp_path, "wb") as out:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
out.write(chunk)
digest.update(chunk)
if expected_digest and expected_digest.startswith("sha256:"):
expected = expected_digest.split(":", 1)[1].lower()
actual = digest.hexdigest().lower()
if actual != expected:
raise RemotePackageError(
f"SHA-256 mismatch for downloaded file (expected {expected}, got {actual})."
)
return temp_path
except Exception:
try:
os.remove(temp_path)
except OSError:
pass
raise
def _download_asset(asset):
url = asset.get("browser_download_url")
if not url:
raise RemotePackageError(f"Release asset {asset.get('name', '?')} has no download URL.")
suffix = os.path.splitext(asset.get("name", ""))[1]
try:
return _download(url, suffix=suffix, expected_digest=asset.get("digest"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc:
raise RemotePackageError(f"Download failed for {asset.get('name', '?')}: {exc}") from exc
def _atomic_replace_file(source, target):
os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True)
staged = target + ".modernization-new"
try:
shutil.copy2(source, staged)
os.replace(staged, target)
finally:
if os.path.exists(staged):
try:
os.remove(staged)
except OSError:
pass
def _replace_directory(source_dir, target_dir):
os.makedirs(os.path.dirname(os.path.abspath(target_dir)), exist_ok=True)
staged = target_dir + ".modernization-new"
backup = target_dir + ".modernization-backup"
shutil.rmtree(staged, ignore_errors=True)
shutil.rmtree(backup, ignore_errors=True)
shutil.copytree(source_dir, staged)
had_existing = os.path.exists(target_dir)
try:
if had_existing:
os.replace(target_dir, backup)
os.replace(staged, target_dir)
shutil.rmtree(backup, ignore_errors=True)
except Exception:
if os.path.exists(target_dir):
shutil.rmtree(target_dir, ignore_errors=True)
if had_existing and os.path.exists(backup):
os.replace(backup, target_dir)
raise
finally:
shutil.rmtree(staged, ignore_errors=True)
shutil.rmtree(backup, ignore_errors=True)
def _safe_extract(zip_path, destination):
root = os.path.realpath(destination)
with zipfile.ZipFile(zip_path) as archive:
for member in archive.infolist():
candidate = os.path.realpath(os.path.join(destination, member.filename))
if candidate != root and not candidate.startswith(root + os.sep):
raise RemotePackageError(f"Unsafe ZIP path: {member.filename}")
archive.extractall(destination)
def _find_file(root, filename):
wanted = filename.lower()
for current_root, _, files in os.walk(root):
for item in files:
if item.lower() == wanted:
return os.path.join(current_root, item)
raise RemotePackageError(f"{filename} was not found in downloaded archive.")
def _find_directory(root, dirname):
wanted = dirname.lower()
for current_root, dirs, _ in os.walk(root):
for item in dirs:
if item.lower() == wanted:
return os.path.join(current_root, item)
raise RemotePackageError(f"{dirname} was not found in downloaded archive.")
def install_classicapi(target_dir):
release = _latest_release("brues-code/ClassicAPI")
asset = _find_asset(release, exact_name="ClassicAPI.dll")
temp_path = _download_asset(asset)
try:
_atomic_replace_file(temp_path, os.path.join(target_dir, "ClassicAPI.dll"))
finally:
os.remove(temp_path)
return release.get("tag_name", "latest")
def install_auction_query_throttle(target_dir):
release = _latest_release("brues-code/AuctionQueryThrottle")
asset = _find_asset(release, exact_name="AuctionQueryThrottle.dll")
temp_path = _download_asset(asset)
try:
_atomic_replace_file(temp_path, os.path.join(target_dir, "AuctionQueryThrottle.dll"))
finally:
os.remove(temp_path)
return release.get("tag_name", "latest")
def install_unitxp(target_dir):
release = _latest_release("brues-code/UnitXP_SP3")
asset = _find_asset(
release,
predicate=lambda name: name.lower().startswith("unitxp_sp3-") and name.lower().endswith(".zip"),
)
zip_path = _download_asset(asset)
extract_root = tempfile.mkdtemp(prefix="modernization_unitxp_")
try:
_safe_extract(zip_path, extract_root)
dll_path = _find_file(extract_root, "UnitXP_SP3.dll")
addon_dir = _find_directory(extract_root, "UnitXP_SP3_Addon")
_atomic_replace_file(dll_path, os.path.join(target_dir, "UnitXP_SP3.dll"))
_replace_directory(
addon_dir,
os.path.join(target_dir, "Interface", "AddOns", "UnitXP_SP3_Addon"),
)
finally:
try:
os.remove(zip_path)
except OSError:
pass
shutil.rmtree(extract_root, ignore_errors=True)
return release.get("tag_name", "latest")
def install_superwow(target_dir):
release = _latest_release("balakethelock/SuperWoW")
asset = _find_asset(
release,
predicate=lambda name: name.lower().startswith("superwow") and name.lower().endswith(".zip"),
)
zip_path = _download_asset(asset)
extract_root = tempfile.mkdtemp(prefix="modernization_superwow_")
try:
_safe_extract(zip_path, extract_root)
dll_path = _find_file(extract_root, "SuperWoWhook.dll")
_atomic_replace_file(dll_path, os.path.join(target_dir, "SuperWoWhook.dll"))
finally:
try:
os.remove(zip_path)
except OSError:
pass
shutil.rmtree(extract_root, ignore_errors=True)
# SuperAPI has no GitHub Releases. Install the current master branch exactly
# as its author documents, stripping GitHub's "-master" archive suffix.
superapi_zip = _download(
"https://codeload.github.com/balakethelock/SuperAPI/zip/refs/heads/master",
suffix=".zip",
)
superapi_root = tempfile.mkdtemp(prefix="modernization_superapi_")
try:
_safe_extract(superapi_zip, superapi_root)
addon_root = None
for current_root, _, files in os.walk(superapi_root):
if "SuperAPI.toc" in files:
addon_root = current_root
break
if addon_root is None:
raise RemotePackageError("SuperAPI.toc was not found in the SuperAPI archive.")
_replace_directory(
addon_root,
os.path.join(target_dir, "Interface", "AddOns", "SuperAPI"),
)
finally:
try:
os.remove(superapi_zip)
except OSError:
pass
shutil.rmtree(superapi_root, ignore_errors=True)
return release.get("name") or release.get("tag_name", "latest")