1212 lines
57 KiB
Python
1212 lines
57 KiB
Python
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
|
|
import json
|
|
import hashlib
|
|
import os
|
|
import sys
|
|
import ssl
|
|
import re
|
|
import subprocess
|
|
import urllib.request
|
|
from urllib.parse import urlsplit
|
|
import shutil
|
|
import stat
|
|
import struct
|
|
import time
|
|
import math
|
|
import threading
|
|
import queue
|
|
from functools import cache
|
|
from pathlib import Path
|
|
|
|
import urllib.error
|
|
import zipfile
|
|
import io
|
|
from app_log import log
|
|
from config import SERVER, UA, UPDATER_VERSION, load_config, update_config, ensure_dir
|
|
from net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
|
|
|
|
MODS_REGISTRY = [
|
|
{
|
|
"id": "VanillaFixes",
|
|
"essential": True,
|
|
"name": "VanillaFixes",
|
|
"description": "Eliminates stuttering and animation lag and enables mod loading. Required by other mods.",
|
|
"repo_url": "https://github.com/hannesmann/vanillafixes",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "hannesmann",
|
|
"repo": "vanillafixes",
|
|
"asset_pattern": "vanillafixes-*.zip",
|
|
"prefer_no": "-dxvk",
|
|
"extract_map": {"VfPatcher.dll": "VfPatcher.dll",
|
|
"VanillaFixes.exe": "VanillaFixes.exe"},
|
|
},
|
|
"register_dll": None, # the DLL loader itself
|
|
"installed_files": ["VfPatcher.dll", "VanillaFixes.exe"],
|
|
},
|
|
{
|
|
"id": "ClassicAPI",
|
|
"essential": True,
|
|
"name": "ClassicAPI",
|
|
"description": "Expands the addon API with functions from later WoW versions.",
|
|
"repo_url": "https://github.com/brues-code/ClassicAPI",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "brues-code",
|
|
"repo": "ClassicAPI",
|
|
"asset_pattern": "ClassicAPI.dll",
|
|
"prefer_no": None,
|
|
"extract_map": None,
|
|
},
|
|
"register_dll": "ClassicAPI.dll",
|
|
"installed_files": ["ClassicAPI.dll"],
|
|
},
|
|
{
|
|
"id": "AuctionQueryThrottle",
|
|
"essential": True,
|
|
"name": "AuctionQueryThrottle",
|
|
"description": "Removes the fixed 5-second wait between auction house searches.",
|
|
"repo_url": "https://github.com/brues-code/AuctionQueryThrottle",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "brues-code",
|
|
"repo": "AuctionQueryThrottle",
|
|
"asset_pattern": "AuctionQueryThrottle.dll",
|
|
"prefer_no": None,
|
|
"extract_map": None,
|
|
},
|
|
"register_dll": "AuctionQueryThrottle.dll",
|
|
"installed_files": ["AuctionQueryThrottle.dll"],
|
|
},
|
|
{
|
|
"id": "dxvk",
|
|
"essential": True,
|
|
"name": "DXVK",
|
|
"description": "Enables Vulkan-based rendering for improved performance.",
|
|
"repo_url": "https://github.com/doitsujin/dxvk",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "doitsujin",
|
|
"repo": "dxvk",
|
|
"asset_pattern": "dxvk-[0-9]*.tar.gz",
|
|
"prefer_no": "-native",
|
|
"extract_map": {"dxvk-*/x32/d3d9.dll": "d3d9.dll"},
|
|
"post_install": ["write_dxvk_conf"],
|
|
},
|
|
"register_dll": None, # d3d9.dll auto-loads as a DirectX proxy
|
|
"installed_files": ["d3d9.dll", "dxvk.conf"],
|
|
},
|
|
{
|
|
"id": "nampower",
|
|
"essential": True,
|
|
"name": "Nampower",
|
|
"description": "Reduces input lag and expands the addon API.",
|
|
"repo_url": "https://github.com/Emyrk/nampower",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "Emyrk",
|
|
"repo": "nampower",
|
|
"asset_pattern": "nampower.dll",
|
|
"prefer_no": None,
|
|
"extract_map": None,
|
|
},
|
|
"register_dll": "nampower.dll",
|
|
"installed_files": ["nampower.dll"],
|
|
},
|
|
{
|
|
"id": "SuperWoW",
|
|
"essential": True,
|
|
"name": "SuperWoW",
|
|
"description": "Adds new features, including healing combat text, chat links of spells and recipes, and expands the addon API.",
|
|
"repo_url": "https://github.com/balakethelock/SuperWoW",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "balakethelock",
|
|
"repo": "SuperWoW",
|
|
"asset_pattern": "SuperWoW*.zip",
|
|
"prefer_no": None,
|
|
# SuperWoW keeps a static "Release" tag and edits it in place, so
|
|
# the version comes from the asset filename (e.g. …2.2.zip), not
|
|
# the tag.
|
|
"version_from": "asset",
|
|
"extract_map": {"SuperWoWhook.dll": "SuperWoWhook.dll"},
|
|
},
|
|
"register_dll": "SuperWoWhook.dll",
|
|
"installed_files": ["SuperWoWhook.dll"],
|
|
},
|
|
{
|
|
"id": "transmogfix",
|
|
"essential": True,
|
|
"name": "TransmogFix",
|
|
"description": "Prevents frame drops on character death caused by the server-side transmogrification durability recalculation.",
|
|
"repo_url": "https://codeberg.org/MarcelineVQ/WeirdUtils",
|
|
"source": {
|
|
"kind": "direct_file",
|
|
"url": "https://codeberg.org/MarcelineVQ/WeirdUtils/releases/download/v0.7.0/transmogfix.dll",
|
|
"dest": "transmogfix.dll",
|
|
"pinned_version": "v0.7.0",
|
|
},
|
|
"register_dll": "transmogfix.dll",
|
|
"installed_files": ["transmogfix.dll"],
|
|
},
|
|
{
|
|
"id": "UnitXP_SP3",
|
|
"essential": True,
|
|
"name": "UnitXP_SP3",
|
|
"description": "Adds new features, including frame limiter, improved targeting, and anti-aliased combat text, and expands the addon API.",
|
|
"repo_url": "https://codeberg.org/konaka/UnitXP_SP3",
|
|
"source": {
|
|
"kind": "codeberg_release",
|
|
"owner": "konaka",
|
|
"repo": "UnitXP_SP3",
|
|
"asset_pattern": "UnitXP_SP3 v*.zip",
|
|
"prefer_no": "-debug",
|
|
"extract_map": {"UnitXP_SP3.dll": "UnitXP_SP3.dll"},
|
|
},
|
|
"register_dll": "UnitXP_SP3.dll",
|
|
"installed_files": ["UnitXP_SP3.dll"],
|
|
},
|
|
{
|
|
"id": "VanillaHelpers",
|
|
"essential": True,
|
|
"name": "VanillaHelpers",
|
|
"description": "Increases the maximum supported texture resolution and improves memory allocation.",
|
|
"repo_url": "https://github.com/isfir/VanillaHelpers",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "isfir",
|
|
"repo": "VanillaHelpers",
|
|
"asset_pattern": "VanillaHelpers.dll",
|
|
"prefer_no": None,
|
|
"extract_map": None,
|
|
},
|
|
"register_dll": "VanillaHelpers.dll",
|
|
"installed_files": ["VanillaHelpers.dll"],
|
|
},
|
|
{
|
|
"id": "VanillaMultiMonitorFix",
|
|
"essential": False,
|
|
"name": "VanillaMultiMonitorFix",
|
|
"description": "Fixes incorrect game resolution on multi-monitor setups with different resolutions. Set the desired monitor's index in VMMFix_preferred_monitor.txt; use ShowAllDisplayDevices.exe to find the index.",
|
|
"repo_url": "https://github.com/Mates1500/VanillaMultiMonitorFix",
|
|
"source": {
|
|
"kind": "github_release",
|
|
"owner": "Mates1500",
|
|
"repo": "VanillaMultiMonitorFix",
|
|
"asset_pattern": "release.zip",
|
|
"prefer_no": None,
|
|
"extract_map": {"VanillaMultiMonitorFix.dll":
|
|
"VanillaMultiMonitorFix.dll",
|
|
"VMMFix_preferred_monitor.txt":
|
|
"VMMFix_preferred_monitor.txt",
|
|
"ShowAllDisplayDevices.exe":
|
|
"ShowAllDisplayDevices.exe"},
|
|
},
|
|
"register_dll": "VanillaMultiMonitorFix.dll",
|
|
"installed_files": ["VanillaMultiMonitorFix.dll",
|
|
"VMMFix_preferred_monitor.txt",
|
|
"ShowAllDisplayDevices.exe"],
|
|
},
|
|
{
|
|
"id": "no1600x1200",
|
|
"essential": False,
|
|
"name": "No1600x1200",
|
|
"description": "Fixes incorrect game resolution when your monitor native resolution isn't detected or 1600x1200 is listed as the maximum.",
|
|
"repo_url": "https://github.com/RetroCro/TurtleWoW-Mods",
|
|
"source": {
|
|
"kind": "direct_file",
|
|
"url": "https://raw.githubusercontent.com/RetroCro/TurtleWoW-Mods/refs/heads/main/Archive/DLL%20BACKUP/no1600x1200.dll",
|
|
"dest": "no1600x1200.dll",
|
|
"pinned_version": "1.0",
|
|
},
|
|
"register_dll": "no1600x1200.dll",
|
|
"installed_files": ["no1600x1200.dll"],
|
|
},
|
|
]
|
|
|
|
GITHUB_API = "https://api.github.com"
|
|
|
|
# Self-update: the updater checks its own GitHub releases once a day.
|
|
UPDATER_REPO = "rebasedkon/octo-updater"
|
|
UPDATER_CHECK_TTL = 86400 # 1 day, cached in the config file
|
|
|
|
|
|
def _parse_version(v: str) -> tuple:
|
|
"""'v1.2.0' → (1, 2, 0); non-numeric parts become 0."""
|
|
parts = []
|
|
for p in (v or "").strip().lstrip("vV").split("."):
|
|
digits = "".join(ch for ch in p if ch.isdigit())
|
|
parts.append(int(digits) if digits else 0)
|
|
return tuple(parts) or (0,)
|
|
|
|
|
|
def fetch_updater_latest_tag(force: bool = False) -> str | None:
|
|
"""Latest release tag of the updater's own repo, cached for a day. Returns
|
|
None when there are no releases yet (GitHub 404) or on any error."""
|
|
now = time.time()
|
|
if not force:
|
|
entry = load_config().get("updater_release_cache", {})
|
|
if (entry.get("tag") is not None
|
|
and (now - entry.get("timestamp", 0)) < UPDATER_CHECK_TTL):
|
|
return entry["tag"]
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{GITHUB_API}/repos/{UPDATER_REPO}/releases/latest",
|
|
headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=10) as r:
|
|
tag = json.load(r).get("tag_name")
|
|
except Exception:
|
|
return None
|
|
if tag:
|
|
update_config(lambda c: c.__setitem__(
|
|
"updater_release_cache", {"timestamp": now, "tag": tag}))
|
|
return tag
|
|
|
|
|
|
def updater_update_available(latest_tag: str) -> bool:
|
|
if not latest_tag:
|
|
return False
|
|
a, b = _parse_version(latest_tag), _parse_version(UPDATER_VERSION)
|
|
n = max(len(a), len(b)) # zero-pad so 1.1 == 1.1.0
|
|
a += (0,) * (n - len(a))
|
|
b += (0,) * (n - len(b))
|
|
return a > b
|
|
|
|
|
|
def _codeberg_latest(owner: str, repo: str, raise_errors=False) -> dict | None:
|
|
url = f"https://codeberg.org/api/v1/repos/{owner}/{repo}/releases?limit=10&pre-release=false"
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=10) as r:
|
|
releases = json.load(r)
|
|
for rel in releases:
|
|
if not rel.get("prerelease", False) and not rel.get("draft", False):
|
|
return rel
|
|
return releases[0] if releases else None
|
|
except Exception as e:
|
|
if raise_errors:
|
|
raise RuntimeError(_describe_net_error(e)) from e
|
|
return None
|
|
|
|
|
|
DXVK_CONF_CONTENT = """# Low latency - limit queued frames - helps input lag
|
|
d3d9.maxFrameLatency = 1
|
|
# Forces clamp for AF through DXVK - if you see grass textures shimmering or shaking try false
|
|
d3d9.clampNegativeLodBias = True
|
|
# Disable logging for performance
|
|
dxvk.logLevel = none
|
|
# Overrides synchronization interval (Vsync) for presentation.
|
|
# Setting this to 0 disables vertical synchronization entirely.
|
|
# A positive value 'n' will enable Vsync and repeat the same
|
|
# image n times, and a negative value will have no effect.
|
|
#
|
|
# Value -1 uses the application's setting
|
|
d3d9.presentInterval = -1
|
|
# VanillaFix handles DPI awareness; avoid double-scaling
|
|
d3d9.dpiAware = False
|
|
# Enable GPL if supported to reduce stuttering (NVIDIA 473.33+, AMD 24.6.1+)
|
|
dxvk.enableGraphicsPipelineLibrary = Auto
|
|
# Track pipeline lifetimes to reduce memory usage
|
|
dxvk.trackPipelineLifetime = True
|
|
# Limit compiler threads to reduce memory usage
|
|
dxvk.numCompilerThreads = 2
|
|
"""
|
|
|
|
|
|
def _write_dxvk_conf(client_dir: str):
|
|
path = os.path.join(client_dir, "dxvk.conf")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(DXVK_CONF_CONTENT)
|
|
log(" Wrote dxvk.conf")
|
|
|
|
|
|
def _github_latest(owner: str, repo: str, raise_errors=False) -> dict | None:
|
|
url = f"{GITHUB_API}/repos/{owner}/{repo}/releases/latest"
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=10) as r:
|
|
return json.load(r)
|
|
except Exception as e:
|
|
if raise_errors:
|
|
raise RuntimeError(_describe_net_error(e)) from e
|
|
return None
|
|
|
|
|
|
def _pick_asset(assets: list, pattern: str, prefer_no) -> dict | None:
|
|
import fnmatch
|
|
candidates = [a for a in assets if fnmatch.fnmatch(a["name"], pattern)]
|
|
if prefer_no:
|
|
preferred = [a for a in candidates if prefer_no not in a["name"]]
|
|
if preferred:
|
|
candidates = preferred
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def _release_version(mod: dict, rel: dict) -> str | None:
|
|
"""Version string for a github/codeberg release. Normally the tag name —
|
|
but some mods (e.g. SuperWoW) keep a static tag and edit the release in
|
|
place, so their tag never changes. For those, derive the version from the
|
|
matched asset instead: its filename embeds the real version."""
|
|
src = mod["source"]
|
|
if src.get("version_from") == "asset":
|
|
asset = _pick_asset(rel.get("assets", []), src["asset_pattern"],
|
|
src.get("prefer_no"))
|
|
if asset and asset.get("name"):
|
|
import re
|
|
m = re.search(r"\d+(?:[._]\d+)+", asset["name"])
|
|
return m.group(0) if m else asset["name"]
|
|
return rel.get("tag_name")
|
|
|
|
|
|
def fetch_mod_latest_version(mod: dict) -> str | None:
|
|
src = mod["source"]
|
|
kind = src["kind"]
|
|
if kind == "github_release":
|
|
rel = _github_latest(src["owner"], src["repo"])
|
|
if rel:
|
|
return _release_version(mod, rel)
|
|
elif kind == "codeberg_release":
|
|
rel = _codeberg_latest(src["owner"], src["repo"])
|
|
if rel:
|
|
return _release_version(mod, rel)
|
|
elif kind in ("direct_file", "direct_tar"):
|
|
return src.get("pinned_version")
|
|
return None
|
|
|
|
|
|
_MOD_VERSION_CACHE_TTL = 3600
|
|
|
|
|
|
def _slim_release(rel: dict) -> dict:
|
|
"""Reduce an API release object to the fields the updater actually uses,
|
|
so the persisted cache stays small."""
|
|
return {
|
|
"tag_name": rel.get("tag_name"),
|
|
"assets": [{"name": a.get("name"),
|
|
"size": a.get("size", 0),
|
|
"browser_download_url": a.get("browser_download_url")}
|
|
for a in rel.get("assets", [])],
|
|
}
|
|
|
|
|
|
def _fetch_release_cached(mod: dict, force: bool = False) -> dict | None:
|
|
"""Latest-release lookup backed by a persistent cache in the config file
|
|
({"mod_release_cache": {mod_id: {"timestamp": epoch, "release": {…}}}}),
|
|
so restarts within the TTL don't re-hit the GitHub/Codeberg APIs."""
|
|
src = mod["source"]
|
|
kind = src["kind"]
|
|
if kind not in ("github_release", "codeberg_release"):
|
|
return None
|
|
mid = mod["id"]
|
|
now = time.time()
|
|
if not force:
|
|
entry = load_config().get("mod_release_cache", {}).get(mid)
|
|
if entry and (now - entry.get("timestamp", 0)) < _MOD_VERSION_CACHE_TTL:
|
|
return entry.get("release")
|
|
if kind == "github_release":
|
|
rel = _github_latest(src["owner"], src["repo"])
|
|
else:
|
|
rel = _codeberg_latest(src["owner"], src["repo"])
|
|
if rel is None:
|
|
return None
|
|
rel = _slim_release(rel)
|
|
update_config(lambda c: c.setdefault("mod_release_cache", {}).__setitem__(
|
|
mid, {"timestamp": now, "release": rel}))
|
|
return rel
|
|
|
|
|
|
def fetch_mod_latest_version_cached(mod: dict, force: bool = False) -> str | None:
|
|
src = mod["source"]
|
|
kind = src["kind"]
|
|
if kind in ("direct_file", "direct_tar"):
|
|
return src.get("pinned_version")
|
|
rel = _fetch_release_cached(mod, force=force)
|
|
if rel:
|
|
return _release_version(mod, rel)
|
|
return None
|
|
|
|
|
|
def install_mod(mod: dict, client_dir: str, release: dict | None = None) -> list:
|
|
src = mod["source"]
|
|
written = []
|
|
|
|
if src["kind"] == "codeberg_release":
|
|
rel = release if release is not None else \
|
|
_codeberg_latest(src["owner"], src["repo"], raise_errors=True)
|
|
if not rel:
|
|
raise RuntimeError("no release found on Codeberg")
|
|
import fnmatch
|
|
assets = rel.get("assets", [])
|
|
asset = next(
|
|
(a for a in assets if fnmatch.fnmatch(a["name"], src["asset_pattern"])
|
|
and (not src.get("prefer_no") or src["prefer_no"] not in a["name"])),
|
|
None
|
|
)
|
|
if not asset:
|
|
raise RuntimeError(
|
|
f"No matching asset '{src['asset_pattern']}' in {mod['id']} release")
|
|
log(f" Downloading {asset['name']} ({asset['size']//1024} KB)...")
|
|
req = urllib.request.Request(asset["browser_download_url"],
|
|
headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=120,
|
|
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
|
|
data = r.read()
|
|
if src.get("extract_map") is None:
|
|
dest_rel = asset["name"]
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
else:
|
|
import zipfile
|
|
tmp_path = os.path.join(client_dir, f"_mod_tmp_{mod['id']}.zip")
|
|
try:
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(data)
|
|
with zipfile.ZipFile(tmp_path) as zf:
|
|
for zip_path, dest_rel in src["extract_map"].items():
|
|
try:
|
|
zip_data = zf.read(zip_path)
|
|
except KeyError:
|
|
log(f" Warning: {zip_path} not in zip, skipping")
|
|
continue
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(zip_data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
elif src["kind"] == "github_release":
|
|
rel = release if release is not None else \
|
|
_github_latest(src["owner"], src["repo"], raise_errors=True)
|
|
if not rel:
|
|
raise RuntimeError("no release found on GitHub")
|
|
asset = _pick_asset(rel.get("assets", []), src["asset_pattern"], src["prefer_no"])
|
|
if not asset:
|
|
raise RuntimeError(
|
|
f"No matching asset '{src['asset_pattern']}' in {mod['id']} release")
|
|
log(f" Downloading {asset['name']} ({asset['size']//1024} KB)...")
|
|
req = urllib.request.Request(asset["browser_download_url"],
|
|
headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=120,
|
|
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
|
|
data = r.read()
|
|
|
|
if src.get("extract_map") is None:
|
|
dest_rel = asset["name"]
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
elif asset["name"].endswith(".tar.gz") or asset["name"].endswith(".tgz"):
|
|
import tarfile, io, fnmatch as _fnmatch
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
|
all_names = tf.getnames()
|
|
for pattern, dest_rel in src["extract_map"].items():
|
|
matched = (
|
|
pattern if pattern in all_names
|
|
else next(
|
|
(n for n in all_names if _fnmatch.fnmatch(n, pattern)),
|
|
None
|
|
)
|
|
)
|
|
if matched is None:
|
|
log(f" Warning: no file matching '{pattern}' in tar, skipping")
|
|
continue
|
|
f_obj = tf.extractfile(tf.getmember(matched))
|
|
tar_data = f_obj.read()
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(tar_data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
else:
|
|
import zipfile
|
|
tmp_path = os.path.join(client_dir, f"_mod_tmp_{mod['id']}.zip")
|
|
try:
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(data)
|
|
with zipfile.ZipFile(tmp_path) as zf:
|
|
for zip_path, dest_rel in src["extract_map"].items():
|
|
try:
|
|
zip_data = zf.read(zip_path)
|
|
except KeyError:
|
|
log(f" Warning: {zip_path} not found in zip, skipping")
|
|
continue
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(zip_data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
elif src["kind"] == "direct_tar":
|
|
log(f" Downloading {src['url'].split('/')[-1]}...")
|
|
req = urllib.request.Request(src["url"], headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=120,
|
|
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
|
|
data = r.read()
|
|
import tarfile, io as _io, fnmatch as _fnmatch
|
|
with tarfile.open(fileobj=_io.BytesIO(data), mode="r:gz") as tf:
|
|
all_names = tf.getnames()
|
|
for pattern, dest_rel in src["extract_map"].items():
|
|
matched = pattern if pattern in all_names else next(
|
|
(n for n in all_names if _fnmatch.fnmatch(n, pattern)), None)
|
|
if matched is None:
|
|
log(f" Warning: no file matching '{pattern}' in tar, skipping")
|
|
continue
|
|
tar_data = tf.extractfile(tf.getmember(matched)).read()
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(tar_data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
if src.get("pinned_version"):
|
|
mod["_resolved_version"] = src["pinned_version"]
|
|
|
|
elif src["kind"] == "direct_file":
|
|
url = src["url"]
|
|
log(f" Downloading {url.rsplit('/', 1)[-1]}...")
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=120,
|
|
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r:
|
|
data = r.read()
|
|
|
|
if src.get("extract_map") is None:
|
|
dest_rel = src["dest"]
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
elif url.endswith(".tar.gz") or url.endswith(".tgz"):
|
|
import tarfile, io, fnmatch as _fnmatch
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
|
all_names = tf.getnames()
|
|
for pattern, dest_rel in src["extract_map"].items():
|
|
matched = (
|
|
pattern if pattern in all_names
|
|
else next(
|
|
(n for n in all_names if _fnmatch.fnmatch(n, pattern)),
|
|
None
|
|
)
|
|
)
|
|
if matched is None:
|
|
log(f" Warning: no file matching '{pattern}' in tar, skipping")
|
|
continue
|
|
f_obj = tf.extractfile(tf.getmember(matched))
|
|
tar_data = f_obj.read()
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(tar_data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
else:
|
|
import zipfile
|
|
tmp_path = os.path.join(client_dir, f"_mod_tmp_{mod['id']}.zip")
|
|
try:
|
|
with open(tmp_path, "wb") as f:
|
|
f.write(data)
|
|
with zipfile.ZipFile(tmp_path) as zf:
|
|
for zip_path, dest_rel in src["extract_map"].items():
|
|
try:
|
|
zip_data = zf.read(zip_path)
|
|
except KeyError:
|
|
log(f" Warning: {zip_path} not in zip, skipping")
|
|
continue
|
|
dest = os.path.join(client_dir, dest_rel)
|
|
os.makedirs(os.path.dirname(dest) or client_dir, exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(zip_data)
|
|
written.append(dest_rel)
|
|
log(f" Installed {dest_rel}")
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
if src.get("pinned_version"):
|
|
mod["_resolved_version"] = src["pinned_version"]
|
|
|
|
for hook in src.get("post_install", []):
|
|
if hook == "write_dxvk_conf":
|
|
_write_dxvk_conf(client_dir)
|
|
written.append("dxvk.conf")
|
|
|
|
return written
|
|
|
|
|
|
def uninstall_mod(mod: dict, client_dir: str):
|
|
cfg = load_config()
|
|
state = cfg.get("mods", {}).get(mod["id"], {})
|
|
files = state.get("installed_files", mod.get("installed_files", []))
|
|
for rel in files:
|
|
full = os.path.join(client_dir, rel)
|
|
if os.path.exists(full):
|
|
os.remove(full)
|
|
log(f" Removed {rel}")
|
|
|
|
|
|
def _dlls_txt_path(client_dir: str) -> str:
|
|
return os.path.join(client_dir, "dlls.txt")
|
|
|
|
|
|
def add_dll(client_dir: str, name: str):
|
|
path = _dlls_txt_path(client_dir)
|
|
lines = open(path).read().splitlines() if os.path.exists(path) else []
|
|
if any(l.strip().lower() == name.lower() for l in lines):
|
|
return
|
|
lines = [l for l in lines if l.strip()] + [name]
|
|
with open(path, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def remove_dll(client_dir: str, name: str):
|
|
path = _dlls_txt_path(client_dir)
|
|
if not os.path.exists(path):
|
|
return
|
|
lines = [l for l in open(path).read().splitlines()
|
|
if l.strip().lower() != name.lower()]
|
|
if not lines:
|
|
os.remove(path)
|
|
else:
|
|
with open(path, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|
|
|
|
|
|
def mod_installed_files_present(mod: dict, client_dir: str) -> bool:
|
|
cfg = load_config()
|
|
state = cfg.get("mods", {}).get(mod["id"], {})
|
|
files = state.get("installed_files", [])
|
|
return bool(files) and all(
|
|
os.path.exists(os.path.join(client_dir, f)) for f in files)
|
|
|
|
|
|
def mod_supports_update_check(mod: dict) -> bool:
|
|
return mod["source"]["kind"] not in ("direct_file", "direct_tar")
|
|
|
|
|
|
def mod_update_available(mod: dict, state: dict, live: dict | None) -> bool:
|
|
if not mod_supports_update_check(mod):
|
|
return False
|
|
if not state.get("enabled", False):
|
|
return False
|
|
if state.get("ignore_updates", False):
|
|
return False
|
|
installed_ver = state.get("installed_version")
|
|
if not installed_ver:
|
|
return False
|
|
latest_ver = (live or {}).get("latest_version")
|
|
if not latest_ver:
|
|
return False
|
|
return latest_ver != installed_ver
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Addons definition & engine (installs via git-archive zips pinned to a
|
|
# commit sha, so no full git client is needed)
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
ADDONS_URL = f"{SERVER}/api/addons.json"
|
|
ADDONS_CATALOG_TTL = 86400 # 1 day, persisted in the config file
|
|
ADDON_SHA_CACHE_TTL = 3600
|
|
ADDONS_VERIFY_TTL = 300 # skip re-verify on tab switches within this
|
|
|
|
# Curated recommended addons: {folder_name: git_url}. Every entry carries
|
|
# its git link explicitly, so a recommended addon always appears — even if
|
|
# addons.json renames or drops it. Where the link differs from the catalog,
|
|
# it's a deliberate fork preference.
|
|
RECOMMENDED_ADDONS = {
|
|
"AtlasLoot": "https://github.com/Otari98/AtlasLoot",
|
|
"aux-addon": "https://github.com/OldManAlpha/aux-addon",
|
|
"BetterCharacterStats": "https://github.com/pepopo978/BetterCharacterStats",
|
|
"DoiteAuras": "https://github.com/deceius/DoiteAuras",
|
|
"FlightTracker": "https://github.com/Lexxoi/FlightTracker",
|
|
"InstanceJournal": "https://github.com/Arthur-Helias/InstanceJournal",
|
|
"ItemRack": "https://github.com/Otari98/ItemRack",
|
|
"LevelRange-Octo": "https://github.com/Dusk-92/LevelRange-Octo",
|
|
"Magnify": "https://github.com/paokkerkir/Magnify",
|
|
"ModernMapMarkers": "https://github.com/tilare/ModernMapMarkers",
|
|
"NampowerSettings": "https://github.com/Dusk-92/NampowerSettings",
|
|
"PallyPowerTW": "https://github.com/ShikawaLePaladin/PallyPowerTW",
|
|
"pfQuest": "https://github.com/The-Kludge-Bureau/pfQuest",
|
|
"pfQuest-turtle": "https://github.com/KameleonUK/pfQuest-turtle",
|
|
"pfUI": "https://github.com/brues-code/pfUI",
|
|
"ShaguDPS": "https://github.com/shagu/ShaguDPS",
|
|
"SUCC-bag": "https://github.com/Otari98/SUCC-bag",
|
|
"SuperAPI": "https://github.com/balakethelock/SuperAPI",
|
|
"SuperCleveRoidMacros": "https://github.com/brues-code/SuperCleveRoidMacros",
|
|
"Tmog": "https://github.com/Otari98/Tmog",
|
|
"TrinketMenu": "https://github.com/jrc13245/TrinketMenu",
|
|
"TurtleCalendar": "https://github.com/Wayoff333/TurtleCalendar",
|
|
"TurtleMail": "https://github.com/sica42/TurtleMail",
|
|
"UnitXP_SP3_Addon": "https://github.com/rebasedkon/UnitXP_SP3_Addon",
|
|
"WhatsTraining_Turtle": "https://github.com/rebasedkon/WhatsTraining_Turtle",
|
|
}
|
|
|
|
# Never shown in the updater, even when present in addons.json.
|
|
BLOCKED_ADDONS = {
|
|
"SuperMacro", # SuperMacro - SuperWoW Support
|
|
"Rested", # Rested XP (hazlema)
|
|
"LevelRange-Turtle", # LevelRange [Turtle] — LevelRange-Octo replaces it
|
|
"CleveRoidMacros", # SuperCleveRoidMacros replaces it
|
|
"BlizzPlates", # Blizzard Plates
|
|
"PallyPower", # CosminPOP PallyPower — PallyPowerTW replaces it
|
|
}
|
|
|
|
|
|
def _same_git_repo(a, b) -> bool:
|
|
"""Compare git URLs ignoring a trailing '.git' / slash and case."""
|
|
def norm(u):
|
|
u = (u or "").rstrip("/")
|
|
return (u[:-4] if u.endswith(".git") else u).lower()
|
|
return norm(a) == norm(b)
|
|
|
|
ADDON_GIT_HOSTS = ("github.com", "gitlab.com", "gitea.com", "codeberg.org")
|
|
|
|
ADDON_ZIP_HOSTS = {"github.com", "codeload.github.com", "gitlab.com",
|
|
"gitea.com", "codeberg.org"}
|
|
|
|
|
|
def addons_path(client_dir: str) -> str:
|
|
return os.path.join(client_dir, "Interface", "AddOns")
|
|
|
|
|
|
def is_allowed_git_url(url: str) -> bool:
|
|
try:
|
|
parts = urlsplit(url)
|
|
except ValueError:
|
|
return False
|
|
if parts.scheme != "https":
|
|
return False
|
|
host = (parts.hostname or "").lower()
|
|
return any(host == h or host.endswith("." + h) for h in ADDON_GIT_HOSTS)
|
|
|
|
|
|
def _slim_addon_catalog(catalog: list) -> list:
|
|
"""Keep only the catalog fields the updater actually uses, so the
|
|
persisted cache stays small."""
|
|
slim = []
|
|
for a in catalog:
|
|
toc = a.get("toc") or {}
|
|
slim.append({
|
|
"name": a.get("name"),
|
|
"git": a.get("git"),
|
|
"branch": a.get("branch"),
|
|
"ref": a.get("ref"),
|
|
"description": a.get("description"),
|
|
"toc": {k: toc[k] for k in ("Title", "Notes", "Interface")
|
|
if k in toc},
|
|
})
|
|
return slim
|
|
|
|
|
|
def fetch_addons_catalog(force=False) -> list:
|
|
"""Addon catalog, cached in the config file for a day
|
|
({"addons_catalog_cache": {"timestamp": epoch, "catalog": […]}})."""
|
|
now = time.time()
|
|
if not force:
|
|
entry = load_config().get("addons_catalog_cache", {})
|
|
if (entry.get("catalog") is not None
|
|
and (now - entry.get("timestamp", 0)) < ADDONS_CATALOG_TTL):
|
|
return entry["catalog"]
|
|
req = urllib.request.Request(ADDONS_URL, headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=10) as r:
|
|
catalog = _slim_addon_catalog(json.load(r))
|
|
update_config(lambda c: c.__setitem__(
|
|
"addons_catalog_cache", {"timestamp": now, "catalog": catalog}))
|
|
return catalog
|
|
|
|
|
|
def read_toc_file(path: str) -> dict:
|
|
"""Parse '## Key: Value' metadata lines from a WoW addon .toc file."""
|
|
toc = {}
|
|
try:
|
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
except OSError:
|
|
return toc
|
|
if content.startswith("\ufeff"): # strip UTF-8 BOM
|
|
content = content[1:]
|
|
for line in content.splitlines():
|
|
if not line.startswith("## "):
|
|
continue
|
|
key, sep, value = line[3:].partition(":")
|
|
if sep:
|
|
toc[key.strip()] = value.strip()
|
|
return toc
|
|
|
|
|
|
def parse_wow_colored(text: str):
|
|
"""Split a string containing WoW colour escapes (|cAARRGGBB … |r) into
|
|
[(segment, "#rrggbb" | None), …] for rendering."""
|
|
import re
|
|
segments = []
|
|
color = None
|
|
pos = 0
|
|
for m in re.finditer(r"\|c[0-9a-fA-F]{8}|\|r", text):
|
|
if m.start() > pos:
|
|
segments.append((text[pos:m.start()], color))
|
|
tok = m.group(0)
|
|
color = f"#{tok[4:]}" if tok.startswith("|c") else None
|
|
pos = m.end()
|
|
if pos < len(text):
|
|
segments.append((text[pos:], color))
|
|
return [(t, c) for t, c in segments if t]
|
|
|
|
|
|
def strip_wow_colors(text: str) -> str:
|
|
return "".join(t for t, _c in parse_wow_colored(text))
|
|
|
|
|
|
def _git_parts(git_url: str):
|
|
"""→ (kind, repo_url, owner, repo, api_base); kind ∈ github/gitlab/gitea.
|
|
Handles path prefixes like octowow.st/git/<owner>/<repo>."""
|
|
parts = urlsplit(git_url)
|
|
host = (parts.hostname or "").lower()
|
|
segs = [s for s in parts.path.split("/") if s]
|
|
if len(segs) < 2:
|
|
raise ValueError(f"Unsupported git URL: {git_url}")
|
|
owner, repo = segs[-2], segs[-1]
|
|
if repo.endswith(".git"):
|
|
repo = repo[:-4]
|
|
prefix = "/".join(segs[:-2])
|
|
origin = f"https://{parts.netloc}"
|
|
repo_url = origin + (f"/{prefix}" if prefix else "") + f"/{owner}/{repo}"
|
|
if host == "github.com" or host.endswith(".github.com"):
|
|
return "github", repo_url, owner, repo, GITHUB_API
|
|
if host == "gitlab.com" or host.endswith(".gitlab.com"):
|
|
return "gitlab", repo_url, owner, repo, f"{origin}/api/v4"
|
|
api = origin + (f"/{prefix}" if prefix else "") + "/api/v1"
|
|
return "gitea", repo_url, owner, repo, api
|
|
|
|
|
|
def _api_json(url: str, timeout=10):
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=timeout) as r:
|
|
return json.load(r)
|
|
|
|
|
|
def _describe_net_error(e: Exception) -> str:
|
|
"""Human-readable cause for a failed API request."""
|
|
import urllib.error
|
|
if isinstance(e, urllib.error.HTTPError):
|
|
if e.code == 403:
|
|
return "API rate limit exceeded — try again in 1 hour"
|
|
if e.code == 404:
|
|
return "repository or branch not found"
|
|
host = (e.url or "").split("/")[2] if (e.url or "").count("/") >= 2 \
|
|
else "server"
|
|
return f"HTTP {e.code} from {host}"
|
|
if isinstance(e, urllib.error.URLError):
|
|
return f"network error ({e.reason})"
|
|
return str(e)
|
|
|
|
|
|
def describe_install_error(e: Exception) -> str:
|
|
"""Map an install/update failure to a message the user can act on."""
|
|
import zipfile
|
|
import urllib.error
|
|
if isinstance(e, (urllib.error.HTTPError, urllib.error.URLError)):
|
|
return _describe_net_error(e)
|
|
if isinstance(e, OSError) and getattr(e, "errno", None) in (2, 13, 22):
|
|
# The archive/file vanished or got locked mid-operation — on Windows
|
|
# that's almost always the antivirus quarantining the download.
|
|
return ("Blocked by antivirus — open Settings (⚙) → "
|
|
"'Add game folder to Defender exclusions', then retry")
|
|
if isinstance(e, zipfile.BadZipFile):
|
|
return ("Downloaded archive is corrupted (possibly blocked by "
|
|
"antivirus) — retry, or use Settings (⚙) → "
|
|
"'Add game folder to Defender exclusions'")
|
|
return str(e)
|
|
|
|
|
|
def addon_remote_sha(git_url: str, branch=None, ref=None,
|
|
force=False, raise_errors=False) -> str | None:
|
|
"""Latest commit sha of a repo's branch (or pinned ref), cached in the
|
|
config file so repeated verifies don't burn API quota. Returns None on
|
|
failure — or raises with a readable cause when raise_errors is set."""
|
|
key = f"{git_url}#{ref or branch or ''}"
|
|
now = time.time()
|
|
if not force:
|
|
entry = load_config().get("addon_sha_cache", {}).get(key)
|
|
if entry and (now - entry.get("timestamp", 0)) < ADDON_SHA_CACHE_TTL:
|
|
return entry.get("sha")
|
|
|
|
kind, _repo_url, owner, repo, api = _git_parts(git_url)
|
|
pin = ref or branch # explicit branch/ref when the caller has one
|
|
sha = None
|
|
try:
|
|
if kind == "github":
|
|
if pin:
|
|
sha = _api_json(
|
|
f"{api}/repos/{owner}/{repo}/commits/{pin}").get("sha")
|
|
else:
|
|
lst = _api_json(
|
|
f"{api}/repos/{owner}/{repo}/commits?per_page=1")
|
|
sha = lst[0].get("sha") if lst else None
|
|
elif kind == "gitlab":
|
|
from urllib.parse import quote
|
|
proj = quote(f"{owner}/{repo}", safe="")
|
|
if pin:
|
|
sha = _api_json(
|
|
f"{api}/projects/{proj}/repository/commits/"
|
|
f"{quote(pin, safe='')}").get("id")
|
|
else:
|
|
lst = _api_json(
|
|
f"{api}/projects/{proj}/repository/commits?per_page=1")
|
|
sha = lst[0].get("id") if lst else None
|
|
else: # gitea / codeberg
|
|
q = f"?sha={pin}&limit=1" if pin else "?limit=1"
|
|
lst = _api_json(f"{api}/repos/{owner}/{repo}/commits{q}")
|
|
sha = lst[0].get("sha") if lst else None
|
|
except Exception as e:
|
|
if raise_errors:
|
|
raise RuntimeError(_describe_net_error(e)) from e
|
|
return None
|
|
if sha:
|
|
update_config(lambda c: c.setdefault("addon_sha_cache", {}).__setitem__(
|
|
key, {"timestamp": now, "sha": sha}))
|
|
return sha
|
|
|
|
|
|
def addon_cached_sha(git_url: str, branch=None, ref=None):
|
|
"""Cached remote sha regardless of age — never touches the network."""
|
|
key = f"{git_url}#{ref or branch or ''}"
|
|
entry = load_config().get("addon_sha_cache", {}).get(key)
|
|
return entry.get("sha") if entry else None
|
|
|
|
|
|
def addon_zip_url(git_url: str, sha: str) -> str:
|
|
kind, repo_url, _owner, repo, _api = _git_parts(git_url)
|
|
if kind == "gitlab":
|
|
return f"{repo_url}/-/archive/{sha}/{repo}-{sha}.zip"
|
|
return f"{repo_url}/archive/{sha}.zip"
|
|
|
|
|
|
def _rmtree_force(path):
|
|
"""Like shutil.rmtree, but also removes read-only files. Plain rmtree
|
|
raises PermissionError on Windows when it meets a read-only file (e.g. a
|
|
.git object store from a manual clone, or a read-only addon shipped in an
|
|
old zip); this clears the read-only bit and retries."""
|
|
def handler(func, p, _exc):
|
|
os.chmod(p, stat.S_IWRITE)
|
|
func(p)
|
|
if sys.version_info >= (3, 12):
|
|
shutil.rmtree(path, onexc=handler) # onerror deprecated in 3.12
|
|
else:
|
|
shutil.rmtree(path, onerror=handler)
|
|
|
|
|
|
def install_addon_files(client_dir: str, folder: str, git_url: str, sha: str):
|
|
"""Download the repo archive at `sha` and unpack it into
|
|
Interface/AddOns/<folder>, atomically replacing any existing copy."""
|
|
url = addon_zip_url(git_url, sha)
|
|
log(f" Downloading {folder} @ {sha[:10]}…")
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with secure_urlopen(req, timeout=120,
|
|
allowed_hosts=ADDON_ZIP_HOSTS) as r:
|
|
data = r.read()
|
|
|
|
import zipfile
|
|
import io
|
|
dest_root = os.path.join(addons_path(client_dir), folder)
|
|
tmp_root = dest_root + ".tmp_install"
|
|
tmp_abs = os.path.abspath(tmp_root)
|
|
if os.path.isdir(tmp_root):
|
|
_rmtree_force(tmp_root)
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
for info in zf.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
# Strip the archive's top-level "<repo>-<sha>/" directory and
|
|
# normalise separators (a zip entry may use "/" or "\").
|
|
parts = [p for p in info.filename.replace("\\", "/").split("/")[1:]
|
|
if p not in ("", ".")]
|
|
if not parts or ".." in parts:
|
|
continue
|
|
target = os.path.join(tmp_root, *parts)
|
|
# Defence in depth: never write outside the target folder even
|
|
# if the guards above are somehow bypassed.
|
|
if not os.path.abspath(target).startswith(tmp_abs + os.sep):
|
|
continue
|
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
with zf.open(info) as src, open(target, "wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
if os.path.isdir(dest_root):
|
|
_rmtree_force(dest_root)
|
|
os.replace(tmp_root, dest_root)
|
|
except BaseException:
|
|
# Never leave a half-written ".tmp_install" behind on failure
|
|
if os.path.isdir(tmp_root):
|
|
try:
|
|
_rmtree_force(tmp_root)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
log(f" Installed addon {folder}")
|
|
|
|
|
|
# ── pfUI "Default" profile patch ─────────────────────────────────────────────
|
|
# pfUI ships a set of built-in design profiles. After every pfUI install/update
|
|
# we add a curated "Default" profile and make it the firstrun default. Because
|
|
# an update overwrites pfUI's files, the patch is re-applied each time and is
|
|
# idempotent (marked blocks are replaced, not duplicated).
|
|
|
|
# The curated profile (JSON captured from a configured pfUI, profile renamed to
|
|
# "Default"). Loaded as a Python dict and emitted as a Lua table at patch time.
|
|
PFUI_DEFAULT_PROFILE = json.loads(r'''
|
|
{"appearance":{"border":{"default":"-1"},"castbar":{"castbarcolor":"1,0.796,0.251,0.8"},"cd":{"debuffs":"1","font":"Interface\\AddOns\\pfUI\\fonts\\Myriad-Pro.ttf","milliseconds":"0"},"infight":{"health":"0"},"minimap":{"arrowscale":"2"}},"buffs":{"hidelist":"","showoverflow":"1","showspillover":"1"},"castbar":{"focus":{"showicon":"1","showtimer":"0"},"player":{"hide_blizz":"0","hide_pfui":"1","showtimer":"0"},"target":{"showicon":"1","showtimer":"0"}},"character":{"inventory":{"durability":"0"},"reputation":{"repRequired":"0"}},"disabled":{"actionbar":"1","addonbuttons":"0","addoncompat":"0","addons":"0","afkcam":"0","autoshift":"0","autovendor":"0","bags":"1","bgscore":"0","bubbles":"1","buff":"1","buffwatch":"0","castbar":"0","chat":"1","chatcopy":"0","combopoints":"0","cooldown":"0","custom":"0","easteregg":"0","energytick":"0","eqcompare":"0","equipmentmanager":"0","farmmode":"0","feigndeath":"0","firstrun":"0","focus":"0","gm":"0","group":"0","gryphons":"0","hdgraphic":"0","hoverbind":"0","hunterbar":"0","infight":"0","innervatecall":"0","itemclick":"0","itemcount":"1","loot":"1","macrotweak":"0","map":"0","mapcolors":"0","mapreveal":"0","marktracking":"0","minimap":"0","mirrortimers":"0","mouseover":"0","nameplates":"0","nampower":"0","panel":"0","pet":"0","pettarget":"0","pixelperfect":"0","player":"0","questitem":"0","raid":"0","roll":"1","screenshot":"0","sellvalue":"0","share":"0","skin":"0","skin_Auctionhouse":"0","skin_Barbershop":"0","skin_Battlefield":"0","skin_Battlefield Minimap":"0","skin_Battlefield Score":"0","skin_Books":"0","skin_Character":"0","skin_Coin Pickup":"0","skin_Color Picker":"0","skin_Dress Up Frame":"0","skin_Everlook Broadcasting":"0","skin_Flightmaster":"1","skin_Friends":"1","skin_GM Survey":"0","skin_Game Menu":"0","skin_Gossip and Quest":"1","skin_Guild Registrar":"0","skin_Guild Tabard":"0","skin_Help":"0","skin_Inspect":"0","skin_KeyBindings":"0","skin_Macro":"0","skin_Mailbox":"1","skin_Merchant":"1","skin_Opacity":"0","skin_Options - Interface":"0","skin_Options - New":"0","skin_Options - Sound":"0","skin_Options - Video":"0","skin_Pet Stable Master":"0","skin_Petition":"0","skin_Popup Dialogs":"0","skin_Profession":"1","skin_Quest Log":"1","skin_Quest Timer":"0","skin_RaidUI":"0","skin_Readycheck":"0","skin_Spellbook":"1","skin_Stack Split":"0","skin_Talents":"1","skin_Tooltips":"0","skin_Trade":"1","skin_Trainer":"1","skin_Transmog":"0","skin_Turtle LFT":"0","skin_Turtle Shop":"0","skin_TurtleCalendar":"0","skin_TurtleMail":"0","skin_Tutorial":"0","socialmod":"0","superwow":"0","swingtimer":"0","target":"0","targettarget":"0","targettargettarget":"0","thirdparty":"0","thirdparty-vanilla":"0","tooltip":"0","totems":"0","tracking":"0","turtle-wow":"0","uf_tukui":"0","unitxp":"0","unlock":"0","unusable":"0","updatenotify":"0","whisperproxy":"0","xpbar":"1"},"global":{"autosell":"1","font_blizzard":"1","font_unit":"Interface\\AddOns\\pfUI\\fonts\\Myriad-Pro.ttf","font_unit_name":"Interface\\AddOns\\pfUI\\fonts\\Continuum.ttf","profile":"Default"},"gui":{"showdisabled":"0"},"nameplates":{"barcombatstate":"0","ccombatnothreat":"0","ccombatofftank":"0","ccombatstun":"0","ccombatthreat":"0","debuffanim":"1","debuffs":{"blacklist":"#Mana Attuned#Mercenary","filter":"blacklist","showstacks":"1"},"debuffsize":"20","guessdebuffs":"1","outcombatstate":"0","overlap":"1","spellname":"1"},"panel":{"left":{"center":"none","left":"none","right":"none"},"other":{"minimap":"time"},"right":{"center":"none","left":"none","right":"none"},"seconds":"0"},"position":{"TicketStatusFrame":{"anchor":"CENTER","parent":"UIParent","xpos":1100,"ypos":300},"WorldMapFrame":{"alpha":1,"anchor":"TOPLEFT","scale":0.69999998807907104,"xpos":1434,"ypos":-150},"pfFocus":{"anchor":"CENTER","parent":"UIParent","xpos":-200,"ypos":220},"pfFocusCastbar":{"anchor":"CENTER","parent":"UIParent","xpos":-200,"ypos":162},"pfGroup1":{"anchor":"TOP","parent":"UIParent","scale":1,"xpos":-468,"ypos":-135},"pfGroup2":{"anchor":"TOP","parent":"UIParent","xpos":-468,"ypos":-210},"pfGroup3":{"anchor":"TOP","parent":"UIParent","xpos":-468,"ypos":-285},"pfGroup4":{"anchor":"TOP","parent":"UIParent","xpos":-468,"ypos":-360},"pfMarkTracking":{"anchor":"CENTER","parent":"UIParent","xpos":270,"ypos":270},"pfPartyPet1":{"scale":0.90000000000000002},"pfPartyPet2":{"scale":0.90000000000000002},"pfPartyPet3":{"scale":0.90000000000000002},"pfPartyPet4":{"scale":0.90000000000000002},"pfPet":{"anchor":"CENTER","parent":"UIParent","scale":1,"xpos":-490,"ypos":338},"pfPlayer":{"anchor":"CENTER","parent":"UIParent","xpos":-445,"ypos":380},"pfRaidCluster":{"anchor":"CENTER","parent":"UIParent","xpos":-1420,"ypos":-120},"pfSwingTimerMainhand":{"anchor":"CENTER","parent":"UIParent","xpos":0,"ypos":-252},"pfSwingTimerRanged":{"anchor":"CENTER","parent":"UIParent","xpos":0,"ypos":-228},"pfTarget":{"anchor":"CENTER","parent":"UIParent","xpos":-215,"ypos":380},"pfTargetCastbar":{"anchor":"CENTER","parent":"UIParent","xpos":-215,"ypos":290},"pfTargetTarget":{"anchor":"CENTER","parent":"UIParent","scale":0.90000000000000002,"xpos":-116,"ypos":385},"pfTargetTargetTarget":{"anchor":"CENTER","parent":"UIParent","scale":0.90000000000000002,"xpos":-8,"ypos":385},"pfTooltipAnchor":{"anchor":"CENTER","parent":"UIParent","xpos":720,"ypos":-386},"pfTotems":{"anchor":"CENTER","parent":"UIParent","xpos":-482,"ypos":334}},"thirdparty":{"statcompare":{"enable":"1"},"wim":{"enable":"0"}},"tooltip":{"position":"free","vendor":{"showalways":"0"}},"unitframes":{"abbrevnum":"0","always2dportrait":"1","animation_speed":"1","castbardecimals":"1","custombgcolor":"0.502,0.2,0.2,1","customcolor":"0.055,0.749,0,1","druidmanaheight":"2","druidmanatext":"0","fallback":{"debuff_ind_range":"0"},"focus":{"buffs":"BOTTOMLEFT","buffsize":"18","cooldown_anim":"1","debuff_ind_range":"0","debuffs":"BOTTOMLEFT","debuffsize":"20","hitindicatorfont":"Interface\\AddOns\\pfUI\\fonts\\Myriad-Pro.ttf","hitindicatorsize":"16","portrait":"right","powercolor":"0","txthpright":"health"},"focustarget":{"debuff_ind_range":"0"},"group":{"buffs":"off","debuff_ind_range":"0","height":"22","txthpright":"none","width":"144"},"grouppet":{"debuff_ind_range":"0"},"grouptarget":{"debuff_ind_range":"0","visible":"0"},"nampower_buffs":"1","pet":{"debuff_ind_range":"0"},"player":{"buffs":"off","debuff_ind_range":"0","debuffs":"off","display_spellpower":"0","height":"26","hitindicator":"1","hitindicatorfont":"Interface\\AddOns\\pfUI\\fonts\\Myriad-Pro.ttf","hitindicatorsize":"16","portrait":"left","powercolor":"0","txthpright":"health","txtpowerright":"power","width":"150"},"ptarget":{"debuff_ind_range":"0"},"raid":{"debuff_ind_range":"0"},"rangecheck_mode":"unitxp","rangechecki":"4","spellqueue":"0","swingtimerattackspeed":"1","swingtimerfontsize":"14","swingtimerlabel":"0","swingtimermhcolor":"0.31,0.596,1,1","swingtimerrangedcolor":"0.686,0.447,1,1","swingtimerwidth":"192","target":{"buffperrow":"7","buffs":"BOTTOMLEFT","buffsize":"18","cooldown_anim":"1","debuff_ind_range":"0","debuffperrow":"6","debuffs":"BOTTOMLEFT","debuffsize":"22","height":"26","hitindicator":"1","hitindicatorfont":"Interface\\AddOns\\pfUI\\fonts\\Myriad-Pro.ttf","hitindicatorsize":"16","portrait":"right","powercolor":"0","showPVP":"1","txthpright":"health","txtpowerright":"power","width":"150"},"track_group":"1","ttarget":{"debuff_ind_range":"0","portrait":"off","powercolor":"0"},"tttarget":{"debuff_ind_range":"0","portrait":"off"}},"version":"999.999.999"}
|
|
''')
|
|
|
|
|
|
def _lua_value(v, indent: int = 0) -> str:
|
|
"""Serialize a JSON-derived value to a pfUI-style Lua literal."""
|
|
if isinstance(v, dict):
|
|
pad, cpad = " " * (indent + 2), " " * indent
|
|
items = "".join(f'{pad}["{k}"] = {_lua_value(val, indent + 2)},\n'
|
|
for k, val in v.items())
|
|
return "{\n" + items + cpad + "}"
|
|
if isinstance(v, bool):
|
|
return "true" if v else "false"
|
|
if isinstance(v, (int, float)):
|
|
return repr(v)
|
|
s = str(v).replace("\\", "\\\\").replace('"', '\\"')
|
|
return f'"{s}"'
|
|
|
|
|
|
_PFUI_MARK_BEGIN = "-- OCTO_UPDATER_DEFAULT_PROFILE_BEGIN"
|
|
_PFUI_MARK_END = "-- OCTO_UPDATER_DEFAULT_PROFILE_END"
|
|
_PFUI_CHAT_BEGIN = "-- OCTO_UPDATER_CHAT_SKIP_BEGIN"
|
|
_PFUI_CHAT_END = "-- OCTO_UPDATER_CHAT_SKIP_END"
|
|
|
|
# Strips any Octo Updater injected block, regardless of which marker pair.
|
|
_PFUI_STRIP_RE = r"[ \t]*-- OCTO_UPDATER_[A-Z_]+?_BEGIN.*?-- OCTO_UPDATER_[A-Z_]+?_END\n?"
|
|
|
|
|
|
def patch_pfui_default_profile(client_dir: str):
|
|
"""Add the curated 'Default' profile to a freshly installed/updated pfUI
|
|
and make it the firstrun default. Idempotent; degrades gracefully if
|
|
pfUI's file layout has changed."""
|
|
import re
|
|
|
|
base = os.path.join(addons_path(client_dir), "pfUI")
|
|
profiles_lua = os.path.join(base, "env", "profiles.lua")
|
|
firstrun_lua = os.path.join(base, "modules", "firstrun.lua")
|
|
if not os.path.exists(profiles_lua):
|
|
return
|
|
|
|
# 1) profiles.lua — append (or replace) a marked block defining Default.
|
|
block = (f"{_PFUI_MARK_BEGIN}\n"
|
|
f"local octo_default = {_lua_value(PFUI_DEFAULT_PROFILE)}\n"
|
|
f'pfUI_profiles["Default"] = octo_default\n'
|
|
f"{_PFUI_MARK_END}\n")
|
|
try:
|
|
with open(profiles_lua, encoding="utf-8", errors="replace") as f:
|
|
txt = f.read()
|
|
txt = re.sub(re.escape(_PFUI_MARK_BEGIN) + r".*?"
|
|
+ re.escape(_PFUI_MARK_END) + r"\n?", "", txt, flags=re.S)
|
|
with open(profiles_lua, "w", encoding="utf-8") as f:
|
|
f.write(txt.rstrip() + "\n\n" + block)
|
|
log(" pfUI: 'Default' profile installed.")
|
|
except OSError as e:
|
|
log(f" pfUI: could not patch profiles.lua ({e})")
|
|
return
|
|
|
|
# 2) pfUI.lua — use 'Default' (not 'Modern') as the fresh-install config,
|
|
# so the very first login already lands in the Default profile.
|
|
pfui_lua = os.path.join(base, "pfUI.lua")
|
|
if os.path.exists(pfui_lua):
|
|
try:
|
|
with open(pfui_lua, encoding="utf-8", errors="replace") as f:
|
|
pf = f.read()
|
|
old = 'CopyTable(pfUI_profiles["Modern"]) or {}'
|
|
if old in pf:
|
|
pf = pf.replace(
|
|
old, 'CopyTable(pfUI_profiles["Default"]) or {}', 1)
|
|
with open(pfui_lua, "w", encoding="utf-8") as f:
|
|
f.write(pf)
|
|
log(" pfUI: 'Default' set as the fresh-install profile.")
|
|
except OSError as e:
|
|
log(f" pfUI: could not patch pfUI.lua ({e})")
|
|
|
|
# 3) firstrun.lua — add a 'Default' button, make it the fallback profile,
|
|
# and skip the chat wizard steps whenever the chat module is disabled.
|
|
if not os.path.exists(firstrun_lua):
|
|
return
|
|
try:
|
|
with open(firstrun_lua, encoding="utf-8", errors="replace") as f:
|
|
fr = f.read()
|
|
|
|
# Remove any previous injections (idempotent re-apply after updates).
|
|
fr = re.sub(_PFUI_STRIP_RE, "", fr, flags=re.S)
|
|
|
|
# When the chat module is disabled (e.g. the "Default" profile), the
|
|
# chat firstrun steps can't apply anything, so pre-mark them done to
|
|
# keep them from showing. Injected right after the step table is made.
|
|
chat_skip = (
|
|
f" {_PFUI_CHAT_BEGIN}\n"
|
|
" if pfUI_config and pfUI_config.disabled"
|
|
' and pfUI_config.disabled.chat == "1" then\n'
|
|
" pfUI_init = pfUI_init or {}\n"
|
|
' pfUI_init["chat_right"] = true\n'
|
|
' pfUI_init["chat_position"] = true\n'
|
|
' pfUI_init["chat_channels"] = true\n'
|
|
" end\n"
|
|
f" {_PFUI_CHAT_END}\n")
|
|
chat_anchor = " pfUI.firstrun.steps = {}\n"
|
|
if chat_anchor in fr:
|
|
fr = fr.replace(chat_anchor, chat_anchor + chat_skip, 1)
|
|
|
|
# Insert a Default button just before the built-in "Modern" button.
|
|
button = (
|
|
f" {_PFUI_MARK_BEGIN}\n"
|
|
' f.Default = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")\n'
|
|
" f.Default:SetWidth(250)\n"
|
|
" f.Default:SetHeight(20)\n"
|
|
' f.Default:SetPoint("BOTTOM", 0, 120)\n'
|
|
" f.Default:SetTextColor(1,1,1)\n"
|
|
' f.Default:SetText("Default (recommended)")\n'
|
|
" f.Default:SetScript(\"OnClick\", function()\n"
|
|
' _G["pfUI_config"] = CopyTable(pfUI_profiles["Default"])\n'
|
|
' pfUI_init.selected_profile = "Default"\n'
|
|
" pfUI:LoadConfig()\n"
|
|
" ReloadUI()\n"
|
|
" end)\n"
|
|
" SkinButton(f.Default)\n"
|
|
f" {_PFUI_MARK_END}\n\n")
|
|
anchor = ' f.Modern = CreateFrame("Button"'
|
|
if anchor in fr:
|
|
fr = fr.replace(anchor, button + anchor, 1)
|
|
|
|
# Make Default the profile used when the user doesn't pick one.
|
|
fr = fr.replace('pfUI_init.selected_profile or "Modern"',
|
|
'pfUI_init.selected_profile or "Default"')
|
|
|
|
with open(firstrun_lua, "w", encoding="utf-8") as f:
|
|
f.write(fr)
|
|
log(" pfUI: 'Default' added to the firstrun profile picker.")
|
|
except OSError as e:
|
|
log(f" pfUI: could not patch firstrun.lua ({e})")
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Tweaks definition
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|