"""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 from .platform_ops import 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//.""" 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/, 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 "-/" 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 # ──────────────────────────────────────────────────────────────────────────────