diff --git a/remote_packages.py b/remote_packages.py index 7504265..b4cb36c 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -5,6 +5,7 @@ import shutil import stat import struct import tempfile +import time import urllib.error import urllib.request import uuid @@ -40,13 +41,25 @@ def _request(url, accept=None): return urllib.request.Request(url, headers=headers) +JSON_CACHE_TTL = 300 +_JSON_CACHE = {} + + def _get_json(url): + now = time.monotonic() + cached = _JSON_CACHE.get(url) + if cached and now - cached[0] < JSON_CACHE_TTL: + return cached[1] + try: with urllib.request.urlopen(_request(url, "application/vnd.github+json"), timeout=NETWORK_TIMEOUT) as response: - return json.loads(response.read().decode("utf-8")) + data = 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 + _JSON_CACHE[url] = (now, data) + return data + def _latest_release(repo): data = _get_json(f"{GITHUB_API}/repos/{repo}/releases/latest") @@ -55,6 +68,41 @@ def _latest_release(repo): return data +def _branch_head_sha(repo, branch): + data = _get_json(f"{GITHUB_API}/repos/{repo}/commits/{branch}") + sha = data.get("sha") if isinstance(data, dict) else None + if not isinstance(sha, str) or len(sha) < 7: + raise RemotePackageError(f"{repo}@{branch}: could not resolve branch revision.") + return sha + + +def _release_revision(release): + tag = release.get("tag_name") if isinstance(release, dict) else None + if isinstance(tag, str) and tag.strip(): + return tag.strip() + release_id = release.get("id") if isinstance(release, dict) else None + if release_id is not None: + return f"release:{release_id}" + return "latest" + + +def _release_asset_revision(release, asset): + """Track the actual release asset, even when an upstream reuses one tag.""" + base = _release_revision(release) + digest = _asset_sha256(asset) + if digest: + return f"{base}|sha256:{digest}" + + asset_id = asset.get("id") if isinstance(asset, dict) else None + updated = asset.get("updated_at") if isinstance(asset, dict) else None + size = asset.get("size") if isinstance(asset, dict) else None + name = asset.get("name") if isinstance(asset, dict) else None + return ( + f"{base}|asset:{asset_id or name or '?'}" + f"|updated:{updated or '?'}|size:{size or '?'}" + ) + + def _find_asset(release, exact_name=None, predicate=None): for asset in release.get("assets", []): name = asset.get("name", "") @@ -469,9 +517,7 @@ def _find_directory_with_file(root, filename): return None -def prepare_vanilla_tweaks(progress=None): - """Download and extract the latest stable tubtubs vanilla-tweaks Windows build.""" - _emit_progress(progress, "Checking vanilla-tweaks release...", None, None) +def vanilla_tweaks_release_info(): release = _latest_release("tubtubs/vanilla-tweaks") asset = _find_asset( release, @@ -481,6 +527,20 @@ def prepare_vanilla_tweaks(progress=None): and not name.lower().endswith(".sha256sum") ), ) + return { + "release": release, + "asset": asset, + "revision": _release_asset_revision(release, asset), + "version": release.get("name") or _release_revision(release), + } + + +def prepare_vanilla_tweaks(progress=None, release_info=None): + """Download and extract the latest stable tubtubs vanilla-tweaks Windows build.""" + _emit_progress(progress, "Checking vanilla-tweaks release...", None, None) + info = release_info or vanilla_tweaks_release_info() + release = info["release"] + asset = info["asset"] zip_path = _download_asset( asset, progress=progress, @@ -500,7 +560,7 @@ def prepare_vanilla_tweaks(progress=None): except OSError: pass - return exe_path, extract_root, release.get("name") or release.get("tag_name", "latest") + return exe_path, extract_root, info["version"], info["revision"] def _write_text_if_missing(path, text): @@ -565,8 +625,9 @@ def install_wowpresence(target_dir, progress=None): """Install or update WowPresence from its latest stable GitHub release ZIP.""" _emit_progress(progress, "Checking WowPresence release...", None, None) release = _latest_release(WOWPRESENCE_REPO) - revision = release.get("tag_name", "latest") + revision = _release_revision(release) package_asset = _find_asset(release, exact_name="WowPresence.zip") + package_revision = _release_asset_revision(release, package_asset) # Preserve whether dlls.txt already belonged to a standalone/manual # WowPresence install before this tool first takes ownership. @@ -602,6 +663,19 @@ def install_wowpresence(target_dir, progress=None): if not isinstance(saved_hashes, dict): saved_hashes = {} + saved_package_revision = manifest.get("package_revision") + current_digest = package_asset.get("digest") + saved_digest = manifest.get("package_digest") + package_matches = ( + str(saved_package_revision) == str(package_revision) + or ( + saved_package_revision in (None, "") + and isinstance(saved_digest, str) + and isinstance(current_digest, str) + and saved_digest == current_digest + ) + ) + def installed_file_ok(filename): path = os.path.join(target_dir, filename) expected = saved_hashes.get(filename) @@ -620,11 +694,13 @@ def install_wowpresence(target_dir, progress=None): dll_ok = installed_file_ok("WowPresence.dll") exe_ok = installed_file_ok("WowPresence.exe") - if dll_ok and exe_ok: + if dll_ok and exe_ok and package_matches: _set_managed_manifest_values( target_dir, WOWPRESENCE_MANAGED_ID, dlls_entry_preexisting=bool(dlls_entry_preexisting), + package_revision=package_revision, + package_digest=current_digest, ) _emit_progress( progress, @@ -676,6 +752,7 @@ def install_wowpresence(target_dir, progress=None): "WowPresence.exe": _file_sha256(exe_path), }, package_digest=package_asset.get("digest"), + package_revision=package_revision, ) finally: if zip_path and os.path.exists(zip_path): @@ -691,10 +768,19 @@ def install_wowpresence(target_dir, progress=None): ensure_wowpresence_config(target_dir) return revision + def install_interact(target_dir, progress=None): _emit_progress(progress, "Checking Interact release...", None, None) release = _latest_release("lookino/Interact") asset = _find_asset(release, exact_name="Interact.zip") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "interact" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"Interact {version} is already current.", None, None) + return version + zip_path = _download_asset(asset, progress=progress, label="Downloading Interact package") extract_root = tempfile.mkdtemp(prefix="modernization_interact_") try: @@ -718,6 +804,15 @@ def install_interact(target_dir, progress=None): ], label="Interact", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "Interact.dll", + os.path.join("Interface", "AddOns", "Interact"), + ], + ) finally: try: os.remove(zip_path) @@ -725,13 +820,27 @@ def install_interact(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") + return version + def install_vanilla_multimonitor_fix(target_dir, progress=None): _emit_progress(progress, "Checking VanillaMultiMonitorFix release...", None, None) release = _latest_release("Mates1500/VanillaMultiMonitorFix") asset = _find_asset(release, exact_name="release.zip") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "vanilla_multimonitor_fix" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"VanillaMultiMonitorFix {version} is already current.", + None, + None, + ) + return version + zip_path = _download_asset( asset, progress=progress, @@ -756,6 +865,15 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): if not os.path.exists(target_config): _emit_progress(progress, "Installing monitor preference file...", None, None) _atomic_replace_file(config_path, target_config) + + # The preference file is intentionally excluded from integrity tracking + # because it is user-editable. + _record_package_state_safely( + target_dir, + package_id, + revision, + ["VanillaMultiMonitorFix.dll"], + ) finally: try: os.remove(zip_path) @@ -763,9 +881,7 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") - - + return version MANAGED_ROOT = ".modernization_tool" @@ -880,6 +996,156 @@ def _file_sha256(path): return digest.hexdigest().lower() +PACKAGE_STATE_DIR = "package_state" + + +def _package_state_path(target_dir, package_id): + safe_id = "".join( + ch if ch.isalnum() or ch in ("-", "_", ".") else "_" + for ch in str(package_id) + ) + return os.path.join( + target_dir, + MANAGED_ROOT, + PACKAGE_STATE_DIR, + safe_id + ".json", + ) + + +def _hash_directory(path): + digest = hashlib.sha256() + if not os.path.isdir(path): + raise OSError(f"Directory does not exist: {path}") + + found_file = False + for current_root, dirs, files in os.walk(path): + dirs.sort(key=str.casefold) + files.sort(key=str.casefold) + for filename in files: + found_file = True + full_path = os.path.join(current_root, filename) + rel = os.path.relpath(full_path, path).replace(os.sep, "/") + digest.update(b"F\0") + digest.update(rel.encode("utf-8", "surrogatepass")) + digest.update(b"\0") + digest.update(_file_sha256(full_path).encode("ascii")) + digest.update(b"\0") + + if not found_file: + digest.update(b"EMPTY\0") + return digest.hexdigest().lower() + + +def _snapshot_package_paths(target_dir, relative_paths): + entries = {} + for relative in relative_paths: + rel = _safe_relative_path(relative) + full_path = os.path.join(target_dir, rel) + key = rel.replace(os.sep, "/") + + if os.path.isfile(full_path): + entries[key] = { + "type": "file", + "sha256": _file_sha256(full_path), + } + elif os.path.isdir(full_path): + entries[key] = { + "type": "dir", + "sha256": _hash_directory(full_path), + } + else: + raise OSError(f"Package path is missing: {full_path}") + return entries + + +def _load_package_state(target_dir, package_id): + path = _package_state_path(target_dir, package_id) + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + return data if isinstance(data, dict) else {} + except (OSError, json.JSONDecodeError, ValueError, TypeError): + return {} + + +def _record_package_state(target_dir, package_id, revision, relative_paths): + entries = _snapshot_package_paths(target_dir, relative_paths) + path = _package_state_path(target_dir, package_id) + os.makedirs(os.path.dirname(path), exist_ok=True) + temp_path = path + ".new" + payload = { + "schema": 1, + "package_id": str(package_id), + "revision": str(revision), + "paths": entries, + } + try: + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + return payload + + +def _package_state_is_current(target_dir, package_id, revision): + data = _load_package_state(target_dir, package_id) + if str(data.get("revision")) != str(revision): + return False + + paths = data.get("paths") + if not isinstance(paths, dict) or not paths: + return False + + try: + current = _snapshot_package_paths(target_dir, paths.keys()) + except OSError: + return False + return current == paths + + +def _record_package_state_safely(target_dir, package_id, revision, relative_paths): + try: + _record_package_state(target_dir, package_id, revision, relative_paths) + return True + except OSError: + # Update metadata is an optimization only. A successful component + # install must remain usable even if its cache state cannot be saved. + return False + + +def _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, +): + """Migrate an existing direct release asset without downloading it again.""" + expected_sha = _asset_sha256(asset) + if not expected_sha: + return False + + rel = _safe_relative_path(relative_path) + path = os.path.join(target_dir, rel) + if not os.path.isfile(path): + return False + + try: + if _file_sha256(path) != expected_sha: + return False + _record_package_state(target_dir, package_id, revision, [rel]) + return True + except OSError: + return False + + def _installed_asset_is_current(path, asset, label): """Validate an installed release asset without trusting existence alone.""" if not os.path.isfile(path): @@ -1389,9 +1655,21 @@ def install_fog_pushback(target_dir, progress=None): return "RetroCro mirror" + def install_pink_herbs(target_dir, progress=None): mod_id = "visual_pink_herbs" destination = os.path.join("Data", "patch-V.mpq") + revision = _branch_head_sha("seacrabsam/patch-herb", "main") + + if _package_state_is_current(target_dir, mod_id, revision): + _emit_progress( + progress, + f"Pink Herbs {revision[:7]} is already current.", + None, + None, + ) + return f"seacrabsam/patch-herb main@{revision[:7]}" + temp_path = _download( "https://raw.githubusercontent.com/seacrabsam/patch-herb/main/patch-H.mpq", suffix=".mpq", @@ -1414,13 +1692,19 @@ def install_pink_herbs(target_dir, progress=None): [(temp_path, destination)], revision=VISUAL_MOD_REVISIONS[mod_id], ) + _record_package_state_safely( + target_dir, + mod_id, + revision, + [destination], + ) finally: try: os.remove(temp_path) except OSError: pass - return "seacrabsam/patch-herb main" + return f"seacrabsam/patch-herb main@{revision[:7]}" def _download_github_branch_archive(repo, branch, progress=None, label="Downloading sound mod"): @@ -1456,7 +1740,18 @@ def _collect_tree_files(source_dir, destination_prefix): return mappings + def _install_github_sound_pack(target_dir, mod_id, repo, branch, source_folder, destination_prefix, progress=None, label="Downloading sound mod"): + revision = _branch_head_sha(repo, branch) + if _package_state_is_current(target_dir, mod_id, revision): + _emit_progress( + progress, + f"{label.replace('Downloading ', '')} is already current.", + None, + None, + ) + return revision + zip_path = _download_github_branch_archive( repo, branch, @@ -1471,12 +1766,19 @@ def _install_github_sound_pack(target_dir, mod_id, repo, branch, source_folder, mappings = _collect_tree_files(source_dir, destination_prefix) _emit_progress(progress, f"Installing {label.replace('Downloading ', '')}...", None, None) _install_managed_files_transactional(target_dir, mod_id, mappings) + _record_package_state_safely( + target_dir, + mod_id, + revision, + _load_managed_manifest(target_dir, mod_id), + ) finally: try: os.remove(zip_path) except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) + return revision def install_no_error_sounds(target_dir, progress=None): @@ -1521,6 +1823,7 @@ def install_warlock_muted_demons(target_dir, progress=None): return "spzilyk/Warlock-Muted-Demons main" + def install_nampower(target_dir, progress=None): _emit_progress(progress, "Checking Nampower release...", None, None) release = _latest_release("brues-code/nampower") @@ -1528,6 +1831,14 @@ def install_nampower(target_dir, progress=None): release, predicate=lambda name: name.lower().startswith("nampower-") and name.lower().endswith(".zip"), ) + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "nampower" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"Nampower {version} is already current.", None, None) + return version + zip_path = _download_asset(asset, progress=progress, label="Downloading Nampower package") extract_root = tempfile.mkdtemp(prefix="modernization_nampower_") try: @@ -1549,19 +1860,46 @@ def install_nampower(target_dir, progress=None): ], label="Nampower", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "nampower.dll", + os.path.join("Interface", "AddOns", "nampowersettings"), + ], + ) finally: try: os.remove(zip_path) except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") + return version def install_vanillahelpers(target_dir, progress=None): _emit_progress(progress, "Checking VanillaHelpers release...", None, None) release = _latest_release("isfir/VanillaHelpers") asset = _find_asset(release, exact_name="VanillaHelpers.dll") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "vanillahelpers" + relative_path = "VanillaHelpers.dll" + + if ( + _package_state_is_current(target_dir, package_id, revision) + or _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, + ) + ): + _emit_progress(progress, f"VanillaHelpers {version} is already current.", None, None) + return version + temp_path = _download_asset( asset, progress=progress, @@ -1570,14 +1908,33 @@ def install_vanillahelpers(target_dir, progress=None): try: _verify_x86_pe(temp_path, "VanillaHelpers.dll") _emit_progress(progress, "Installing VanillaHelpers.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "VanillaHelpers.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return release.get("tag_name", "latest") + return version def install_no1600x1200(target_dir, progress=None): _emit_progress(progress, "Checking no1600x1200 source...", None, None) + revision = _branch_head_sha("RetroCro/TurtleWoW-Mods", "main") + package_id = "no1600x1200" + relative_path = "no1600x1200.dll" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"no1600x1200 {revision[:7]} is already current.", + None, + None, + ) + return f"RetroCro/TurtleWoW-Mods main@{revision[:7]}" + url = ( "https://raw.githubusercontent.com/RetroCro/TurtleWoW-Mods/" "refs/heads/main/Archive/DLL%20BACKUP/no1600x1200.dll" @@ -1591,30 +1948,84 @@ def install_no1600x1200(target_dir, progress=None): try: _verify_x86_pe(temp_path, "no1600x1200.dll") _emit_progress(progress, "Installing no1600x1200.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "no1600x1200.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return "RetroCro/TurtleWoW-Mods main" + return f"RetroCro/TurtleWoW-Mods main@{revision[:7]}" + 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") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "classicapi" + relative_path = "ClassicAPI.dll" + + if ( + _package_state_is_current(target_dir, package_id, revision) + or _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, + ) + ): + _emit_progress(progress, f"ClassicAPI {version} is already current.", None, None) + return version + temp_path = _download_asset(asset, progress=progress, label="Downloading ClassicAPI.dll") try: _verify_x86_pe(temp_path, "ClassicAPI.dll") _emit_progress(progress, "Installing ClassicAPI.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "ClassicAPI.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return release.get("tag_name", "latest") + return version 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") + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "auction_query_throttle" + relative_path = "AuctionQueryThrottle.dll" + + if ( + _package_state_is_current(target_dir, package_id, revision) + or _record_release_asset_state_if_matching( + target_dir, + package_id, + revision, + relative_path, + asset, + ) + ): + _emit_progress( + progress, + f"AuctionQueryThrottle {version} is already current.", + None, + None, + ) + return version + temp_path = _download_asset( asset, progress=progress, @@ -1623,10 +2034,16 @@ def install_auction_query_throttle(target_dir, progress=None): try: _verify_x86_pe(temp_path, "AuctionQueryThrottle.dll") _emit_progress(progress, "Installing AuctionQueryThrottle.dll...", None, None) - _atomic_replace_file(temp_path, os.path.join(target_dir, "AuctionQueryThrottle.dll")) + _atomic_replace_file(temp_path, os.path.join(target_dir, relative_path)) + _record_package_state_safely( + target_dir, + package_id, + revision, + [relative_path], + ) finally: os.remove(temp_path) - return release.get("tag_name", "latest") + return version def install_unitxp(target_dir, progress=None): @@ -1636,6 +2053,14 @@ def install_unitxp(target_dir, progress=None): release, predicate=lambda name: name.lower().startswith("unitxp_sp3") and name.lower().endswith(".zip"), ) + revision = _release_asset_revision(release, asset) + version = _release_revision(release) + package_id = "unitxp_sp3" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"UnitXP_SP3 {version} is already current.", None, None) + return version + zip_path = _download_asset(asset, progress=progress, label="Downloading UnitXP_SP3 package") extract_root = tempfile.mkdtemp(prefix="modernization_unitxp_") try: @@ -1657,22 +2082,47 @@ def install_unitxp(target_dir, progress=None): ], label="UnitXP_SP3", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "UnitXP_SP3.dll", + os.path.join("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") + return version def install_superwow(target_dir, progress=None): _emit_progress(progress, "Checking SuperWoW release...", None, None) release = _latest_release("balakethelock/SuperWoW") + release_version = release.get("name") or _release_revision(release) asset = _find_asset( release, predicate=lambda name: name.lower().startswith("superwow") and name.lower().endswith(".zip"), ) + release_asset_revision = _release_asset_revision(release, asset) + + _emit_progress(progress, "Checking SuperAPI revision...", None, None) + superapi_revision = _branch_head_sha("balakethelock/SuperAPI", "master") + revision = f"{release_asset_revision}|superapi:{superapi_revision}" + package_id = "superwow" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"{release_version} + SuperAPI {superapi_revision[:7]} are already current.", + None, + None, + ) + return release_version wow_zip = None superapi_zip = None @@ -1725,6 +2175,15 @@ def install_superwow(target_dir, progress=None): ], label="SuperWoW + SuperAPI", ) + _record_package_state_safely( + target_dir, + package_id, + revision, + [ + "SuperWoWhook.dll", + os.path.join("Interface", "AddOns", "SuperAPI"), + ], + ) finally: for path in (wow_zip, superapi_zip): if path: @@ -1735,4 +2194,6 @@ def install_superwow(target_dir, progress=None): shutil.rmtree(wow_root, ignore_errors=True) shutil.rmtree(superapi_root, ignore_errors=True) - return release.get("name") or release.get("tag_name", "latest") + return release_version + + diff --git a/setup_tool.py b/setup_tool.py index 420fc37..7d58e64 100644 --- a/setup_tool.py +++ b/setup_tool.py @@ -1725,13 +1725,19 @@ class WowSetupTool: # is enough to trigger one fresh download when a source/version changes. for key, managed_id, display_name, installer in visual_defs: if self.visual_mods[key].get(): - revision = remote_packages.VISUAL_MOD_REVISIONS[managed_id] - if remote_packages.managed_mpq_is_current( - target, - managed_id, - revision, - ): - continue + # Hosted static mirrors use explicit revisions and can be + # skipped without any network request. Pink Herbs follows a + # GitHub branch, so its installer performs a lightweight + # branch-SHA check before deciding whether a download is + # needed. + if key != "pink_herbs": + revision = remote_packages.VISUAL_MOD_REVISIONS[managed_id] + if remote_packages.managed_mpq_is_current( + target, + managed_id, + revision, + ): + continue try: installer(target, progress=progress) diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 60d4479..b28752d 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -558,6 +558,7 @@ class ModernWowSetupTool(WowSetupTool): patcher_source, patcher_version, patcher_path, + patcher_revision=None, ): wow_exe = os.path.join(target, "WoW.exe") output_exe = os.path.join(target, "WoW_Modernized.exe") @@ -570,9 +571,12 @@ class ModernWowSetupTool(WowSetupTool): os.makedirs(os.path.dirname(marker_path), exist_ok=True) temp_path = marker_path + ".new" payload = { - "schema": 1, + "schema": 2, "patcher_source": patcher_source, "patcher_version": str(patcher_version), + "patcher_revision": ( + str(patcher_revision) if patcher_revision is not None else None + ), "patcher_sha256": ( self._file_sha256(patcher_path) if patcher_path and os.path.isfile(patcher_path) @@ -622,20 +626,25 @@ class ModernWowSetupTool(WowSetupTool): except (OSError, json.JSONDecodeError, ValueError, TypeError): return False, None + def run_vanilla_tweaks(self, target): - # Prefer the latest stable tubtubs build on every Apply. If the network - # is unavailable, preserve an already valid/current WoW_Modernized.exe - # instead of unnecessarily repatching it with an older bundled build. + # First compare the small GitHub release metadata with the patcher + # revision already recorded for this exact WoW.exe/settings output. + # The multi-megabyte Windows package is downloaded only when needed. + existing_matches, marker = self._existing_vanilla_tweaks_output_matches( + target + ) + try: - tweaks_exe, extract_root, version = remote_packages.prepare_vanilla_tweaks( - progress=self._report_download_progress, + self._report_download_progress( + "Checking vanilla-tweaks release...", + None, + None, ) + release_info = remote_packages.vanilla_tweaks_release_info() except Exception as exc: self._close_download_progress() - existing_matches, marker = self._existing_vanilla_tweaks_output_matches( - target - ) if existing_matches: patcher_version = ( marker.get("patcher_version", "previously installed") @@ -644,10 +653,9 @@ class ModernWowSetupTool(WowSetupTool): ) messagebox.showwarning( "Latest vanilla-tweaks unavailable", - "Could not download the latest tubtubs/vanilla-tweaks build.\n\n" + "Could not check the latest tubtubs/vanilla-tweaks build.\n\n" "Your existing WoW_Modernized.exe matches the current WoW.exe " - "and all executable patch settings, so it was kept unchanged " - f"instead of being repatched with the bundled fallback.\n\n" + "and all executable patch settings, so it was kept unchanged.\n\n" f"Existing patcher version: {patcher_version}\n\n" f"Details: {exc}", ) @@ -657,7 +665,81 @@ class ModernWowSetupTool(WowSetupTool): bundled_exe = os.path.join(get_base_path(), "vanilla-tweaks.exe") messagebox.showwarning( "Latest vanilla-tweaks unavailable", - "Could not download the latest tubtubs/vanilla-tweaks build.\n\n" + "Could not check the latest tubtubs/vanilla-tweaks build.\n\n" + "The current WoW.exe or executable patch settings need to be " + "repatched, so the bundled known-good vanilla-tweaks build will " + f"be used instead ({bundled['version']}).\n\n" + f"Details: {exc}", + ) + + result = super().run_vanilla_tweaks( + target, + tweaks_exe=bundled_exe, + modern_cli=True, + ) + self._write_vanilla_tweaks_marker( + target, + patcher_source="bundled", + patcher_version=bundled["version"], + patcher_path=bundled_exe, + ) + return result + + remote_revision = release_info["revision"] + remote_version = release_info["version"] + if existing_matches and isinstance(marker, dict): + same_online_patcher = ( + marker.get("patcher_source") == "online" + and ( + str(marker.get("patcher_revision")) == str(remote_revision) + or ( + marker.get("patcher_revision") in (None, "") + and str(marker.get("patcher_version")) == str(remote_version) + ) + ) + ) + if same_online_patcher: + self._report_download_progress( + f"vanilla-tweaks {remote_version} is already current.", + None, + None, + ) + self._close_download_progress() + return os.path.join(target, "WoW_Modernized.exe") + + try: + tweaks_exe, extract_root, version, revision = ( + remote_packages.prepare_vanilla_tweaks( + progress=self._report_download_progress, + release_info=release_info, + ) + ) + except Exception as exc: + self._close_download_progress() + + # A remote update may exist but be temporarily unavailable. Keep a + # valid existing output rather than downgrading or repatching it. + if existing_matches: + patcher_version = ( + marker.get("patcher_version", "previously installed") + if isinstance(marker, dict) + else "previously installed" + ) + messagebox.showwarning( + "Latest vanilla-tweaks unavailable", + "The latest vanilla-tweaks package could not be downloaded.\n\n" + "Your existing WoW_Modernized.exe still matches the current " + "WoW.exe and all executable patch settings, so it was kept unchanged.\n\n" + f"Existing patcher version: {patcher_version}\n\n" + f"Details: {exc}", + ) + return os.path.join(target, "WoW_Modernized.exe") + + bundled = self._bundled_vanilla_tweaks_info() + bundled_exe = os.path.join(get_base_path(), "vanilla-tweaks.exe") + messagebox.showwarning( + "Latest vanilla-tweaks unavailable", + "The latest vanilla-tweaks package could not be downloaded.\n\n" "The current WoW.exe or executable patch settings need to be " "repatched, so the bundled known-good vanilla-tweaks build will " f"be used instead ({bundled['version']}).\n\n" @@ -693,6 +775,7 @@ class ModernWowSetupTool(WowSetupTool): patcher_source="online", patcher_version=version, patcher_path=tweaks_exe, + patcher_revision=revision, ) return result finally: diff --git a/tests/test_safety.py b/tests/test_safety.py index c642215..4aad071 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -7,6 +7,7 @@ from unittest import mock import remote_packages import setup_tool +import setup_tool_dynamic from setup_tool import WowSetupTool @@ -612,6 +613,183 @@ class PeValidationTests(unittest.TestCase): remote_packages._verify_x86_pe(bad, "bad.dll") +class SmartUpdateTests(unittest.TestCase): + def test_package_state_tracks_revision_and_local_integrity(self): + with tempfile.TemporaryDirectory() as root: + dll = os.path.join(root, "Example.dll") + addon = os.path.join(root, "Interface", "AddOns", "Example") + os.makedirs(addon) + with open(dll, "wb") as handle: + handle.write(b"dll-v1") + with open(os.path.join(addon, "Example.toc"), "wb") as handle: + handle.write(b"addon-v1") + + remote_packages._record_package_state( + root, + "example", + "v1", + ["Example.dll", os.path.join("Interface", "AddOns", "Example")], + ) + + self.assertTrue( + remote_packages._package_state_is_current(root, "example", "v1") + ) + self.assertFalse( + remote_packages._package_state_is_current(root, "example", "v2") + ) + + with open(os.path.join(addon, "Example.toc"), "wb") as handle: + handle.write(b"modified") + self.assertFalse( + remote_packages._package_state_is_current(root, "example", "v1") + ) + + def test_release_asset_revision_detects_replaced_asset_under_same_tag(self): + release_a = { + "tag_name": "Release", + "assets": [ + { + "id": 1, + "name": "package.zip", + "updated_at": "2026-09-01T10:00:00Z", + "size": 100, + } + ], + } + release_b = { + "tag_name": "Release", + "assets": [ + { + "id": 2, + "name": "package.zip", + "updated_at": "2026-09-02T10:00:00Z", + "size": 120, + } + ], + } + + self.assertNotEqual( + remote_packages._release_asset_revision( + release_a, + release_a["assets"][0], + ), + remote_packages._release_asset_revision( + release_b, + release_b["assets"][0], + ), + ) + + def test_release_component_skips_download_when_revision_and_hashes_match(self): + with tempfile.TemporaryDirectory() as root: + target = os.path.join(root, "ClassicAPI.dll") + with open(target, "wb") as handle: + handle.write(b"already-installed") + + release = { + "tag_name": "v-test", + "assets": [ + { + "name": "ClassicAPI.dll", + "browser_download_url": "https://example.invalid/ClassicAPI.dll", + } + ], + } + revision = remote_packages._release_asset_revision( + release, + release["assets"][0], + ) + remote_packages._record_package_state( + root, + "classicapi", + revision, + ["ClassicAPI.dll"], + ) + + with mock.patch( + "remote_packages._latest_release", + return_value=release, + ), mock.patch( + "remote_packages._download_asset", + side_effect=AssertionError("unchanged component was downloaded"), + ) as download: + self.assertEqual( + remote_packages.install_classicapi(root), + "v-test", + ) + + download.assert_not_called() + + def test_branch_component_skips_archive_when_commit_and_files_match(self): + with tempfile.TemporaryDirectory() as root: + sound = os.path.join(root, "Sound", "example.wav") + os.makedirs(os.path.dirname(sound)) + with open(sound, "wb") as handle: + handle.write(b"sound") + + remote_packages._record_package_state( + root, + "audio_example", + "abcdef1234567890", + [os.path.join("Sound", "example.wav")], + ) + + with mock.patch( + "remote_packages._branch_head_sha", + return_value="abcdef1234567890", + ), mock.patch( + "remote_packages._download_github_branch_archive", + side_effect=AssertionError("unchanged branch archive was downloaded"), + ) as download: + revision = remote_packages._install_github_sound_pack( + root, + "audio_example", + "owner/repo", + "main", + "Sound", + "Sound", + ) + + self.assertEqual(revision, "abcdef1234567890") + download.assert_not_called() + + def test_vanilla_tweaks_skips_package_when_output_and_revision_match(self): + tool = setup_tool_dynamic.ModernWowSetupTool.__new__( + setup_tool_dynamic.ModernWowSetupTool + ) + tool._existing_vanilla_tweaks_output_matches = mock.Mock( + return_value=( + True, + { + "patcher_source": "online", + "patcher_version": "Vanilla Tweaks v1", + "patcher_revision": "v1", + }, + ) + ) + tool._report_download_progress = mock.Mock() + tool._close_download_progress = mock.Mock() + + release_info = { + "release": {}, + "asset": {}, + "revision": "v1", + "version": "Vanilla Tweaks v1", + } + + with tempfile.TemporaryDirectory() as root, mock.patch( + "setup_tool_dynamic.remote_packages.vanilla_tweaks_release_info", + return_value=release_info, + ), mock.patch( + "setup_tool_dynamic.remote_packages.prepare_vanilla_tweaks", + side_effect=AssertionError("unchanged vanilla-tweaks was downloaded"), + ) as prepare: + result = tool.run_vanilla_tweaks(root) + + self.assertEqual(result, os.path.join(root, "WoW_Modernized.exe")) + prepare.assert_not_called() + tool._close_download_progress.assert_called_once() + + class WowPresenceIntegrationTests(unittest.TestCase): def make_tool(self, selected=False): tool = WowSetupTool.__new__(WowSetupTool)