From 343f3b3ce3b04f5ac2713e1187751fe63b236fa3 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Thu, 27 Aug 2026 08:49:05 +0200 Subject: [PATCH] ui: report download progress and harden addon replacement --- remote_packages.py | 153 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 128 insertions(+), 25 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index baafc15..115eec2 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -2,9 +2,11 @@ import hashlib import json import os import shutil +import stat import tempfile import urllib.error import urllib.request +import uuid import zipfile USER_AGENT = "Modernization-Tool/1.0 (+https://github.com/Dusk-92/Modernization-Tool)" @@ -16,6 +18,16 @@ class RemotePackageError(RuntimeError): pass +def _emit_progress(callback, message, current=None, total=None): + if callback is None: + return + try: + callback(message, current, total) + except Exception: + # UI/progress reporting must never break an installation. + pass + + def _request(url, accept=None): headers = {"User-Agent": USER_AGENT} if accept: @@ -49,18 +61,33 @@ def _find_asset(release, exact_name=None, predicate=None): raise RemotePackageError(f"Could not find {wanted} in release {release.get('tag_name', '?')}.") -def _download(url, suffix="", expected_digest=None): +def _download(url, suffix="", expected_digest=None, progress=None, label="Downloading"): 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: + total = None + length = response.headers.get("Content-Length") + if length: + try: + total = int(length) + except (TypeError, ValueError): + total = None + + downloaded = 0 + _emit_progress(progress, label, downloaded, total) + while True: - chunk = response.read(1024 * 1024) + chunk = response.read(256 * 1024) if not chunk: break out.write(chunk) digest.update(chunk) + downloaded += len(chunk) + _emit_progress(progress, label, downloaded, total) + + _emit_progress(progress, label, downloaded, total or downloaded) if expected_digest and expected_digest.startswith("sha256:"): expected = expected_digest.split(":", 1)[1].lower() @@ -78,13 +105,20 @@ def _download(url, suffix="", expected_digest=None): raise -def _download_asset(asset): +def _download_asset(asset, progress=None, label=None): 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] + display = label or f"Downloading {asset.get('name', 'release asset')}" try: - return _download(url, suffix=suffix, expected_digest=asset.get("digest")) + return _download( + url, + suffix=suffix, + expected_digest=asset.get("digest"), + progress=progress, + label=display, + ) except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc: raise RemotePackageError(f"Download failed for {asset.get('name', '?')}: {exc}") from exc @@ -103,30 +137,78 @@ def _atomic_replace_file(source, target): pass +def _remove_tree(path): + if not os.path.exists(path): + return + + def onerror(func, failing_path, _exc_info): + try: + os.chmod(failing_path, stat.S_IWRITE) + func(failing_path) + except Exception: + raise + + shutil.rmtree(path, onerror=onerror) + + def _replace_directory(source_dir, target_dir): + """Replace an addon directory without reusing stale fixed backup names.""" 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) + token = uuid.uuid4().hex[:10] + staged = f"{target_dir}.modernization-new-{token}" + backup = f"{target_dir}.modernization-backup-{token}" + shutil.copytree(source_dir, staged) - had_existing = os.path.exists(target_dir) + backup_created = False + try: if had_existing: - os.replace(target_dir, backup) + try: + os.replace(target_dir, backup) + backup_created = True + except PermissionError: + # Windows can refuse directory renames in some addon folders. + # Fall back to a direct remove-and-replace after the new copy has + # already been fully staged. + try: + _remove_tree(target_dir) + except Exception as exc: + raise RemotePackageError( + f"Could not replace {target_dir}. Close WoW and any program " + f"using this addon folder, then try again. ({exc})" + ) from exc + os.replace(staged, target_dir) - shutil.rmtree(backup, ignore_errors=True) + + if backup_created and os.path.exists(backup): + _remove_tree(backup) + 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) + if backup_created and os.path.exists(backup): + if os.path.exists(target_dir): + try: + _remove_tree(target_dir) + except Exception: + pass + if not os.path.exists(target_dir): + try: + os.replace(backup, target_dir) + except Exception: + pass raise finally: - shutil.rmtree(staged, ignore_errors=True) - shutil.rmtree(backup, ignore_errors=True) + if os.path.exists(staged): + try: + _remove_tree(staged) + except Exception: + pass + if os.path.exists(backup): + try: + _remove_tree(backup) + except Exception: + pass def _safe_extract(zip_path, destination): @@ -157,42 +239,54 @@ def _find_directory(root, dirname): raise RemotePackageError(f"{dirname} was not found in downloaded archive.") -def install_classicapi(target_dir): +def install_classicapi(target_dir, progress=None): + _emit_progress(progress, "Checking ClassicAPI release...", None, None) release = _latest_release("brues-code/ClassicAPI") asset = _find_asset(release, exact_name="ClassicAPI.dll") - temp_path = _download_asset(asset) + temp_path = _download_asset(asset, progress=progress, label="Downloading ClassicAPI.dll") try: + _emit_progress(progress, "Installing ClassicAPI.dll...", None, None) _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): +def install_auction_query_throttle(target_dir, progress=None): + _emit_progress(progress, "Checking AuctionQueryThrottle release...", None, None) release = _latest_release("brues-code/AuctionQueryThrottle") asset = _find_asset(release, exact_name="AuctionQueryThrottle.dll") - temp_path = _download_asset(asset) + temp_path = _download_asset( + asset, + progress=progress, + label="Downloading AuctionQueryThrottle.dll", + ) try: + _emit_progress(progress, "Installing AuctionQueryThrottle.dll...", None, None) _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): +def install_unitxp(target_dir, progress=None): + _emit_progress(progress, "Checking UnitXP_SP3 release...", None, None) 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) + zip_path = _download_asset(asset, progress=progress, label="Downloading UnitXP_SP3 package") extract_root = tempfile.mkdtemp(prefix="modernization_unitxp_") try: + _emit_progress(progress, "Extracting UnitXP_SP3...", None, None) _safe_extract(zip_path, extract_root) dll_path = _find_file(extract_root, "UnitXP_SP3.dll") addon_dir = _find_directory(extract_root, "UnitXP_SP3_Addon") + _emit_progress(progress, "Installing UnitXP_SP3.dll...", None, None) _atomic_replace_file(dll_path, os.path.join(target_dir, "UnitXP_SP3.dll")) + _emit_progress(progress, "Installing UnitXP_SP3_Addon...", None, None) _replace_directory( addon_dir, os.path.join(target_dir, "Interface", "AddOns", "UnitXP_SP3_Addon"), @@ -206,17 +300,20 @@ def install_unitxp(target_dir): return release.get("tag_name", "latest") -def install_superwow(target_dir): +def install_superwow(target_dir, progress=None): + _emit_progress(progress, "Checking SuperWoW release...", None, None) 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) + zip_path = _download_asset(asset, progress=progress, label="Downloading SuperWoW package") extract_root = tempfile.mkdtemp(prefix="modernization_superwow_") try: + _emit_progress(progress, "Extracting SuperWoW...", None, None) _safe_extract(zip_path, extract_root) dll_path = _find_file(extract_root, "SuperWoWhook.dll") + _emit_progress(progress, "Installing SuperWoWhook.dll...", None, None) _atomic_replace_file(dll_path, os.path.join(target_dir, "SuperWoWhook.dll")) finally: try: @@ -227,12 +324,16 @@ def install_superwow(target_dir): # SuperAPI has no GitHub Releases. Install the current master branch exactly # as its author documents, stripping GitHub's "-master" archive suffix. + _emit_progress(progress, "Preparing SuperAPI update...", None, None) superapi_zip = _download( "https://codeload.github.com/balakethelock/SuperAPI/zip/refs/heads/master", suffix=".zip", + progress=progress, + label="Downloading SuperAPI addon", ) superapi_root = tempfile.mkdtemp(prefix="modernization_superapi_") try: + _emit_progress(progress, "Extracting SuperAPI...", None, None) _safe_extract(superapi_zip, superapi_root) addon_root = None for current_root, _, files in os.walk(superapi_root): @@ -241,6 +342,8 @@ def install_superwow(target_dir): break if addon_root is None: raise RemotePackageError("SuperAPI.toc was not found in the SuperAPI archive.") + + _emit_progress(progress, "Installing SuperAPI addon...", None, None) _replace_directory( addon_root, os.path.join(target_dir, "Interface", "AddOns", "SuperAPI"),