Files
2026-09-19 12:34:33 -04:00

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
# ──────────────────────────────────────────────────────────────────────────────