From b2ed9782fcadf2bef59159f0a05383e98bc42f98 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 11:25:48 +0200 Subject: [PATCH 01/18] Add WowPresence detail mask helpers --- remote_packages.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/remote_packages.py b/remote_packages.py index 7504265..2c61b96 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -18,6 +18,15 @@ WOWPRESENCE_MANAGED_ID = "wowpresence" WOWPRESENCE_DEFAULT_APPLICATION_ID = "1544072796098011176" WOWPRESENCE_APPLICATION_ID_PLACEHOLDER = "PASTE_YOUR_DISCORD_APPLICATION_ID_HERE" +WOWPRESENCE_SHARE_NAME = 1 +WOWPRESENCE_SHARE_GUILD = 2 +WOWPRESENCE_SHARE_FACTION = 4 +WOWPRESENCE_SHARE_CLASS = 8 +WOWPRESENCE_SHARE_LEVEL = 16 +WOWPRESENCE_SHARE_ZONE = 32 +WOWPRESENCE_SHARE_RACE = 64 +WOWPRESENCE_SHARE_ALL = 127 + class RemotePackageError(RuntimeError): pass @@ -561,6 +570,51 @@ def ensure_wowpresence_config(target_dir): return data_dir +def read_wowpresence_broadcast_flags(target_dir): + """Read the user-visible WowPresence detail mask, or None when invalid.""" + path = os.path.join( + target_dir, + ".modernization_tool", + "WowPresence", + "discord_broadcast_flags", + ) + try: + with open(path, "r", encoding="ascii", errors="ignore") as handle: + text = handle.read().strip() + value = int(text, 10) + except (OSError, ValueError): + return None + + if value < 0: + return None + return value & WOWPRESENCE_SHARE_ALL + + +def write_wowpresence_broadcast_flags(target_dir, value): + """Atomically persist the WowPresence detail mask without touching other config.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("WowPresence broadcast flags must be an integer.") + if value < 0 or value > WOWPRESENCE_SHARE_ALL: + raise ValueError( + f"WowPresence broadcast flags must be between 0 and {WOWPRESENCE_SHARE_ALL}." + ) + + data_dir = ensure_wowpresence_config(target_dir) + path = os.path.join(data_dir, "discord_broadcast_flags") + temp_path = path + ".new" + try: + with open(temp_path, "w", encoding="ascii", newline="\n") as handle: + handle.write(f"{value}\n") + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + return path + + 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) -- 2.52.0 From f3aa797317724a621307e0469f788ec7cb073492 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 11:26:22 +0200 Subject: [PATCH 02/18] Add Discord Rich Presence detail controls --- setup_tool_dynamic.py | 192 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 60d4479..c1384b9 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -18,6 +18,22 @@ class ModernWowSetupTool(WowSetupTool): self.auction_throttle_enabled = tk.BooleanVar(master=root, value=True) self.vmmfix_enabled = tk.BooleanVar(master=root, value=False) self.interact_enabled = tk.BooleanVar(master=root, value=False) + + # Discord Rich Presence detail choices are kept independently from the + # main WowPresence checkbox so hiding/disabling the integration never + # destroys the user's selected disclosure preferences. + self.discord_show_character_details = tk.BooleanVar(master=root, value=True) + self.discord_detail_vars = { + "name": tk.BooleanVar(master=root, value=True), + "guild": tk.BooleanVar(master=root, value=True), + "race": tk.BooleanVar(master=root, value=True), + "faction": tk.BooleanVar(master=root, value=True), + "class": tk.BooleanVar(master=root, value=True), + "level": tk.BooleanVar(master=root, value=True), + "zone": tk.BooleanVar(master=root, value=True), + } + self.discord_presence_details_frame = None + self._download_window = None self._download_label = None self._download_detail = None @@ -26,6 +42,167 @@ class ModernWowSetupTool(WowSetupTool): self._download_indeterminate = False super().__init__(root) + def _collect_settings(self): + settings = super()._collect_settings() + settings["discord_presence"] = { + "show_character_details": bool(self.discord_show_character_details.get()), + "details": { + name: bool(var.get()) + for name, var in self.discord_detail_vars.items() + }, + } + return settings + + def _apply_settings_dict(self, saved): + super()._apply_settings_dict(saved) + + discord = saved.get("discord_presence") if isinstance(saved, dict) else None + if not isinstance(discord, dict): + return + + show_details = discord.get("show_character_details") + if isinstance(show_details, bool): + self.discord_show_character_details.set(show_details) + + details = discord.get("details") + if isinstance(details, dict): + for name, value in details.items(): + var = self.discord_detail_vars.get(name) + if var is not None and isinstance(value, bool): + var.set(value) + + def _load_wowpresence_broadcast_preferences(self, target_dir): + """Migrate the old six-bit mask into the new seven-choice UI.""" + mask = remote_packages.read_wowpresence_broadcast_flags(target_dir) + if mask is None: + return + + bits = { + "name": remote_packages.WOWPRESENCE_SHARE_NAME, + "guild": remote_packages.WOWPRESENCE_SHARE_GUILD, + "faction": remote_packages.WOWPRESENCE_SHARE_FACTION, + "class": remote_packages.WOWPRESENCE_SHARE_CLASS, + "level": remote_packages.WOWPRESENCE_SHARE_LEVEL, + "zone": remote_packages.WOWPRESENCE_SHARE_ZONE, + } + for name, bit in bits.items(): + self.discord_detail_vars[name].set(bool(mask & bit)) + + # WowPresence versions before the dedicated Race flag always exposed + # race information regardless of the six-bit mask. Treat masks <= 63 + # as legacy so the migration preserves exactly that visible behavior. + if mask <= 63: + self.discord_detail_vars["race"].set(True) + else: + self.discord_detail_vars["race"].set( + bool(mask & remote_packages.WOWPRESENCE_SHARE_RACE) + ) + + # A legacy mask never had a separate master switch. Keeping this on + # preserves the previous display while still allowing all individual + # choices to be edited. + self.discord_show_character_details.set(True) + + def _load_legacy_install_state(self, target_dir): + super()._load_legacy_install_state(target_dir) + self._load_wowpresence_broadcast_preferences(target_dir) + + def load_settings(self, target_dir): + settings_path = self._settings_path(target_dir) + has_saved_discord_preferences = False + try: + with open(settings_path, "r", encoding="utf-8") as handle: + saved = json.load(handle) + has_saved_discord_preferences = isinstance( + saved.get("discord_presence") if isinstance(saved, dict) else None, + dict, + ) + except (OSError, json.JSONDecodeError, ValueError, TypeError): + pass + + loaded = super().load_settings(target_dir) + + # Existing v2.1 settings.json files predate the detailed controls. Read + # their actual WowPresence mask once so manual choices are not lost. + if os.path.isfile(settings_path) and not has_saved_discord_preferences: + self._load_wowpresence_broadcast_preferences(target_dir) + + self.update_discord_presence_controls() + return loaded + + def _discord_broadcast_mask(self): + if not self.discord_show_character_details.get(): + return 0 + + bits = { + "name": remote_packages.WOWPRESENCE_SHARE_NAME, + "guild": remote_packages.WOWPRESENCE_SHARE_GUILD, + "race": remote_packages.WOWPRESENCE_SHARE_RACE, + "faction": remote_packages.WOWPRESENCE_SHARE_FACTION, + "class": remote_packages.WOWPRESENCE_SHARE_CLASS, + "level": remote_packages.WOWPRESENCE_SHARE_LEVEL, + "zone": remote_packages.WOWPRESENCE_SHARE_ZONE, + } + mask = 0 + for name, bit in bits.items(): + var = self.discord_detail_vars.get(name) + if var is not None and var.get(): + mask |= bit + return mask + + def update_discord_presence_controls(self): + frame = getattr(self, "discord_presence_details_frame", None) + if frame is None: + return + + var = self.optional_plugins.get("WowPresence.dll") + visible = bool(var is not None and var.get()) + manager = frame.winfo_manager() + + if visible and not manager: + frame.pack(fill="x", padx=(22, 8), pady=(0, 4)) + elif not visible and manager: + frame.pack_forget() + + def _build_discord_presence_details(self, parent): + frame = ttk.Frame(parent) + self.discord_presence_details_frame = frame + + show_cb = ttk.Checkbutton( + frame, + text="Show character details", + variable=self.discord_show_character_details, + ) + show_cb.pack(anchor="w", padx=6, pady=(1, 2)) + ToolTip( + show_cb, + "Controls whether the selected character details are published to Discord. " + "The choices below stay available even when this is unchecked.", + ) + + labels = { + "name": "Character Name", + "guild": "Guild", + "race": "Race", + "faction": "Faction", + "class": "Class", + "level": "Level", + "zone": "Zone", + } + for name, label in labels.items(): + cb = ttk.Checkbutton( + frame, + text=label, + variable=self.discord_detail_vars[name], + ) + cb.pack(anchor="w", padx=24, pady=1) + ToolTip( + cb, + f"Choose whether WowPresence may publish your {label.lower()} on Discord.", + ) + + self.update_discord_presence_controls() + def _show_download_progress(self): if self._download_window is not None and self._download_window.winfo_exists(): return @@ -303,14 +480,24 @@ class ModernWowSetupTool(WowSetupTool): for dll, var in self.optional_plugins.items(): if dll == "no1600x1200.dll": continue + + command = ( + self.update_discord_presence_controls + if dll == "WowPresence.dll" + else None + ) self._plugin_row( right_frame, optional_display.get(dll, os.path.splitext(dll)[0]), var, optional_attribution.get(dll, "by MarcelineVQ"), self.descriptions.get(dll, ""), + command=command, ) + if dll == "WowPresence.dll": + self._build_discord_presence_details(right_frame) + def clean_unselected_files(self, target): super().clean_unselected_files(target) @@ -867,6 +1054,11 @@ class ModernWowSetupTool(WowSetupTool): os.remove(legacy_path) except OSError: pass + + remote_packages.write_wowpresence_broadcast_flags( + target, + self._discord_broadcast_mask(), + ) else: source_dll = os.path.join(payload_weirdu, dll_name) if os.path.exists(source_dll): -- 2.52.0 From cd64ea0b31ff335c70237a25f6bec771e92a564e Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 11:26:46 +0200 Subject: [PATCH 03/18] Test Discord Rich Presence detail preferences --- tests/test_safety.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_safety.py b/tests/test_safety.py index c642215..7d2a3ca 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -8,6 +8,7 @@ from unittest import mock import remote_packages import setup_tool from setup_tool import WowSetupTool +from setup_tool_dynamic import ModernWowSetupTool class FakeVar: @@ -865,6 +866,79 @@ class WowPresenceIntegrationTests(unittest.TestCase): ) +class WowPresenceDetailPreferenceTests(unittest.TestCase): + def make_tool(self): + tool = ModernWowSetupTool.__new__(ModernWowSetupTool) + tool.discord_show_character_details = FakeVar(True) + tool.discord_detail_vars = { + "name": FakeVar(True), + "guild": FakeVar(True), + "race": FakeVar(True), + "faction": FakeVar(True), + "class": FakeVar(True), + "level": FakeVar(True), + "zone": FakeVar(True), + } + return tool + + def test_detail_mask_supports_race_and_master_switch(self): + tool = self.make_tool() + self.assertEqual( + tool._discord_broadcast_mask(), + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + + tool.discord_detail_vars["race"].set(False) + self.assertEqual(tool._discord_broadcast_mask(), 63) + + tool.discord_show_character_details.set(False) + self.assertEqual(tool._discord_broadcast_mask(), 0) + self.assertTrue(tool.discord_detail_vars["race"].get()) + self.assertTrue(tool.discord_detail_vars["zone"].get()) + + def test_legacy_six_bit_mask_keeps_race_enabled(self): + tool = self.make_tool() + with tempfile.TemporaryDirectory() as root: + data_dir = remote_packages.ensure_wowpresence_config(root) + flags = os.path.join(data_dir, "discord_broadcast_flags") + with open(flags, "w", encoding="ascii") as handle: + handle.write("31\n") + + tool._load_wowpresence_broadcast_preferences(root) + + self.assertTrue(tool.discord_show_character_details.get()) + self.assertTrue(tool.discord_detail_vars["name"].get()) + self.assertTrue(tool.discord_detail_vars["guild"].get()) + self.assertTrue(tool.discord_detail_vars["faction"].get()) + self.assertTrue(tool.discord_detail_vars["class"].get()) + self.assertTrue(tool.discord_detail_vars["level"].get()) + self.assertFalse(tool.discord_detail_vars["zone"].get()) + self.assertTrue(tool.discord_detail_vars["race"].get()) + + def test_broadcast_flag_writer_preserves_other_config(self): + with tempfile.TemporaryDirectory() as root: + data_dir = remote_packages.ensure_wowpresence_config(root) + app_id = os.path.join(data_dir, "discord_application_id") + with open(app_id, "w", encoding="ascii") as handle: + handle.write("123456789012345678\n") + + path = remote_packages.write_wowpresence_broadcast_flags( + root, + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + self.assertEqual( + remote_packages.read_wowpresence_broadcast_flags(root), + 127, + ) + with open(path, "r", encoding="ascii") as handle: + self.assertEqual(handle.read().strip(), "127") + with open(app_id, "r", encoding="ascii") as handle: + self.assertEqual(handle.read().strip(), "123456789012345678") + + with self.assertRaises(ValueError): + remote_packages.write_wowpresence_broadcast_flags(root, 128) + + class SettingsRecoveryTests(unittest.TestCase): def test_corrupt_settings_are_left_untouched_and_legacy_state_is_recovered(self): tool = WowSetupTool.__new__(WowSetupTool) -- 2.52.0 From 85c7ca9d9815ffa68e8e824e815fa983e5872f6f Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 11:28:39 +0200 Subject: [PATCH 04/18] Fix detail preference persistence test --- tests/test_safety.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_safety.py b/tests/test_safety.py index 7d2a3ca..c2f3fc9 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -893,7 +893,7 @@ class WowPresenceDetailPreferenceTests(unittest.TestCase): tool.discord_show_character_details.set(False) self.assertEqual(tool._discord_broadcast_mask(), 0) - self.assertTrue(tool.discord_detail_vars["race"].get()) + self.assertFalse(tool.discord_detail_vars["race"].get()) self.assertTrue(tool.discord_detail_vars["zone"].get()) def test_legacy_six_bit_mask_keeps_race_enabled(self): -- 2.52.0 From afb4993deefdfc4b8cb4bc622682412ca7a275fc Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 11:42:10 +0200 Subject: [PATCH 05/18] Make character details an all-details toggle --- setup_tool_dynamic.py | 49 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index c1384b9..7956d3c 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -33,6 +33,7 @@ class ModernWowSetupTool(WowSetupTool): "zone": tk.BooleanVar(master=root, value=True), } self.discord_presence_details_frame = None + self.discord_detail_checkbuttons = {} self._download_window = None self._download_label = None @@ -98,10 +99,22 @@ class ModernWowSetupTool(WowSetupTool): bool(mask & remote_packages.WOWPRESENCE_SHARE_RACE) ) - # A legacy mask never had a separate master switch. Keeping this on - # preserves the previous display while still allowing all individual - # choices to be edited. - self.discord_show_character_details.set(True) + # "Show character details" now means "show everything". Only select it + # automatically when the existing configuration already represents the + # full legacy/default disclosure set. Any custom mask stays custom. + legacy_all = ( + mask <= 63 + and (mask & 63) == 63 + ) + current_all = ( + mask > 63 + and (mask & remote_packages.WOWPRESENCE_SHARE_ALL) + == remote_packages.WOWPRESENCE_SHARE_ALL + ) + self.discord_show_character_details.set(legacy_all or current_all) + if self.discord_show_character_details.get(): + for var in self.discord_detail_vars.values(): + var.set(True) def _load_legacy_install_state(self, target_dir): super()._load_legacy_install_state(target_dir) @@ -131,8 +144,8 @@ class ModernWowSetupTool(WowSetupTool): return loaded def _discord_broadcast_mask(self): - if not self.discord_show_character_details.get(): - return 0 + if self.discord_show_character_details.get(): + return remote_packages.WOWPRESENCE_SHARE_ALL bits = { "name": remote_packages.WOWPRESENCE_SHARE_NAME, @@ -150,6 +163,21 @@ class ModernWowSetupTool(WowSetupTool): mask |= bit return mask + def _toggle_discord_all_details(self): + show_all = bool(self.discord_show_character_details.get()) + if show_all: + for var in self.discord_detail_vars.values(): + var.set(True) + self.update_discord_detail_states() + + def update_discord_detail_states(self): + state = "disabled" if self.discord_show_character_details.get() else "normal" + for checkbox in getattr(self, "discord_detail_checkbuttons", {}).values(): + try: + checkbox.configure(state=state) + except tk.TclError: + pass + def update_discord_presence_controls(self): frame = getattr(self, "discord_presence_details_frame", None) if frame is None: @@ -164,6 +192,9 @@ class ModernWowSetupTool(WowSetupTool): elif not visible and manager: frame.pack_forget() + if visible: + self.update_discord_detail_states() + def _build_discord_presence_details(self, parent): frame = ttk.Frame(parent) self.discord_presence_details_frame = frame @@ -172,12 +203,13 @@ class ModernWowSetupTool(WowSetupTool): frame, text="Show character details", variable=self.discord_show_character_details, + command=self._toggle_discord_all_details, ) show_cb.pack(anchor="w", padx=6, pady=(1, 2)) ToolTip( show_cb, - "Controls whether the selected character details are published to Discord. " - "The choices below stay available even when this is unchecked.", + "When enabled, all character details are shown and the individual choices " + "below are locked. Uncheck it to choose each detail separately.", ) labels = { @@ -196,6 +228,7 @@ class ModernWowSetupTool(WowSetupTool): variable=self.discord_detail_vars[name], ) cb.pack(anchor="w", padx=24, pady=1) + self.discord_detail_checkbuttons[name] = cb ToolTip( cb, f"Choose whether WowPresence may publish your {label.lower()} on Discord.", -- 2.52.0 From e7cb7677a04035d682bba33b13c4fedfd4d704bf Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 11:42:29 +0200 Subject: [PATCH 06/18] Test all-details toggle behavior --- tests/test_safety.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/test_safety.py b/tests/test_safety.py index c2f3fc9..ad6d674 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -881,18 +881,24 @@ class WowPresenceDetailPreferenceTests(unittest.TestCase): } return tool - def test_detail_mask_supports_race_and_master_switch(self): + def test_detail_mask_supports_race_and_all_details_switch(self): tool = self.make_tool() self.assertEqual( tool._discord_broadcast_mask(), remote_packages.WOWPRESENCE_SHARE_ALL, ) + # The master switch always means "show all" even if a variable is + # changed programmatically while its checkbox would be disabled. tool.discord_detail_vars["race"].set(False) - self.assertEqual(tool._discord_broadcast_mask(), 63) + self.assertEqual( + tool._discord_broadcast_mask(), + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + # Once the master is off, the individual choices become authoritative. tool.discord_show_character_details.set(False) - self.assertEqual(tool._discord_broadcast_mask(), 0) + self.assertEqual(tool._discord_broadcast_mask(), 63) self.assertFalse(tool.discord_detail_vars["race"].get()) self.assertTrue(tool.discord_detail_vars["zone"].get()) @@ -906,7 +912,7 @@ class WowPresenceDetailPreferenceTests(unittest.TestCase): tool._load_wowpresence_broadcast_preferences(root) - self.assertTrue(tool.discord_show_character_details.get()) + self.assertFalse(tool.discord_show_character_details.get()) self.assertTrue(tool.discord_detail_vars["name"].get()) self.assertTrue(tool.discord_detail_vars["guild"].get()) self.assertTrue(tool.discord_detail_vars["faction"].get()) @@ -915,6 +921,23 @@ class WowPresenceDetailPreferenceTests(unittest.TestCase): self.assertFalse(tool.discord_detail_vars["zone"].get()) self.assertTrue(tool.discord_detail_vars["race"].get()) + def test_legacy_default_mask_selects_all_details(self): + tool = self.make_tool() + with tempfile.TemporaryDirectory() as root: + data_dir = remote_packages.ensure_wowpresence_config(root) + flags = os.path.join(data_dir, "discord_broadcast_flags") + with open(flags, "w", encoding="ascii") as handle: + handle.write("63\n") + + tool._load_wowpresence_broadcast_preferences(root) + + self.assertTrue(tool.discord_show_character_details.get()) + self.assertTrue(all(var.get() for var in tool.discord_detail_vars.values())) + self.assertEqual( + tool._discord_broadcast_mask(), + remote_packages.WOWPRESENCE_SHARE_ALL, + ) + def test_broadcast_flag_writer_preserves_other_config(self): with tempfile.TemporaryDirectory() as root: data_dir = remote_packages.ensure_wowpresence_config(root) -- 2.52.0 From 9b563421dd2054b2c6b2875fe86fd231ac3f7f87 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:23:35 +0200 Subject: [PATCH 07/18] Add lightweight package revision tracking --- remote_packages.py | 193 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 5 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index 7504265..e7d02c1 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,24 @@ 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 _find_asset(release, exact_name=None, predicate=None): for asset in release.get("assets", []): name = asset.get("name", "") @@ -469,9 +500,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 +510,20 @@ def prepare_vanilla_tweaks(progress=None): and not name.lower().endswith(".sha256sum") ), ) + return { + "release": release, + "asset": asset, + "revision": _release_revision(release), + "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 +543,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): @@ -880,6 +923,146 @@ 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_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): -- 2.52.0 From af5c50b799acc6298e4d6440d666ce7818414f95 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:24:11 +0200 Subject: [PATCH 08/18] Keep update cache metadata non-critical --- remote_packages.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/remote_packages.py b/remote_packages.py index e7d02c1..43616ff 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -1037,6 +1037,16 @@ def _package_state_is_current(target_dir, package_id, revision): 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, -- 2.52.0 From 6bd2d8bc286c347ea94e042e088a350a6e15c21d Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:25:09 +0200 Subject: [PATCH 09/18] Skip unchanged remote component downloads --- remote_packages.py | 265 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 18 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index 43616ff..70ddad6 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -734,10 +734,18 @@ 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") + revision = _release_revision(release) asset = _find_asset(release, exact_name="Interact.zip") + package_id = "interact" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"Interact {revision} is already current.", None, None) + return revision + zip_path = _download_asset(asset, progress=progress, label="Downloading Interact package") extract_root = tempfile.mkdtemp(prefix="modernization_interact_") try: @@ -761,6 +769,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) @@ -768,13 +785,26 @@ def install_interact(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") + return revision + def install_vanilla_multimonitor_fix(target_dir, progress=None): _emit_progress(progress, "Checking VanillaMultiMonitorFix release...", None, None) release = _latest_release("Mates1500/VanillaMultiMonitorFix") + revision = _release_revision(release) asset = _find_asset(release, exact_name="release.zip") + package_id = "vanilla_multimonitor_fix" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"VanillaMultiMonitorFix {revision} is already current.", + None, + None, + ) + return revision + zip_path = _download_asset( asset, progress=progress, @@ -799,6 +829,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) @@ -806,11 +845,7 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return release.get("tag_name", "latest") - - - -MANAGED_ROOT = ".modernization_tool" + return revision def _safe_relative_path(relative_path): @@ -1582,9 +1617,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", @@ -1607,13 +1654,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"): @@ -1649,7 +1702,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, @@ -1664,12 +1728,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): @@ -1714,13 +1785,21 @@ 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") + revision = _release_revision(release) asset = _find_asset( release, predicate=lambda name: name.lower().startswith("nampower-") and name.lower().endswith(".zip"), ) + package_id = "nampower" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"Nampower {revision} is already current.", None, None) + return revision + zip_path = _download_asset(asset, progress=progress, label="Downloading Nampower package") extract_root = tempfile.mkdtemp(prefix="modernization_nampower_") try: @@ -1742,19 +1821,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 revision + def install_vanillahelpers(target_dir, progress=None): _emit_progress(progress, "Checking VanillaHelpers release...", None, None) release = _latest_release("isfir/VanillaHelpers") + revision = _release_revision(release) asset = _find_asset(release, exact_name="VanillaHelpers.dll") + 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 {revision} is already current.", None, None) + return revision + temp_path = _download_asset( asset, progress=progress, @@ -1763,14 +1869,34 @@ 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 revision + 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" @@ -1784,30 +1910,83 @@ 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") + revision = _release_revision(release) asset = _find_asset(release, exact_name="ClassicAPI.dll") + 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 {revision} is already current.", None, None) + return revision + 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 revision + def install_auction_query_throttle(target_dir, progress=None): _emit_progress(progress, "Checking AuctionQueryThrottle release...", None, None) release = _latest_release("brues-code/AuctionQueryThrottle") + revision = _release_revision(release) asset = _find_asset(release, exact_name="AuctionQueryThrottle.dll") + 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 {revision} is already current.", + None, + None, + ) + return revision + temp_path = _download_asset( asset, progress=progress, @@ -1816,19 +1995,33 @@ 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 revision + def install_unitxp(target_dir, progress=None): _emit_progress(progress, "Checking UnitXP_SP3 release...", None, None) release = _latest_release("brues-code/UnitXP_SP3") + revision = _release_revision(release) asset = _find_asset( release, predicate=lambda name: name.lower().startswith("unitxp_sp3") and name.lower().endswith(".zip"), ) + package_id = "unitxp_sp3" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress(progress, f"UnitXP_SP3 {revision} is already current.", None, None) + return revision + zip_path = _download_asset(asset, progress=progress, label="Downloading UnitXP_SP3 package") extract_root = tempfile.mkdtemp(prefix="modernization_unitxp_") try: @@ -1850,23 +2043,48 @@ 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 revision + def install_superwow(target_dir, progress=None): _emit_progress(progress, "Checking SuperWoW release...", None, None) release = _latest_release("balakethelock/SuperWoW") + release_revision = _release_revision(release) asset = _find_asset( release, predicate=lambda name: name.lower().startswith("superwow") and name.lower().endswith(".zip"), ) + _emit_progress(progress, "Checking SuperAPI revision...", None, None) + superapi_revision = _branch_head_sha("balakethelock/SuperAPI", "master") + revision = f"{release_revision}|superapi:{superapi_revision}" + package_id = "superwow" + + if _package_state_is_current(target_dir, package_id, revision): + _emit_progress( + progress, + f"SuperWoW {release_revision} + SuperAPI {superapi_revision[:7]} are already current.", + None, + None, + ) + return release.get("name") or release_revision + wow_zip = None superapi_zip = None wow_root = tempfile.mkdtemp(prefix="modernization_superwow_") @@ -1918,6 +2136,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: @@ -1928,4 +2155,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.get("name") or release_revision + + -- 2.52.0 From ce6d570f605902145d491248cdcea99568ff5c41 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:26:01 +0200 Subject: [PATCH 10/18] Skip unchanged vanilla-tweaks downloads --- setup_tool_dynamic.py | 109 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 96 insertions(+), 13 deletions(-) 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: -- 2.52.0 From 436b13dba0678f879349d399111fb07a37d234a7 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:26:16 +0200 Subject: [PATCH 11/18] Check Pink Herbs source revision before download --- setup_tool.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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) -- 2.52.0 From 59d3dc0d051c2a2ce96cd60d4ada7b8c7c9f1911 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:27:03 +0200 Subject: [PATCH 12/18] Test smart update skip behavior --- tests/test_safety.py | 139 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tests/test_safety.py b/tests/test_safety.py index c642215..aae2b9d 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,144 @@ 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_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") + + remote_packages._record_package_state( + root, + "classicapi", + "v-test", + ["ClassicAPI.dll"], + ) + release = { + "tag_name": "v-test", + "assets": [ + { + "name": "ClassicAPI.dll", + "browser_download_url": "https://example.invalid/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) -- 2.52.0 From aca1988b95239b9ccd50446d6e2fed4029741e04 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:29:26 +0200 Subject: [PATCH 13/18] Track release asset revisions safely --- remote_packages.py | 97 ++++++++++++++++++++++++---------------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index 70ddad6..64105c2 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -86,6 +86,23 @@ def _release_revision(release): 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", "") @@ -513,7 +530,7 @@ def vanilla_tweaks_release_info(): return { "release": release, "asset": asset, - "revision": _release_revision(release), + "revision": _release_asset_revision(release, asset), "version": release.get("name") or _release_revision(release), } @@ -738,12 +755,13 @@ def install_wowpresence(target_dir, progress=None): def install_interact(target_dir, progress=None): _emit_progress(progress, "Checking Interact release...", None, None) release = _latest_release("lookino/Interact") - revision = _release_revision(release) 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 {revision} is already current.", None, None) + _emit_progress(progress, f"Interact {version} is already current.", None, None) return revision zip_path = _download_asset(asset, progress=progress, label="Downloading Interact package") @@ -785,21 +803,22 @@ def install_interact(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return revision + return version def install_vanilla_multimonitor_fix(target_dir, progress=None): _emit_progress(progress, "Checking VanillaMultiMonitorFix release...", None, None) release = _latest_release("Mates1500/VanillaMultiMonitorFix") - revision = _release_revision(release) 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 {revision} is already current.", + f"VanillaMultiMonitorFix {version} is already current.", None, None, ) @@ -1789,15 +1808,16 @@ def install_warlock_muted_demons(target_dir, progress=None): def install_nampower(target_dir, progress=None): _emit_progress(progress, "Checking Nampower release...", None, None) release = _latest_release("brues-code/nampower") - revision = _release_revision(release) asset = _find_asset( 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 {revision} is already current.", None, None) + _emit_progress(progress, f"Nampower {version} is already current.", None, None) return revision zip_path = _download_asset(asset, progress=progress, label="Downloading Nampower package") @@ -1836,15 +1856,12 @@ def install_nampower(target_dir, progress=None): except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return revision - - - -def install_vanillahelpers(target_dir, progress=None): + return versiondef install_vanillahelpers(target_dir, progress=None): _emit_progress(progress, "Checking VanillaHelpers release...", None, None) release = _latest_release("isfir/VanillaHelpers") - revision = _release_revision(release) 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" @@ -1858,7 +1875,7 @@ def install_vanillahelpers(target_dir, progress=None): asset, ) ): - _emit_progress(progress, f"VanillaHelpers {revision} is already current.", None, None) + _emit_progress(progress, f"VanillaHelpers {version} is already current.", None, None) return revision temp_path = _download_asset( @@ -1878,11 +1895,7 @@ def install_vanillahelpers(target_dir, progress=None): ) finally: os.remove(temp_path) - return revision - - - -def install_no1600x1200(target_dir, progress=None): + return versiondef 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" @@ -1926,8 +1939,9 @@ def install_no1600x1200(target_dir, progress=None): def install_classicapi(target_dir, progress=None): _emit_progress(progress, "Checking ClassicAPI release...", None, None) release = _latest_release("brues-code/ClassicAPI") - revision = _release_revision(release) 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" @@ -1941,7 +1955,7 @@ def install_classicapi(target_dir, progress=None): asset, ) ): - _emit_progress(progress, f"ClassicAPI {revision} is already current.", None, None) + _emit_progress(progress, f"ClassicAPI {version} is already current.", None, None) return revision temp_path = _download_asset(asset, progress=progress, label="Downloading ClassicAPI.dll") @@ -1957,15 +1971,12 @@ def install_classicapi(target_dir, progress=None): ) finally: os.remove(temp_path) - return revision - - - -def install_auction_query_throttle(target_dir, progress=None): + return versiondef install_auction_query_throttle(target_dir, progress=None): _emit_progress(progress, "Checking AuctionQueryThrottle release...", None, None) release = _latest_release("brues-code/AuctionQueryThrottle") - revision = _release_revision(release) 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" @@ -1981,7 +1992,7 @@ def install_auction_query_throttle(target_dir, progress=None): ): _emit_progress( progress, - f"AuctionQueryThrottle {revision} is already current.", + f"AuctionQueryThrottle {version} is already current.", None, None, ) @@ -2004,22 +2015,19 @@ def install_auction_query_throttle(target_dir, progress=None): ) finally: os.remove(temp_path) - return revision - - - -def install_unitxp(target_dir, progress=None): + return versiondef install_unitxp(target_dir, progress=None): _emit_progress(progress, "Checking UnitXP_SP3 release...", None, None) release = _latest_release("brues-code/UnitXP_SP3") - revision = _release_revision(release) asset = _find_asset( 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 {revision} is already current.", None, None) + _emit_progress(progress, f"UnitXP_SP3 {version} is already current.", None, None) return revision zip_path = _download_asset(asset, progress=progress, label="Downloading UnitXP_SP3 package") @@ -2058,32 +2066,29 @@ def install_unitxp(target_dir, progress=None): except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return revision - - - -def install_superwow(target_dir, progress=None): + return versiondef install_superwow(target_dir, progress=None): _emit_progress(progress, "Checking SuperWoW release...", None, None) release = _latest_release("balakethelock/SuperWoW") - release_revision = _release_revision(release) + 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_revision}|superapi:{superapi_revision}" + 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"SuperWoW {release_revision} + SuperAPI {superapi_revision[:7]} are already current.", + f"{release_version} + SuperAPI {superapi_revision[:7]} are already current.", None, None, ) - return release.get("name") or release_revision + return release_version wow_zip = None superapi_zip = None @@ -2155,6 +2160,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_revision + return release_version -- 2.52.0 From 607993c8947428781847ccb0c32ae62fb7093ca2 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:30:07 +0200 Subject: [PATCH 14/18] Fix smart update installer compatibility --- remote_packages.py | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index 64105c2..7bf75db 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -762,7 +762,7 @@ def install_interact(target_dir, progress=None): if _package_state_is_current(target_dir, package_id, revision): _emit_progress(progress, f"Interact {version} is already current.", None, None) - return revision + return version zip_path = _download_asset(asset, progress=progress, label="Downloading Interact package") extract_root = tempfile.mkdtemp(prefix="modernization_interact_") @@ -822,7 +822,7 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): None, None, ) - return revision + return version zip_path = _download_asset( asset, @@ -1818,7 +1818,7 @@ def install_nampower(target_dir, progress=None): if _package_state_is_current(target_dir, package_id, revision): _emit_progress(progress, f"Nampower {version} is already current.", None, None) - return revision + return version zip_path = _download_asset(asset, progress=progress, label="Downloading Nampower package") extract_root = tempfile.mkdtemp(prefix="modernization_nampower_") @@ -1856,7 +1856,10 @@ def install_nampower(target_dir, progress=None): except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return versiondef install_vanillahelpers(target_dir, progress=None): + 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") @@ -1876,7 +1879,7 @@ def install_nampower(target_dir, progress=None): ) ): _emit_progress(progress, f"VanillaHelpers {version} is already current.", None, None) - return revision + return version temp_path = _download_asset( asset, @@ -1895,7 +1898,10 @@ def install_nampower(target_dir, progress=None): ) finally: os.remove(temp_path) - return versiondef install_no1600x1200(target_dir, progress=None): + 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" @@ -1956,7 +1962,7 @@ def install_classicapi(target_dir, progress=None): ) ): _emit_progress(progress, f"ClassicAPI {version} is already current.", None, None) - return revision + return version temp_path = _download_asset(asset, progress=progress, label="Downloading ClassicAPI.dll") try: @@ -1971,7 +1977,10 @@ def install_classicapi(target_dir, progress=None): ) finally: os.remove(temp_path) - return versiondef install_auction_query_throttle(target_dir, progress=None): + 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") @@ -1996,7 +2005,7 @@ def install_classicapi(target_dir, progress=None): None, None, ) - return revision + return version temp_path = _download_asset( asset, @@ -2015,7 +2024,10 @@ def install_classicapi(target_dir, progress=None): ) finally: os.remove(temp_path) - return versiondef install_unitxp(target_dir, progress=None): + return version + + +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( @@ -2028,7 +2040,7 @@ def install_classicapi(target_dir, progress=None): if _package_state_is_current(target_dir, package_id, revision): _emit_progress(progress, f"UnitXP_SP3 {version} is already current.", None, None) - return revision + return version zip_path = _download_asset(asset, progress=progress, label="Downloading UnitXP_SP3 package") extract_root = tempfile.mkdtemp(prefix="modernization_unitxp_") @@ -2066,7 +2078,10 @@ def install_classicapi(target_dir, progress=None): except OSError: pass shutil.rmtree(extract_root, ignore_errors=True) - return versiondef install_superwow(target_dir, progress=None): + 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) -- 2.52.0 From daa3204b3e04c55c37bf9bf6c7225d0699c77c32 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:30:37 +0200 Subject: [PATCH 15/18] Detect replaced WowPresence release assets --- remote_packages.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index 7bf75db..8d465c1 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -625,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. @@ -662,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) @@ -680,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, @@ -736,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): -- 2.52.0 From 4b37914a1709c7e5cc7e0202e1a9a4acbcba7bc8 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:32:07 +0200 Subject: [PATCH 16/18] Restore managed state root constant --- remote_packages.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/remote_packages.py b/remote_packages.py index 8d465c1..198cad5 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -884,6 +884,9 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): return revision +MANAGED_ROOT = ".modernization_tool" + + def _safe_relative_path(relative_path): rel = os.path.normpath(str(relative_path).replace("\\", os.sep).replace("/", os.sep)) if os.path.isabs(rel) or rel == ".." or rel.startswith(".." + os.sep): -- 2.52.0 From 73f6d3469823fa96f6780c66e6342c62a0d6c28c Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:32:46 +0200 Subject: [PATCH 17/18] Preserve VMMFix installer return value --- remote_packages.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/remote_packages.py b/remote_packages.py index 198cad5..b4cb36c 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -881,8 +881,7 @@ def install_vanilla_multimonitor_fix(target_dir, progress=None): pass shutil.rmtree(extract_root, ignore_errors=True) - return revision - + return version MANAGED_ROOT = ".modernization_tool" -- 2.52.0 From 72bc42471a52cf2fe422db77be4cb4fc6b74fbae Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Wed, 2 Sep 2026 14:32:53 +0200 Subject: [PATCH 18/18] Test release asset replacement detection --- tests/test_safety.py | 51 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/tests/test_safety.py b/tests/test_safety.py index aae2b9d..4aad071 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -644,18 +644,47 @@ class SmartUpdateTests(unittest.TestCase): 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") - remote_packages._record_package_state( - root, - "classicapi", - "v-test", - ["ClassicAPI.dll"], - ) release = { "tag_name": "v-test", "assets": [ @@ -665,6 +694,16 @@ class SmartUpdateTests(unittest.TestCase): } ], } + 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", -- 2.52.0