Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfa1e09b7a | |||
| 2dee327763 | |||
| 44b2c31339 | |||
| ef41957f11 | |||
| 253dd69de1 | |||
| 3b424bd377 | |||
| 5a57ef47a4 | |||
| f431b7b70b | |||
| a7bb6fbdbe | |||
| 2f01828bba | |||
| 4c34b8ab7b | |||
| dd52e49c99 | |||
| a30406c046 | |||
| e7cb7677a0 | |||
| afb4993dee | |||
| 85c7ca9d98 | |||
| cd64ea0b31 | |||
| f3aa797317 | |||
| b2ed9782fc | |||
| fb1c5f7701 | |||
| 4237125bcf | |||
| 5c72b69f56 | |||
| dadf7801a3 | |||
| 7fd9c04fff |
@@ -81,7 +81,7 @@ jobs:
|
||||
Get-Item $zip | Format-List Name,Length,LastWriteTime
|
||||
|
||||
- name: Upload test artifact
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') && !(github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.1') }}
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') && !(github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.2') }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: WoW_Modernization_Tool-test
|
||||
@@ -90,22 +90,22 @@ jobs:
|
||||
dist/WoW_Modernization_Tool.zip
|
||||
if-no-files-found: error
|
||||
|
||||
# One-time production promotion for v2.1. The exact commit message keeps
|
||||
# normal main pushes from publishing or modifying the v2.0 release.
|
||||
- name: Create GitHub Release v2.1
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.1' }}
|
||||
# One-time production promotion for v2.2. The exact commit message keeps
|
||||
# normal main pushes from publishing or modifying the v2.2 release.
|
||||
- name: Create GitHub Release v2.2
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.event.head_commit.message == 'release v2.2' }}
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v2.1
|
||||
tag_name: v2.2
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: |
|
||||
dist/WoW_Modernization_Tool.exe
|
||||
dist/WoW_Modernization_Tool.zip
|
||||
fail_on_unmatched_files: true
|
||||
name: "WoW Modernization Tool v2.1"
|
||||
overwrite_files: true
|
||||
name: "WoW Modernization Tool v2.2"
|
||||
body_path: RELEASE_NOTES.md
|
||||
generate_release_notes: true
|
||||
previous_tag: v2.0
|
||||
generate_release_notes: false
|
||||
make_latest: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
|
||||
@@ -17,7 +17,7 @@ Download the latest version from:
|
||||
|
||||
**[GitHub Releases](https://github.com/Dusk-92/Modernization-Tool/releases/latest)**
|
||||
|
||||
Current major release: **v2.1**
|
||||
Current major release: **v2.2**
|
||||
|
||||
No installation is required. Download and run:
|
||||
|
||||
@@ -93,7 +93,7 @@ Optional client-side improvements include:
|
||||
- **MinimapIcons** — adds TBC/WotLK-style minimap tracking icons for NPCs and game objects, with a combined tracking menu and saved preferences.
|
||||
- **PNG Screenshots** — saves screenshots as compressed PNG files instead of the default uncompressed TGA format, using background processing to minimize frame-time impact.
|
||||
- **WorldMarkers** — lets party or raid leaders place up to five animated, Cataclysm-style colored world markers for positioning and tactical planning.
|
||||
- **Discord Rich Presence** — installs and keeps **WowPresence** up to date from [Dusk-92/WowPresence](https://github.com/Dusk-92/WowPresence). The Tool preconfigures the OctoWoW Discord Application ID in `.modernization_tool\WowPresence\discord_application_id`; custom IDs are preserved across updates.
|
||||
- **Discord Rich Presence** — installs and keeps **WowPresence** up to date from [Dusk-92/WowPresence](https://github.com/Dusk-92/WowPresence). The Tool preconfigures the OctoWoW Discord Application ID and lets you choose which character details are shown on Discord, including **Name, Guild, Race, Faction, Class, Level and Zone**. Custom Discord Application IDs are preserved across updates.
|
||||
|
||||
---
|
||||
|
||||
@@ -247,7 +247,7 @@ Always download Modernization Tool from the official GitHub Releases page.
|
||||
# 🔗 Useful Links
|
||||
|
||||
- **[Latest Release](https://github.com/Dusk-92/Modernization-Tool/releases/latest)**
|
||||
- **[v2.1 Release Notes](https://github.com/Dusk-92/Modernization-Tool/releases/tag/v2.1)**
|
||||
- **[v2.2 Release Notes](https://github.com/Dusk-92/Modernization-Tool/releases/tag/v2.2)**
|
||||
- **[OctoWoW Installation & Modernization Guide](https://octowow.st/forum/viewtopic.php?t=831)**
|
||||
|
||||
---
|
||||
|
||||
+22
-23
@@ -1,35 +1,34 @@
|
||||
# 🛠️ WoW Modernization Tool v2.1
|
||||
|
||||
This update adds **Discord Rich Presence** and includes several compatibility, maintenance and documentation improvements.
|
||||
# 🛠️ WoW Modernization Tool v2.2
|
||||
|
||||
## 🎮 Discord Rich Presence
|
||||
|
||||
- Added optional **WowPresence** integration with automatic installation and updates from its GitHub Releases.
|
||||
- Preconfigures the **OctoWoW Discord Application ID** while preserving custom IDs and settings.
|
||||
- Includes improved zone detection from WowPresence v1.2 and safer handling of standalone/manual WowPresence installations.
|
||||
- Added detailed Discord Rich Presence privacy controls.
|
||||
- Choose individually whether Discord can display:
|
||||
- Character Name
|
||||
- Guild
|
||||
- Race
|
||||
- Faction
|
||||
- Class
|
||||
- Level
|
||||
- Zone
|
||||
- Added a `Show character details` option to quickly enable all Discord details.
|
||||
- Updated WowPresence integration for the new privacy system.
|
||||
|
||||
## 🌿 Pink Herbs
|
||||
## 🔄 Updates & Downloads
|
||||
|
||||
- Moved **Pink Herbs** safely from `patch-H.mpq` to `patch-V.mpq`.
|
||||
- Existing custom `patch-H.mpq` files are protected from accidental replacement or removal.
|
||||
- Added smart update checks for remote components.
|
||||
- Unchanged components are no longer downloaded again on every Apply.
|
||||
- Modified or missing managed files are automatically detected and repaired.
|
||||
- Release assets are detected even when an upstream project replaces a file under the same tag.
|
||||
- Improved vanilla-tweaks update detection to avoid unnecessary downloads and repatching.
|
||||
- Improved update checks for GitHub branch-based components.
|
||||
|
||||
## 🖥️ Compatibility Fixes
|
||||
## 📚 Documentation
|
||||
|
||||
- Fixed elevated **DEP configuration** when the WoW installation path contains spaces.
|
||||
- Improved cleanup and migration of older managed files.
|
||||
- Updated Discord Rich Presence documentation.
|
||||
|
||||
## 📜 Licensing & Provenance
|
||||
|
||||
- Added expanded licensing, binary provenance and asset provenance documentation.
|
||||
- Improved third-party attribution and project identity information.
|
||||
- Updated Credits and source links.
|
||||
|
||||
## ✅ Updating from v2.0
|
||||
## ✅ Updating from v2.1
|
||||
|
||||
Download the new executable, select your existing WoW folder and click **Apply Setup & Tweaks**.
|
||||
|
||||
A complete WoW reinstall is not required.
|
||||
|
||||
---
|
||||
|
||||
The detailed commit history since **v2.0** is available on GitHub.
|
||||
|
||||
+590
-54
@@ -5,6 +5,7 @@ import shutil
|
||||
import stat
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
@@ -18,6 +19,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
|
||||
@@ -40,13 +50,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 +77,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 +526,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 +536,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 +569,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):
|
||||
@@ -556,18 +625,64 @@ def ensure_wowpresence_config(target_dir):
|
||||
|
||||
_write_text_if_missing(
|
||||
os.path.join(data_dir, "discord_broadcast_flags"),
|
||||
"63\n",
|
||||
"127\n",
|
||||
)
|
||||
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."""
|
||||
"""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")
|
||||
dll_asset = _find_asset(release, exact_name="WowPresence.dll")
|
||||
exe_asset = _find_asset(release, exact_name="WowPresence.exe")
|
||||
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.
|
||||
@@ -592,27 +707,55 @@ def install_wowpresence(target_dir, progress=None):
|
||||
"WowPresence.dll",
|
||||
)
|
||||
|
||||
# The directory itself is the signal used by WowPresence v1.2 to select
|
||||
# The directory itself is the signal used by WowPresence to select
|
||||
# the Modernization Tool managed data location instead of the standalone
|
||||
# one. User-editable config is created only when missing.
|
||||
ensure_wowpresence_config(target_dir)
|
||||
|
||||
if managed_mod_is_current(target_dir, WOWPRESENCE_MANAGED_ID, revision):
|
||||
dll_ok = _installed_asset_is_current(
|
||||
os.path.join(target_dir, "WowPresence.dll"),
|
||||
dll_asset,
|
||||
"WowPresence.dll",
|
||||
manifest = _load_managed_manifest_data(target_dir, WOWPRESENCE_MANAGED_ID)
|
||||
saved_hashes = manifest.get("file_sha256")
|
||||
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
|
||||
)
|
||||
)
|
||||
exe_ok = _installed_asset_is_current(
|
||||
os.path.join(target_dir, "WowPresence.exe"),
|
||||
exe_asset,
|
||||
"WowPresence.exe",
|
||||
)
|
||||
if dll_ok and exe_ok:
|
||||
|
||||
def installed_file_ok(filename):
|
||||
path = os.path.join(target_dir, filename)
|
||||
expected = saved_hashes.get(filename)
|
||||
if isinstance(expected, str):
|
||||
expected = expected.strip().lower()
|
||||
if len(expected) == 64 and all(ch in "0123456789abcdef" for ch in expected):
|
||||
try:
|
||||
return _file_sha256(path) == expected
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
_verify_x86_pe(path, filename)
|
||||
return True
|
||||
except RemotePackageError:
|
||||
return False
|
||||
|
||||
dll_ok = installed_file_ok("WowPresence.dll")
|
||||
exe_ok = installed_file_ok("WowPresence.exe")
|
||||
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,
|
||||
@@ -623,24 +766,25 @@ def install_wowpresence(target_dir, progress=None):
|
||||
return revision
|
||||
_emit_progress(
|
||||
progress,
|
||||
f"WowPresence {revision} needs repair; refreshing binaries...",
|
||||
f"WowPresence {revision} needs repair; refreshing package...",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
dll_path = None
|
||||
exe_path = None
|
||||
zip_path = None
|
||||
extract_root = None
|
||||
try:
|
||||
dll_path = _download_asset(
|
||||
dll_asset,
|
||||
zip_path = _download_asset(
|
||||
package_asset,
|
||||
progress=progress,
|
||||
label="Downloading WowPresence.dll",
|
||||
)
|
||||
exe_path = _download_asset(
|
||||
exe_asset,
|
||||
progress=progress,
|
||||
label="Downloading WowPresence.exe",
|
||||
label="Downloading WowPresence.zip",
|
||||
)
|
||||
extract_root = tempfile.mkdtemp(prefix="modernization_wowpresence_")
|
||||
_emit_progress(progress, "Extracting WowPresence...", None, None)
|
||||
_safe_extract(zip_path, extract_root)
|
||||
|
||||
dll_path = _find_file(extract_root, "WowPresence.dll")
|
||||
exe_path = _find_file(extract_root, "WowPresence.exe")
|
||||
_verify_x86_pe(dll_path, "WowPresence.dll")
|
||||
_verify_x86_pe(exe_path, "WowPresence.exe")
|
||||
|
||||
@@ -658,15 +802,24 @@ def install_wowpresence(target_dir, progress=None):
|
||||
target_dir,
|
||||
WOWPRESENCE_MANAGED_ID,
|
||||
dlls_entry_preexisting=bool(dlls_entry_preexisting),
|
||||
file_sha256={
|
||||
"WowPresence.dll": _file_sha256(dll_path),
|
||||
"WowPresence.exe": _file_sha256(exe_path),
|
||||
},
|
||||
package_digest=package_asset.get("digest"),
|
||||
package_revision=package_revision,
|
||||
)
|
||||
finally:
|
||||
for temp_path in (dll_path, exe_path):
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if zip_path and os.path.exists(zip_path):
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
if extract_root:
|
||||
shutil.rmtree(extract_root, ignore_errors=True)
|
||||
|
||||
# Never overwrite user-editable settings with the copies bundled in the
|
||||
# release ZIP. The managed config folder remains owned by the user/tool.
|
||||
ensure_wowpresence_config(target_dir)
|
||||
return revision
|
||||
|
||||
@@ -675,6 +828,14 @@ 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:
|
||||
@@ -698,6 +859,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)
|
||||
@@ -705,13 +875,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,
|
||||
@@ -736,6 +920,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)
|
||||
@@ -743,9 +936,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"
|
||||
|
||||
@@ -860,6 +1051,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):
|
||||
@@ -1369,9 +1710,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",
|
||||
@@ -1394,13 +1747,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"):
|
||||
@@ -1436,7 +1795,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,
|
||||
@@ -1451,12 +1821,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):
|
||||
@@ -1501,6 +1878,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")
|
||||
@@ -1508,6 +1886,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:
|
||||
@@ -1529,19 +1915,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,
|
||||
@@ -1550,14 +1963,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"
|
||||
@@ -1571,30 +2003,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,
|
||||
@@ -1603,10 +2089,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):
|
||||
@@ -1616,6 +2108,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:
|
||||
@@ -1637,22 +2137,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
|
||||
@@ -1705,6 +2230,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:
|
||||
@@ -1715,4 +2249,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
|
||||
|
||||
|
||||
|
||||
+13
-7
@@ -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)
|
||||
|
||||
+320
-13
@@ -18,6 +18,23 @@ 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.discord_detail_checkbuttons = {}
|
||||
|
||||
self._download_window = None
|
||||
self._download_label = None
|
||||
self._download_detail = None
|
||||
@@ -26,6 +43,199 @@ 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)
|
||||
)
|
||||
|
||||
# "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)
|
||||
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 self.discord_show_character_details.get():
|
||||
return remote_packages.WOWPRESENCE_SHARE_ALL
|
||||
|
||||
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 _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:
|
||||
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()
|
||||
|
||||
if visible:
|
||||
self.update_discord_detail_states()
|
||||
|
||||
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,
|
||||
command=self._toggle_discord_all_details,
|
||||
)
|
||||
show_cb.pack(anchor="w", padx=6, pady=(1, 2))
|
||||
ToolTip(
|
||||
show_cb,
|
||||
"When enabled, all character details are shown and the individual choices "
|
||||
"below are locked. Uncheck it to choose each detail separately.",
|
||||
)
|
||||
|
||||
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)
|
||||
self.discord_detail_checkbuttons[name] = cb
|
||||
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 +513,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)
|
||||
|
||||
@@ -558,6 +778,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 +791,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)
|
||||
@@ -623,19 +847,23 @@ class ModernWowSetupTool(WowSetupTool):
|
||||
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 +872,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 +884,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 +994,7 @@ class ModernWowSetupTool(WowSetupTool):
|
||||
patcher_source="online",
|
||||
patcher_version=version,
|
||||
patcher_path=tweaks_exe,
|
||||
patcher_revision=revision,
|
||||
)
|
||||
return result
|
||||
finally:
|
||||
@@ -867,6 +1169,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):
|
||||
|
||||
+350
-1
@@ -7,7 +7,9 @@ from unittest import mock
|
||||
|
||||
import remote_packages
|
||||
import setup_tool
|
||||
import setup_tool_dynamic
|
||||
from setup_tool import WowSetupTool
|
||||
from setup_tool_dynamic import ModernWowSetupTool
|
||||
|
||||
|
||||
class FakeVar:
|
||||
@@ -612,6 +614,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)
|
||||
@@ -635,7 +814,7 @@ class WowPresenceIntegrationTests(unittest.TestCase):
|
||||
remote_packages.WOWPRESENCE_DEFAULT_APPLICATION_ID,
|
||||
)
|
||||
with open(flags, "r", encoding="ascii") as handle:
|
||||
self.assertEqual(handle.read().strip(), "63")
|
||||
self.assertEqual(handle.read().strip(), "127")
|
||||
|
||||
with open(app_id, "w", encoding="ascii") as handle:
|
||||
handle.write(
|
||||
@@ -658,6 +837,81 @@ class WowPresenceIntegrationTests(unittest.TestCase):
|
||||
with open(flags, "r", encoding="ascii") as handle:
|
||||
self.assertEqual(handle.read().strip(), "31")
|
||||
|
||||
def test_install_wowpresence_from_zip_preserves_user_config(self):
|
||||
with tempfile.TemporaryDirectory() as root, tempfile.TemporaryDirectory() as package_root:
|
||||
data_dir = remote_packages.ensure_wowpresence_config(root)
|
||||
app_id = os.path.join(data_dir, "discord_application_id")
|
||||
flags = os.path.join(data_dir, "discord_broadcast_flags")
|
||||
with open(app_id, "w", encoding="ascii") as handle:
|
||||
handle.write("123456789012345678\n")
|
||||
with open(flags, "w", encoding="ascii") as handle:
|
||||
handle.write("31\n")
|
||||
|
||||
dll = os.path.join(package_root, "WowPresence.dll")
|
||||
exe = os.path.join(package_root, "WowPresence.exe")
|
||||
PeValidationTests().write_minimal_x86_pe(dll)
|
||||
PeValidationTests().write_minimal_x86_pe(exe)
|
||||
|
||||
zip_path = os.path.join(package_root, "WowPresence.zip")
|
||||
with remote_packages.zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.write(dll, "WowPresence.dll")
|
||||
archive.write(exe, "WowPresence.exe")
|
||||
archive.writestr(
|
||||
"WowPresence/discord_application_id",
|
||||
"999999999999999999\n",
|
||||
)
|
||||
archive.writestr("WowPresence/discord_broadcast_flags", "0\n")
|
||||
|
||||
release = {
|
||||
"tag_name": "v-test",
|
||||
"assets": [
|
||||
{
|
||||
"name": "WowPresence.zip",
|
||||
"browser_download_url": "https://example.invalid/WowPresence.zip",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with mock.patch(
|
||||
"remote_packages._latest_release",
|
||||
return_value=release,
|
||||
) as latest_release, mock.patch(
|
||||
"remote_packages._download_asset",
|
||||
return_value=zip_path,
|
||||
) as download:
|
||||
self.assertEqual(
|
||||
remote_packages.install_wowpresence(root),
|
||||
"v-test",
|
||||
)
|
||||
|
||||
latest_release.assert_called_once_with(
|
||||
remote_packages.WOWPRESENCE_REPO,
|
||||
)
|
||||
download.assert_called_once()
|
||||
remote_packages._verify_x86_pe(
|
||||
os.path.join(root, "WowPresence.dll"),
|
||||
"WowPresence.dll",
|
||||
)
|
||||
remote_packages._verify_x86_pe(
|
||||
os.path.join(root, "WowPresence.exe"),
|
||||
"WowPresence.exe",
|
||||
)
|
||||
|
||||
with open(app_id, "r", encoding="ascii") as handle:
|
||||
self.assertEqual(handle.read().strip(), "123456789012345678")
|
||||
with open(flags, "r", encoding="ascii") as handle:
|
||||
self.assertEqual(handle.read().strip(), "31")
|
||||
|
||||
manifest = remote_packages._load_managed_manifest_data(
|
||||
root,
|
||||
remote_packages.WOWPRESENCE_MANAGED_ID,
|
||||
)
|
||||
self.assertEqual(manifest.get("revision"), "v-test")
|
||||
self.assertEqual(
|
||||
set((manifest.get("file_sha256") or {}).keys()),
|
||||
{"WowPresence.dll", "WowPresence.exe"},
|
||||
)
|
||||
|
||||
def test_unchecked_manual_wowpresence_is_untouched(self):
|
||||
tool = self.make_tool(selected=False)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
@@ -793,6 +1047,101 @@ 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_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(),
|
||||
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(), 63)
|
||||
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):
|
||||
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.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())
|
||||
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_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)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user