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

3874 lines
176 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 tkinter as tk
from tkinter import filedialog
import urllib.error
from app_log import LOG_QUEUE as _LOG_Q, log
from config import *
from net import *
from client import *
from torrent import *
from mods import *
from platform_ops import open_directory, launch_executable, request_defender_exclusion
# News tab: the latest announcement (forum 2, full post) fills the left panel,
# the patch-notes list (forum 4) fills the right.
NEWS_FEATURED_URL = f"{SERVER}/forum/octonews.php?forum=2&mode=full"
PATCHNOTES_URL = f"{SERVER}/forum/octonews.php?mode=list&forum=4&limit=8"
NEWS_TIMEOUT = 8
NEWS_CACHE_TTL = 300
# Design dimensions, authored at 96 DPI (100% scaling). At startup the app
# multiplies these — and every other hard-coded pixel value, via self._px() —
# by a single DPI scale factor, so the whole fixed-pixel layout scales as a
# unit instead of point-fonts overflowing fixed geometry on high-DPI displays.
WIN_W, WIN_H = 1000, 700
FOOT_H = 130
C_BG = "#120e1a"
C_PANEL = "#161120"
C_PANEL_BDR = "#261d3a"
C_HDR = "#0d0a14"
C_DIVIDER = "#2a2142"
C_GOLD = "#c8922a"
C_GOLD_LT = "#e8b84b"
C_PURPLE = "#8a4fa5"
C_GREEN_BTN = "#4a7c2f"
C_GREEN_HOV = "#5a9438"
C_TEXT = "#d8d4cc"
C_TEXT_DIM = "#7a7670"
C_LOG_BG = "#0f0b16"
C_OK = "#6abf69"
C_ERR = "#bf6969"
C_MOD_HL = "#a8b83c" # olive-green highlight for installed mods
# Parchment palette for the featured news post
C_PARCH = "#e9dcb8"
C_PARCH_BAND = "#ddcda0"
C_PARCH_LINE = "#c3b083"
C_PARCH_TITLE = "#7c5a12"
C_PARCH_TEXT = "#3a352a"
C_PARCH_DIM = "#8b8064"
C_PARCH_LINK = "#a3561c"
C_PARCH_EDGE = "#b7a678"
FONT_BODY = ("Segoe UI", 9)
NEWS_FEATURED_URL = f"{SERVER}/forum/octonews.php?forum=2&mode=full"
PATCHNOTES_URL = f"{SERVER}/forum/octonews.php?mode=list&forum=4&limit=8"
NEWS_TIMEOUT = 8
NEWS_CACHE_TTL = 300
def _strip_html(raw: str) -> str:
"""Reduce forum HTML to readable plain text for a Tk widget."""
import re
import html as html_mod
txt = re.sub(r"(?is)<(script|style)[^>]*>.*?</\1>", "", raw)
txt = re.sub(r"(?i)<br\s*/?>", "\n", txt)
txt = re.sub(r"(?i)<li[^>]*>", "\n• ", txt)
txt = re.sub(r"(?i)</(p|div|li|ul|ol|h[1-6]|tr|blockquote)>", "\n", txt)
txt = re.sub(r"<[^>]+>", "", txt)
txt = html_mod.unescape(txt)
txt = re.sub(r"[ \t]+", " ", txt)
txt = re.sub(r" ?\n ?", "\n", txt)
txt = re.sub(r"\n{3,}", "\n\n", txt)
return txt.strip()
def _format_news_date(iso: str) -> str:
from datetime import datetime
try:
return datetime.fromisoformat(iso).strftime("%d %b %Y")
except Exception:
return iso
def fetch_patch_notes() -> list:
"""Patch-notes list → [{id, title, date, body, url?, author?}, …]"""
req = urllib.request.Request(PATCHNOTES_URL, headers={"User-Agent": UA})
with secure_urlopen(req, timeout=NEWS_TIMEOUT) as r:
data = json.load(r)
items = data.get("items", [])
# The feed lists topics in forum order — show newest first (ISO dates
# with a fixed offset sort correctly as strings).
items.sort(key=lambda it: it.get("date", ""), reverse=True)
return items
def fetch_featured_post() -> dict | None:
"""Latest announcements-forum post → {id, title, author?, date, url, html}"""
req = urllib.request.Request(NEWS_FEATURED_URL, headers={"User-Agent": UA})
with secure_urlopen(req, timeout=NEWS_TIMEOUT) as r:
data = json.load(r)
return data if isinstance(data, dict) and data.get("id") else None
# ──────────────────────────────────────────────────────────────────────────────
# GUI
# ──────────────────────────────────────────────────────────────────────────────
class SlimScrollbar(tk.Canvas):
"""Flat minimal scrollbar (the native tk.Scrollbar can't be themed on
Windows). Speaks the standard set()/command scrollbar protocol."""
def __init__(self, parent, command=None, width=10,
bg=C_PANEL, thumb="#3a2f55", thumb_hover=C_GOLD, **kw):
super().__init__(parent, width=width, bg=bg,
highlightthickness=0, bd=0, **kw)
self.command = command
self._thumb = thumb
self._thumb_hover = thumb_hover
self._first = 0.0
self._last = 1.0
self._drag_off = None
self._hover = False
self.bind("<Configure>", lambda e: self._redraw())
self.bind("<Button-1>", self._click)
self.bind("<B1-Motion>", self._drag)
self.bind("<Enter>", lambda e: self._set_hover(True))
self.bind("<Leave>", lambda e: self._set_hover(False))
def set(self, first, last):
self._first, self._last = float(first), float(last)
self._redraw()
def _set_hover(self, on):
self._hover = on
self._redraw()
def _redraw(self):
self.delete("all")
if self._last - self._first >= 1.0:
return
h = self.winfo_height()
w = self.winfo_width()
y0 = int(self._first * h)
y1 = max(int(self._last * h), y0 + 24)
self.create_rectangle(
2, y0, w - 2, y1,
fill=self._thumb_hover if self._hover else self._thumb,
outline="")
def _click(self, e):
h = self.winfo_height() or 1
y0 = self._first * h
y1 = self._last * h
self._drag_off = (e.y - y0) if y0 <= e.y <= y1 else (y1 - y0) / 2
self._drag(e)
def _drag(self, e):
h = self.winfo_height() or 1
span = self._last - self._first
first = max(0.0, min((e.y - (self._drag_off or 0)) / h, 1.0 - span))
if self.command:
self.command("moveto", first)
class OctoUpdaterApp(tk.Tk):
def __init__(self):
super().__init__()
# ── DPI scaling ────────────────────────────────────────────────────
# With awareness declared (see _enable_dpi_awareness) Windows reports
# the real DPI. Derive one scale factor from it and drive everything
# off it: fonts via Tk's own point→pixel scaling, and every hard-coded
# pixel value via self._px(). At 100% the factor is 1.0 and _px() is an
# exact identity, so nothing changes; only high-DPI diverges.
self._sc = max(1.0, self.winfo_fpixels("1i") / 96.0)
# tk scaling is pixels-per-point; DPI/72 == _sc * 96/72. Setting it
# explicitly keeps point-fonts growing by exactly _sc regardless of
# what Tk defaulted to.
self.tk.call("tk", "scaling", self._sc * 96.0 / 72.0)
# Bake the factor into the shared design constants once, up front, so
# the ~40 WIN_W/WIN_H/FOOT_H references downstream need no changes.
global WIN_W, WIN_H, FOOT_H
WIN_W = self._px(WIN_W)
WIN_H = self._px(WIN_H)
FOOT_H = self._px(FOOT_H)
# Keep the window hidden until it's positioned and fully built, so it
# never flashes at the default top-left corner before centering.
self.withdraw()
# Move a pre-1.3 config/cache from beside the .exe into the per-user
# data dir before anything reads them, so first-run detection and
# load_config() below see the relocated files (see _relocate_legacy_data).
_relocate_legacy_data()
# Detect first run before anything writes the config.
self._first_run = not os.path.exists(CONFIG_FILE)
# Set when the user adds a Defender exclusion via Settings; checked at
# the next reconcile to skip the auto-prompt (so a manual add isn't
# double-prompted), then reset — so each folder change offers one unless
# the user just added it themselves.
self._av_excluded = False
self._cfg = load_config()
# Pending reconcile mode (None = none): _offer_reconcile surfaces the
# Update button and the Update the user clicks runs an integrity pass
# instead of a routine size-based sync. "full" also writes a fresh
# Config.wtf (folder change / first run); "keep-config" leaves Config.wtf.
self._pending_reconcile = self._cfg.get("pending_reconcile") or None
self._running = False
# True only after a verify/update confirmed the client files are up
# to date. PLAY is gated on this AND on no mod being in an error state.
self._client_ready = False
self._worker: UpdateWorker | None = None
self._log_q: queue.Queue = queue.Queue()
self._prog_q: queue.Queue = queue.Queue()
# Session log lives in memory; the "Show logs" window renders it.
self._log_buffer: list = []
self._logwin = None
self._logwin_text = None
self._settings_overlay = None
# Guards against triggering the default-mods / recommended-addons
# auto-install more than once per app session (e.g. verify firing
# twice in quick succession).
self._default_mods_install_started = False
self._default_addons_install_started = False
# Game folder path — shared by the Settings modal. A change takes
# effect only once Settings is closed (see _close_settings), so no
# live trace fires mid-edit.
self._game_path = tk.StringVar(
value=os.path.normpath(self._cfg.get("out_dir", DEFAULT_GAME_DIR)))
# Count of mods with an update available — shown as a badge on the
# MODS nav tab.
self._mod_updates_count = 0
# Addons state
self._addons_status = {"state": "idle", "addons": {}, "available": []}
self._addons_busy = False
# True only while addons are actually downloading/installing (not
# during a verify) — gates the PLAY button, like mods installs do.
self._addons_installing = False
self._addons_verified_ts = 0.0
self._addon_updates_count = 0
self._mpq_updates_count = 0
self._mpq_busy = None
# {folder: {"error": msg, "git": url}} for failed installs/updates —
# survives the post-operation rescan so rows can show what went wrong.
self._addon_errors = {}
self._addon_sections_open = {"INSTALLED": True, "AVAILABLE": True}
# News feed cache (featured post and announcements cached separately)
self._feat_ts = 0.0
self._patch_ts = 0.0
self._patch_items = None
self._featured = None
# Scrollable list canvases that respond to the mouse wheel whenever
# the pointer is anywhere over them (not just over the scrollbar).
self._wheel_canvases: list = []
self.bind_all("<MouseWheel>", self._on_mousewheel)
self.title("Octo Updater")
self.resizable(False, False)
self.configure(bg=C_BG)
# Center on screen up front. WIN_W/WIN_H are fixed, so we can position
# the window in a single geometry call (no need to map-then-measure).
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
x = (sw - WIN_W) // 2
y = (sh - WIN_H) // 2
self.geometry(f"{WIN_W}x{WIN_H}+{x}+{y}")
self._build()
out_dir = self._cfg.get("out_dir", DEFAULT_GAME_DIR)
if not os.path.exists(out_dir):
def _wipe(c):
c.pop("mods", None)
c.pop("addons", None)
self._cfg = update_config(_wipe)
live_ver = get_client_version(out_dir)
if live_ver:
self._client_ver_var.set(live_ver)
self.protocol("WM_DELETE_WINDOW", self._on_close)
self._poll()
# Run any one-time migrations for a freshly-installed updater version
# (and stamp octo_updater_version) before verifying. It runs
# synchronously, so a v1.3 re-patch is settled before verify reads
# WoW.exe, and the normal verify below still runs — an upgrading user
# never misses a pending client update.
self._new_version_installed()
# On first run, defer verification until Settings is closed (see
# _close_settings). If a reconcile was offered on a previous run but
# never applied, restore the Update button instead of a routine verify.
# Otherwise run the routine launch verify.
if self._first_run:
pass
elif self._pending_reconcile:
self.after(300, self._offer_reconcile)
else:
self.after(300, self._start_verify)
self.after(600, self._load_news)
# Check mod updates at launch too — but only once mods have actually
# been used (mod_release_cache exists). On a first run or right after a
# game-folder change nothing is installed yet, so there's nothing to
# check; the MODS tab will do it when opened.
if self._cfg.get("mod_release_cache"):
self.after(900, lambda: threading.Thread(
target=self._load_mods_state, daemon=True).start())
# Same parity for addons: background verify at launch (feeds the
# ADDONS tab badge), but only once addons were initialized for this
# folder — never on a first run / fresh folder.
if self._cfg.get("addons") is not None:
self.after(1500, self._addons_verify)
# MPQ patches: check installed known patches for updates at launch so
# the tab badge shows without opening the tab (no-op if none installed).
if not self._first_run and self._game_path.get().strip():
self.after(1700, self._mpq_check)
# Daily self-update check (cached), last so it never delays the rest.
self.after(2000, lambda: threading.Thread(
target=self._check_updater_update, daemon=True).start())
# First launch: open Settings so the user sets the game folder etc.
if self._first_run:
self.after(500, self._open_settings)
# Everything is positioned and built — reveal the centered window.
self.deiconify()
def _px(self, *vals):
"""Scale design pixels (authored at 96 DPI) to the current DPI. Returns
an int for one arg, a tuple for several — handy for canvas coordinate
lists. Exact identity when the scale factor is 1.0 (100% scaling)."""
if len(vals) == 1:
return round(vals[0] * self._sc)
return tuple(round(v * self._sc) for v in vals)
# ── build ─────────────────────────────────────────────────────────────────
def _on_close(self):
"""Hide the window first so the close feels instant
Config/caches are already saved at write time,
and the worker threads are daemons, so nothing blocks the exit."""
try:
self.withdraw()
except Exception:
pass
# A download's aria2c child isn't a daemon thread — kill it explicitly
# so it can't keep syncing headless after the window is gone.
stop_aria2c()
self.quit()
def _add_tooltip(self, widget, text: str):
"""Attach a small hover tooltip to a widget."""
state = {"win": None}
def show(_e=None):
if state["win"] is not None:
return
x = widget.winfo_rootx() + self._px(12)
y = widget.winfo_rooty() + widget.winfo_height() + self._px(4)
tw = tk.Toplevel(self)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tk.Label(tw, text=text, font=("Segoe UI", 9),
fg=C_TEXT, bg="#0f0b16",
highlightthickness=1, highlightbackground=C_PANEL_BDR,
padx=self._px(6), pady=self._px(2)).pack()
state["win"] = tw
def hide(_e=None):
if state["win"] is not None:
state["win"].destroy()
state["win"] = None
widget.bind("<Enter>", show, add="+")
widget.bind("<Leave>", hide, add="+")
def _on_mousewheel(self, event):
# Panels are stacked, so several list canvases share the same screen
# region — only the one inside the active tab's panel should scroll.
active = getattr(self, "_active_panel", None)
active_prefix = (str(active) + ".") if active is not None else None
for cv in list(self._wheel_canvases):
try:
if not cv.winfo_ismapped():
continue
if active_prefix and not str(cv).startswith(active_prefix):
continue
wx, wy = cv.winfo_rootx(), cv.winfo_rooty()
if (wx <= event.x_root <= wx + cv.winfo_width()
and wy <= event.y_root <= wy + cv.winfo_height()):
# When the content fits entirely in view, Tk would still
# happily shift it around — ignore the wheel instead.
first, last = cv.yview()
if last - first < 1.0:
cv.yview_scroll(int(-event.delta / 120), "units")
return
except tk.TclError:
self._wheel_canvases.remove(cv)
def _draw_nav_tab(self, tab: str, hover: bool = False):
"""Render a nav tab on the header canvas; the active tab's text gets
a soft glow (dim gold halo layers behind the bright text)."""
cv = self._hdr_canvas
tag = f"nav_{tab}"
cv.delete(tag)
cx, cy = self._nav_pos[tab], self._px(54)
font = ("Segoe UI", 11, "bold")
if tab == self._active_tab:
for r, col in ((self._px(2), "#42340f"), (self._px(1), "#7a5c1d")):
for dx, dy in ((-r, 0), (r, 0), (0, -r), (0, r),
(-r, -r), (r, -r), (-r, r), (r, r)):
cv.create_text(cx + dx, cy + dy, text=tab,
font=font, fill=col, tags=tag)
cv.create_text(cx, cy, text=tab, font=font, fill=C_GOLD_LT,
tags=tag)
else:
cv.create_text(cx, cy, text=tab, font=font,
fill=C_GOLD_LT if hover else C_TEXT, tags=tag)
# Update-counter badges
count = 0
if tab == "MODS":
count = self._mod_updates_count
elif tab == "ADDONS":
count = self._addon_updates_count
elif tab == "MPQ":
count = self._mpq_updates_count
if count:
bx = cx + self._nav_text_w[tab] // 2 + self._px(11)
by = cy - self._px(11)
# Canvas oval, not a ● glyph: the oval gives exact geometric
# control so the number always centers, consistently across OSes.
# (A filled-circle glyph antialiases nicely but its disk isn't
# centered in the glyph box — by a font-specific amount — so the
# number drifts off-centre and can't be corrected reliably.)
r8 = self._px(8)
cv.create_oval(bx - r8, by - r8, bx + r8, by + r8,
fill=C_GOLD, outline="", tags=tag)
cv.create_text(bx, by, text=str(count),
font=("Segoe UI", 8, "bold"),
fill="#1a1408", tags=tag)
def _switch_tab(self, tab: str):
if tab == self._active_tab:
return
prev = self._active_tab
self._active_tab = tab
self._draw_nav_tab(prev)
self._draw_nav_tab(tab)
PANEL_TOP = self._px(119)
PANEL_H = WIN_H - PANEL_TOP - FOOT_H - self._px(10)
# Panels stay mapped and stacked; switching tabs only raises the
# active one. place_forget()/place() would unmap and remap the whole
# widget tree of a populated panel (hundreds of widgets) every
# switch — a visible synchronous stall.
panels = {"NEWS": self._news_panel,
"TWEAKS": self._tweaks_panel_frame,
"ADDONS": self._addons_panel_frame,
"MODS": self._mods_panel_frame,
"MPQ": self._mpq_panel_frame}
target = panels.get(tab, self._news_panel)
if not target.winfo_ismapped():
target.place(x=self._px(40), y=PANEL_TOP,
width=WIN_W - self._px(80), height=PANEL_H)
target.tkraise()
self._active_panel = target
if tab == "MODS":
threading.Thread(target=self._load_mods_state, daemon=True).start()
elif tab == "TWEAKS":
self._refresh_tweaks_panel()
elif tab == "ADDONS":
self._addons_verify()
elif tab == "MPQ":
self._mpq_check()
else:
self._load_news()
def _build(self):
self._bg_canvas = tk.Canvas(self, width=WIN_W, height=WIN_H,
bg=C_BG, highlightthickness=0)
self._bg_canvas.place(x=0, y=0)
self._draw_bg()
self._build_header()
self._build_panel()
self._build_footer()
def _draw_bg(self):
c = self._bg_canvas
bloom_cx, bloom_cy = WIN_W - self._px(80), self._px(80)
for i in range(40, 0, -1):
r = self._px(i * 9)
alpha_frac = (40 - i) / 40
r_val = int(0x12 + alpha_frac * (0x2e - 0x12))
g_val = int(0x0e + alpha_frac * (0x18 - 0x0e))
b_val = int(0x1a + alpha_frac * (0x50 - 0x1a))
col = f"#{r_val:02x}{g_val:02x}{b_val:02x}"
c.create_oval(bloom_cx - r, bloom_cy - r,
bloom_cx + r, bloom_cy + r,
fill=col, outline="")
c.create_line(0, WIN_H - self._px(1), WIN_W, WIN_H - self._px(1),
fill=C_PANEL_BDR)
def _build_header(self):
HDR_H = self._px(108)
hdr = tk.Canvas(self, width=WIN_W, height=HDR_H,
bg=C_BG, highlightthickness=0)
hdr.place(x=0, y=0, width=WIN_W, height=HDR_H)
self._hdr_canvas = hdr
# Same corner bloom as the main background (identical coordinates
# and colors) so the header blends seamlessly with the body instead
# of sitting as a darker separated band.
bloom_cx, bloom_cy = WIN_W - self._px(80), self._px(80)
for i in range(40, 0, -1):
r = self._px(i * 9)
alpha_frac = (40 - i) / 40
r_val = int(0x12 + alpha_frac * (0x2e - 0x12))
g_val = int(0x0e + alpha_frac * (0x18 - 0x0e))
b_val = int(0x1a + alpha_frac * (0x50 - 0x1a))
col = f"#{r_val:02x}{g_val:02x}{b_val:02x}"
hdr.create_oval(bloom_cx - r, bloom_cy - r,
bloom_cx + r, bloom_cy + r,
fill=col, outline="")
import tkinter.font as tkfont
self._logo_y = HDR_H // 2 - self._px(6)
self._draw_logo()
# Point-sized font: Tk scales it by tk-scaling, so measure() below
# already returns device pixels at the current DPI.
nav_font = tkfont.Font(family="Segoe UI", size=11, weight="bold")
tabs = ["NEWS", "TWEAKS", "ADDONS", "MODS", "MPQ"]
self._active_tab = "NEWS"
self._nav_pos = {}
self._nav_text_w = {}
self._hdr_regions = {}
x = self._px(240)
for tab in tabs:
w = nav_font.measure(tab) + self._px(36)
self._nav_pos[tab] = x + w // 2
self._nav_text_w[tab] = nav_font.measure(tab)
self._hdr_regions[tab] = (x, 0, x + w, HDR_H)
x += w
self._draw_nav_tab(tab)
self._hdr_regions["gear"] = (WIN_W - self._px(36), self._px(2),
WIN_W - self._px(2), self._px(34))
self._draw_gear()
# The wordmark is a clickable header element too (opens the repo).
lb = hdr.bbox("logo")
if lb:
self._hdr_regions["logo"] = (lb[0] - self._px(4), lb[1] - self._px(4),
lb[2] + self._px(6), lb[3] + self._px(4))
self._logo_cx = (lb[0] + lb[2]) // 2
else:
self._logo_cx = self._px(127)
# "Update available!" label under the wordmark, shown once the daily
# self-update check finds a newer release.
self._update_available = False
self._draw_update_label()
self._hdr_hover = None
hdr.bind("<Button-1>", self._on_hdr_click)
hdr.bind("<Motion>", self._on_hdr_motion)
hdr.bind("<Leave>", lambda e: self._on_hdr_motion(None))
self._clear_wdb_var = tk.BooleanVar(
value=bool(self._cfg.get("clear_wdb_on_launch", False)))
self._close_on_launch_var = tk.BooleanVar(
value=bool(self._cfg.get("close_on_launch", False)))
self._auto_mods_var = tk.BooleanVar(
value=bool(self._cfg.get("auto_install_mods", True)))
self._auto_addons_var = tk.BooleanVar(
value=bool(self._cfg.get("auto_install_addons", True)))
self._ignore_speech_var = tk.BooleanVar(
value=bool(self._cfg.get("ignore_speech", False)))
# Deferred "install missing" pending from turning an auto-install
# option on in Settings — applied on close (see _close_settings).
self._auto_mods_retrigger = False
self._auto_addons_retrigger = False
def _draw_logo(self, hover: bool = False):
cv = self._hdr_canvas
cv.delete("logo")
cv.create_text(self._px(24), self._logo_y, text="Octo Updater",
font=("Segoe UI", 24, "bold"),
fill="#b478d9" if hover else "#9a5cbf",
anchor="w", tags="logo")
def _draw_update_label(self, hover: bool = False):
cv = self._hdr_canvas
cv.delete("upd_label")
self._hdr_regions.pop("update", None)
if not self._update_available:
return
cv.create_text(self._logo_cx, self._logo_y + self._px(26),
text="Update available!",
font=("Segoe UI", 10, "bold"),
fill=C_GOLD_LT if hover else C_GOLD,
anchor="n", tags="upd_label")
lb = cv.bbox("upd_label")
if lb:
self._hdr_regions["update"] = (lb[0] - self._px(4), lb[1] - self._px(2),
lb[2] + self._px(4), lb[3] + self._px(2))
def _draw_gear(self, hover: bool = False):
cv = self._hdr_canvas
cv.delete("gear_icon")
cv.create_text(WIN_W - self._px(10), self._px(8), text="⚙",
font=("Segoe UI", 13),
fill=C_GOLD if hover else C_TEXT_DIM,
anchor="ne", tags="gear_icon")
def _hdr_hit(self, x, y):
for name, (x0, y0, x1, y1) in self._hdr_regions.items():
if x0 <= x <= x1 and y0 <= y <= y1:
return name
return None
def _on_hdr_motion(self, event):
name = self._hdr_hit(event.x, event.y) if event is not None else None
if name == self._hdr_hover:
return
prev = self._hdr_hover
self._hdr_hover = name
if prev in self._nav_pos:
self._draw_nav_tab(prev)
if name in self._nav_pos:
self._draw_nav_tab(name, hover=True)
if "gear" in (prev, name):
self._draw_gear(hover=(name == "gear"))
if "logo" in (prev, name):
self._draw_logo(hover=(name == "logo"))
if "update" in (prev, name):
self._draw_update_label(hover=(name == "update"))
self._hdr_canvas.configure(cursor="hand2" if name else "")
def _on_hdr_click(self, event):
name = self._hdr_hit(event.x, event.y)
if name == "gear":
self._open_settings(event)
elif name in ("logo", "update"):
self._open_url("https://github.com/rebasedkon/octo-updater")
elif name in self._nav_pos:
self._switch_tab(name)
def _check_updater_update(self):
"""Background daily check of the updater's own GitHub releases; shows
the 'Update available!' header label if a newer version exists."""
try:
tag = fetch_updater_latest_tag()
except Exception:
tag = None
if updater_update_available(tag):
def show():
self._update_available = True
self._draw_update_label()
self.after(0, show)
def _build_panel(self):
PANEL_TOP = self._px(109)
PANEL_BOT = FOOT_H
PANEL_H = WIN_H - PANEL_TOP - PANEL_BOT
PAD = self._px(40)
panel = tk.Frame(self, bg=C_BG)
panel.place(x=PAD, y=PANEL_TOP + self._px(10),
width=WIN_W - PAD * 2,
height=PANEL_H - self._px(20))
self._news_panel = panel
self._active_panel = panel # NEWS is the initial tab
inner_w = WIN_W - PAD * 2
self._news_left_w = int(inner_w * 0.60)
self._news_right_w = inner_w - self._news_left_w - self._px(12)
# Latest announcement — parchment panel (left)
feat = tk.Frame(panel, bg=C_PARCH)
feat.place(x=0, y=0, width=self._news_left_w, relheight=1.0)
self._feat_frame = feat
# Patch-notes list (right)
ann = tk.Frame(panel, bg=C_PANEL,
highlightthickness=1,
highlightbackground=C_PANEL_BDR)
ann.place(x=self._news_left_w + self._px(12), y=0,
width=self._news_right_w, relheight=1.0)
self._patch_frame = ann
self._render_featured(None, loading=True)
self._render_patch_notes(None, loading=True)
self._log_line("Octo Updater v" + UPDATER_VERSION + "\n", "acct")
self._log_line("─" * 60 + "\n", "dim")
self._build_mods_panel()
self._build_tweaks_panel()
self._build_addons_panel()
self._build_mpq_panel()
# ── news panel ───────────────────────────────────────────────────────────
def _load_news(self, force=False):
self._load_featured(force)
self._load_patch_notes(force)
def _load_featured(self, force=False):
now = time.time()
if (not force and self._featured is not None
and (now - self._feat_ts) < NEWS_CACHE_TTL):
return
self._render_featured(None, loading=True)
def worker():
feat, err = None, ""
try:
feat = fetch_featured_post()
except Exception:
err = "Couldn't reach the news feed."
def apply():
self._feat_ts = time.time()
self._featured = feat
self._render_featured(feat, error=err)
self.after(0, apply)
threading.Thread(target=worker, daemon=True).start()
def _load_patch_notes(self, force=False):
now = time.time()
if (not force and self._patch_items is not None
and (now - self._patch_ts) < NEWS_CACHE_TTL):
return
self._render_patch_notes(None, loading=True)
def worker():
items, err = None, ""
try:
items = fetch_patch_notes()
except Exception:
err = "Couldn't reach the news feed."
def apply():
self._patch_ts = time.time()
self._patch_items = items
self._render_patch_notes(items, error=err)
self.after(0, apply)
threading.Thread(target=worker, daemon=True).start()
def _render_featured(self, post, loading=False, error=""):
f = self._feat_frame
for w in f.winfo_children():
w.destroy()
f.configure(highlightthickness=1, highlightbackground=C_PARCH_EDGE)
title = (post or {}).get("title", "")
# Title band — slightly darker parchment strip
band = tk.Frame(f, bg=C_PARCH_BAND)
band.pack(fill="x")
hdr = tk.Frame(band, bg=C_PARCH_BAND)
hdr.pack(fill="x", padx=self._px(20), pady=(self._px(16), self._px(12)))
tk.Label(hdr,
text=title.upper() if title else "NEWS",
font=("Segoe UI", 13, "bold"),
fg=C_PARCH_TITLE, bg=C_PARCH_BAND,
wraplength=self._news_left_w - self._px(100),
justify="left", anchor="w").pack(side="left",
fill="x", expand=True)
rf = tk.Label(hdr, text="⟳", font=("Segoe UI", 14),
fg=C_PARCH_DIM, bg=C_PARCH_BAND, cursor="hand2")
rf.pack(side="right")
rf.bind("<Button-1>", lambda e: self._load_featured(force=True))
rf.bind("<Enter>", lambda e: rf.configure(fg=C_PARCH_LINK))
rf.bind("<Leave>", lambda e: rf.configure(fg=C_PARCH_DIM))
if not post:
msg = error or ("Loading…" if loading
else "No news yet — check back later.")
tk.Label(f, text=msg, font=("Segoe UI", 10),
fg=C_PARCH_DIM, bg=C_PARCH).pack(padx=self._px(20),
pady=self._px(16),
anchor="w")
return
byline = []
if post.get("author"):
byline.append(f"by {post['author']}")
byline.append(_format_news_date(post.get("date", "")))
bl = tk.Frame(f, bg=C_PARCH_BAND)
bl.pack(fill="x")
tk.Label(bl, text=" · ".join(byline),
font=("Segoe UI", 10, "italic"),
fg=C_PARCH_DIM, bg=C_PARCH_BAND,
anchor="w").pack(fill="x", padx=self._px(20), pady=self._px(10))
tk.Frame(f, bg=C_PARCH_LINE, height=self._px(1)).pack(fill="x")
# Pack the link first with side="bottom" so it's always reserved its
# space; the body Text (which defaults to 24 lines tall) then fills
# only the remaining area instead of clipping the link off the panel.
if post.get("url"):
link = tk.Label(f, text="⧉ Read full post on the forum",
font=("Segoe UI", 11),
fg=C_PARCH_LINK, bg=C_PARCH,
cursor="hand2", anchor="w")
link.pack(side="bottom", fill="x", padx=self._px(20),
pady=(self._px(4), self._px(16)))
link.bind("<Button-1>",
lambda e, u=post["url"]: self._open_url(u))
link.bind("<Enter>", lambda e: link.configure(fg=C_PARCH_TITLE))
link.bind("<Leave>", lambda e: link.configure(fg=C_PARCH_LINK))
body = _strip_html(post.get("html", ""))
txt = tk.Text(f, bg=C_PARCH, fg=C_PARCH_TEXT, relief="flat",
font=("Segoe UI", 11), wrap="word", height=1,
padx=self._px(2), pady=self._px(8),
spacing2=self._px(4), spacing3=self._px(4),
highlightthickness=0, cursor="arrow")
txt.insert("1.0", body)
txt.configure(state="disabled")
txt.pack(fill="both", expand=True, padx=self._px(20),
pady=(self._px(8), self._px(2)))
def _render_patch_notes(self, items, loading=False, error=""):
f = self._patch_frame
for w in f.winfo_children():
w.destroy()
hdr = tk.Frame(f, bg=C_PANEL)
hdr.pack(fill="x", padx=self._px(14), pady=(self._px(16), self._px(10)))
tk.Label(hdr, text="PATCH NOTES",
font=("Segoe UI", 12, "bold"),
fg=C_GOLD, bg=C_PANEL).pack(side="left")
rf = tk.Label(hdr, text="⟳", font=("Segoe UI", 14),
fg=C_TEXT_DIM, bg=C_PANEL, cursor="hand2")
rf.pack(side="right")
rf.bind("<Button-1>", lambda e: self._load_patch_notes(force=True))
rf.bind("<Enter>", lambda e: rf.configure(fg=C_GOLD))
rf.bind("<Leave>", lambda e: rf.configure(fg=C_TEXT_DIM))
tk.Frame(f, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", padx=self._px(14))
if items is None or error:
msg = error or ("Loading…" if loading
else "Couldn't reach the news feed.")
tk.Label(f, text=msg, font=FONT_BODY,
fg=C_TEXT_DIM, bg=C_PANEL).pack(padx=self._px(14),
pady=self._px(12),
anchor="w")
return
if not items:
tk.Label(f, text="No news yet — check back later.",
font=FONT_BODY, fg=C_TEXT_DIM,
bg=C_PANEL).pack(padx=self._px(14), pady=self._px(12),
anchor="w")
return
list_frame = tk.Frame(f, bg=C_PANEL)
list_frame.pack(fill="both", expand=True, padx=(self._px(14), self._px(4)),
pady=(0, self._px(10)))
canvas = tk.Canvas(list_frame, bg=C_PANEL, highlightthickness=0)
sb = SlimScrollbar(list_frame, command=canvas.yview, bg=C_PANEL,
width=self._px(10))
self._wheel_canvases.append(canvas)
inner = tk.Frame(canvas, bg=C_PANEL)
inner.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw",
width=self._news_right_w - self._px(40))
canvas.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
wrap_w = self._news_right_w - self._px(50)
for item in items:
top = tk.Frame(inner, bg=C_PANEL)
top.pack(fill="x", pady=(self._px(12), 0))
tk.Label(top, text=_format_news_date(item.get("date", "")),
font=("Segoe UI", 9), fg=C_TEXT_DIM,
bg=C_PANEL).pack(side="right", anchor="n")
tk.Label(top, text=item.get("title", ""),
font=("Segoe UI", 11, "bold"),
fg=C_GOLD, bg=C_PANEL,
wraplength=wrap_w - self._px(85), justify="left",
anchor="w").pack(side="left", fill="x", expand=True)
if item.get("author"):
tk.Label(inner, text=f"by {item['author']}",
font=("Segoe UI", 10, "italic"),
fg=C_TEXT_DIM, bg=C_PANEL,
anchor="w").pack(fill="x", pady=(self._px(2), 0))
body = item.get("body", "").strip()
if len(body) > 260:
body = body[:260].rstrip() + "…"
if body:
tk.Label(inner, text=body, font=("Segoe UI", 10),
fg=C_TEXT, bg=C_PANEL,
wraplength=wrap_w, justify="left",
anchor="w").pack(fill="x", pady=(self._px(5), 0))
if item.get("url"):
lnk = tk.Label(inner, text="⧉ Read more",
font=("Segoe UI", 10),
fg=C_GOLD, bg=C_PANEL,
cursor="hand2", anchor="w")
lnk.pack(fill="x", pady=(self._px(5), 0))
lnk.bind("<Button-1>",
lambda e, u=item["url"]: self._open_url(u))
lnk.bind("<Enter>", lambda e, w=lnk: w.configure(fg=C_GOLD_LT))
lnk.bind("<Leave>", lambda e, w=lnk: w.configure(fg=C_GOLD))
tk.Frame(inner, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", pady=(self._px(12), 0))
# ── tweaks panel ─────────────────────────────────────────────────────────────
def _build_tweaks_panel(self):
outer = tk.Frame(self, bg=C_PANEL,
highlightthickness=1,
highlightbackground=C_PANEL_BDR,
highlightcolor=C_PANEL_BDR)
self._tweaks_panel_frame = outer
self._tweaks_inner = tk.Frame(outer, bg=C_PANEL)
self._tweaks_inner.pack(fill="both", expand=True)
bar = tk.Frame(outer, bg=C_PANEL)
bar.place(relx=1.0, x=-self._px(16), y=self._px(3), anchor="ne")
apl = tk.Label(bar, text="Apply", font=("Segoe UI", 11),
fg=C_TEXT, bg=C_PANEL_BDR, cursor="hand2",
padx=self._px(16), pady=self._px(4))
apl.bind("<Button-1>", lambda e: self._apply_tweaks())
apl.bind("<Enter>", lambda e: apl.configure(bg=C_GOLD, fg="#000"))
apl.bind("<Leave>", lambda e: apl.configure(bg=C_PANEL_BDR, fg=C_TEXT))
self._tweaks_apply_btn = apl
rst = tk.Label(bar, text="Reset", font=("Segoe UI", 11),
fg=C_TEXT, bg=C_PANEL_BDR, cursor="hand2",
padx=self._px(16), pady=self._px(4))
rst.bind("<Button-1>", lambda e: self._reset_tweaks())
rst.bind("<Enter>", lambda e: rst.configure(bg=C_GOLD, fg="#000"))
rst.bind("<Leave>", lambda e: rst.configure(bg=C_PANEL_BDR, fg=C_TEXT))
self._tweaks_reset_btn = rst
self._tweak_widgets: dict = {}
self._tweak_vars: dict = {}
self._build_tweaks_rows()
def _build_tweaks_rows(self):
for w in self._tweaks_inner.winfo_children():
w.destroy()
self._tweak_widgets = {}
self._tweak_vars = {}
values = load_tweaks_config()
PAD_X = self._px(16)
for (tid, label, kind, recommended, _, desc, mn, mx, step) in TWEAKS_ITEMS:
if kind == "section":
tk.Label(self._tweaks_inner, text=label,
font=("Segoe UI", 11, "bold"),
fg=C_GOLD, bg=C_PANEL,
anchor="w").pack(fill="x", padx=PAD_X,
pady=(self._px(10), self._px(2)))
tk.Frame(self._tweaks_inner, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", padx=PAD_X, pady=(0, self._px(4)))
continue
row = tk.Frame(self._tweaks_inner, bg=C_PANEL)
row.pack(fill="x", padx=PAD_X, pady=self._px(3))
tk.Label(row, text=label,
font=("Segoe UI", 10, "bold"),
fg=C_TEXT, bg=C_PANEL,
width=22, anchor="w").pack(side="left")
if kind == "checkbox":
var = tk.BooleanVar(value=values.get(tid, False))
var.trace_add("write", self._refresh_tweaks_buttons)
tk.Checkbutton(row, variable=var,
bg=C_PANEL, activebackground=C_PANEL,
fg=C_TEXT, selectcolor=C_PANEL,
highlightthickness=0, bd=0,
relief="flat", cursor="hand2"
).pack(side="left", padx=(self._px(4), self._px(12)))
self._tweak_vars[tid] = var
elif kind == "number":
val = values.get(tid, mn or 0)
var = tk.StringVar(value=str(int(val)))
var.trace_add("write", self._refresh_tweaks_buttons)
entry = tk.Entry(row, textvariable=var,
bg="#18181e", fg=C_TEXT,
insertbackground=C_GOLD,
relief="flat", font=FONT_MONO,
width=7,
highlightthickness=1,
highlightbackground=C_PANEL_BDR,
highlightcolor=C_GOLD,
justify="center")
entry.pack(side="left", padx=(self._px(4), self._px(12)),
ipady=self._px(3))
self._tweak_vars[tid] = var
self._tweak_widgets[tid] = entry
def _clamp(e, t=tid, lo=mn, hi=mx):
try:
v = int(float(self._tweak_vars[t].get()))
if lo is not None: v = max(lo, v)
if hi is not None: v = min(hi, v)
self._tweak_vars[t].set(str(v))
except ValueError:
self._tweak_vars[t].set(str(TWEAKS_DEFAULTS.get(t, lo or 0)))
entry.bind("<FocusOut>", _clamp)
entry.bind("<Return>", _clamp)
elif kind == "dropdown":
cur = values.get(tid, TWEAKS_DEFAULTS.get(tid))
if cur not in LOCALES:
cur = DEFAULT_LOCALE
var = tk.StringVar(value=cur) # holds the locale code
var.trace_add("write", self._refresh_tweaks_buttons)
mb = tk.Menubutton(
row, text=LOCALES[cur][1], font=("Segoe UI", 10),
fg=C_TEXT, bg="#18181e", activebackground=C_PANEL_BDR,
activeforeground=C_TEXT, relief="flat", cursor="hand2",
width=15, anchor="w", padx=self._px(8), pady=self._px(2),
highlightthickness=1, highlightbackground=C_PANEL_BDR)
menu = tk.Menu(mb, tearoff=0, bg=C_PANEL, fg=C_TEXT,
activebackground=C_GOLD, activeforeground="#000")
for code, (_i, label) in LOCALES.items():
menu.add_command(
label=label,
command=lambda c=code, v=var: v.set(c))
mb["menu"] = menu
mb.pack(side="left", padx=(self._px(4), self._px(12)))
var.trace_add(
"write",
lambda *_a, m=mb, v=var: m.configure(
text=LOCALES.get(v.get(), (0, v.get()))[1]))
self._tweak_vars[tid] = var
if desc:
tk.Label(row, text=desc,
font=("Segoe UI", 10), fg=C_TEXT_DIM, bg=C_PANEL,
wraplength=self._px(520), justify="left", anchor="w"
).pack(side="left", fill="x", expand=True)
self._refresh_tweaks_buttons()
def _refresh_tweaks_buttons(self, *args):
"""Show Apply only when the UI differs from the saved config and
Reset only when values are custom (differ from the defaults); paint
out-of-range number entries red."""
if not getattr(self, "_tweak_vars", None):
return
any_bad = False
for tid, entry in self._tweak_widgets.items():
lo, hi = TWEAKS_LIMITS.get(tid, (None, None))
try:
v = int(float(self._tweak_vars[tid].get()))
bad = ((lo is not None and v < lo) or
(hi is not None and v > hi))
except ValueError:
bad = True
any_bad = any_bad or bad
entry.configure(fg=C_ERR if bad else C_TEXT)
ui = self._get_tweaks_from_ui()
saved = load_tweaks_config()
defaults = dict(TWEAKS_DEFAULTS)
defaults["fieldOfView"] = fov_default_for_display()
def norm(d):
def one(k):
default = TWEAKS_DEFAULTS.get(k)
if isinstance(default, bool):
return bool(d.get(k))
if isinstance(default, str):
return str(d.get(k, default))
return int(d.get(k, 0))
return {k: one(k) for k in ui}
# An out-of-range entry always counts as a change: _get_tweaks_from_ui
# clamps it, and the clamped value can coincide with the saved one
# (e.g. saved 180, typed 192 → clamps to 180), which would otherwise
# hide the buttons while the entry still shows an invalid number.
ui_n = norm(ui)
dirty = any_bad or ui_n != norm(saved)
custom = any_bad or ui_n != norm(defaults)
self._tweaks_apply_btn.pack_forget()
self._tweaks_reset_btn.pack_forget()
if dirty:
self._tweaks_apply_btn.pack(side="left")
if custom:
self._tweaks_reset_btn.pack(
side="left", padx=(self._px(8), 0) if dirty else (0, 0))
def _refresh_tweaks_panel(self):
values = load_tweaks_config()
for tid, var in self._tweak_vars.items():
v = values.get(tid, TWEAKS_DEFAULTS.get(tid))
if isinstance(var, tk.BooleanVar):
var.set(bool(v))
elif isinstance(TWEAKS_DEFAULTS.get(tid), str):
var.set(v if v in LOCALES else DEFAULT_LOCALE)
else:
var.set(str(int(v)) if v is not None else "")
def _get_tweaks_from_ui(self) -> dict:
"""Read tweak values from the UI, always clamped to their limits —
an out-of-range entry can never reach the config or the exe patch."""
result = {}
for tid, var in self._tweak_vars.items():
if isinstance(var, tk.BooleanVar):
result[tid] = var.get()
elif isinstance(TWEAKS_DEFAULTS.get(tid), str):
code = var.get()
result[tid] = code if code in LOCALES else DEFAULT_LOCALE
else:
try:
v = int(float(var.get()))
except ValueError:
v = TWEAKS_DEFAULTS.get(tid, 0)
lo, hi = TWEAKS_LIMITS.get(tid, (None, None))
if lo is not None:
v = max(lo, v)
if hi is not None:
v = min(hi, v)
result[tid] = v
return result
def _reset_tweaks(self):
defaults = dict(TWEAKS_DEFAULTS)
defaults["fieldOfView"] = fov_default_for_display()
save_tweaks_config(defaults)
self._refresh_tweaks_panel()
out = self._game_path.get().strip()
if out and os.path.exists(os.path.join(out, "WoW.exe")):
self._set_btn_busy("Patching…")
self._status_var.set("Applying tweaks…")
# Pass the same defaults that were saved
threading.Thread(target=self._apply_tweaks_worker,
args=(out, defaults), daemon=True).start()
def _apply_tweaks(self):
values = self._get_tweaks_from_ui()
save_tweaks_config(values)
# Write the (possibly clamped) saved values back into the entries so
# the UI never keeps showing an out-of-range number after Apply.
self._refresh_tweaks_panel()
out = self._game_path.get().strip()
if not out:
self._log_line("Game folder not set.\n", "err")
return
exe = os.path.join(out, "WoW.exe")
if not os.path.exists(exe):
self._log_line("WoW.exe not found — run Update first.\n", "err")
return
self._log_line("\nApplying tweaks to WoW.exe...\n", "acct")
self._set_btn_busy("Patching…")
self._status_var.set("Applying tweaks…")
threading.Thread(target=self._apply_tweaks_worker,
args=(out, values), daemon=True).start()
def _apply_tweaks_worker(self, client_dir: str, tweaks: dict):
log_q = queue.Queue()
prog_q = queue.Queue()
worker = UpdateWorker(client_dir, log_q, prog_q)
def drain():
try:
while True:
msg, tag = log_q.get_nowait()
if msg not in ("__DONE__", "__ERROR__") and not msg.startswith("__"):
self.after(0, lambda m=msg, t=tag: self._log_line(
(m if m.endswith("\n") else m + "\n"), t))
except queue.Empty:
pass
try:
worker.patch_exe(tweaks)
drain()
update_config_wtf(client_dir, tweaks)
self._log_line("\nTweaks applied.\n", "ok")
self.after(0, self._refresh_ready_state)
except Exception as e:
drain()
self._log_line(f"\n✗ Tweak patch failed: {e}\n", "err")
def _fail_state():
self._status_var.set("Tweaks failed — check the log")
self._set_btn_update()
self.after(0, _fail_state)
# ── version migrations ─────────────────────────────────────────────────────────
def _new_version_installed(self):
"""One-time, version-gated migrations run at launch.
The config key ``octo_updater_version`` records the updater version
whose migrations have already been applied to this install (absent on
installs predating this mechanism, < 1.3). When it already matches the
running ``UPDATER_VERSION`` there's nothing to do.
Otherwise replay every migration step newer than the stored version,
then stamp the current one so each runs exactly once. Runs
synchronously (before the normal startup verify is scheduled), so any
WoW.exe re-patch is settled before verify reads the exe — no read/write
race, and the standard verify still runs and can surface a pending
client update. Future versions add their own ``if from_ver < (x, y): …``
block below."""
cfg = load_config()
if cfg.get("octo_updater_version") == UPDATER_VERSION:
return
if not self._first_run:
from_ver = _parse_version(cfg.get("octo_updater_version", ""))
if from_ver < (1, 3):
self._migrate_1_3()
self._cfg = update_config(
lambda c: c.__setitem__("octo_updater_version", UPDATER_VERSION))
def _migrate_1_3(self):
"""1.3: raise the ground-clutter defaults for existing installs.
frillDistance is a VanillaTweak baked into WoW.exe, so bumping the old
70 → 120 default needs a re-patch (skipped for a user who already set a
higher value). frillDensity is a plain Config.wtf value; if it's still
below 128 we lift it to the new default in place."""
out = self._game_path.get().strip()
# frillDistance (VanillaTweak / WoW.exe). Raise to 120 if it's lower
stored = dict(load_config().get("tweaks", {}))
if int(stored.get("frillDistance", 70)) < 120:
stored["frillDistance"] = 120
save_tweaks_config(stored)
self._log_line(
"Ground clutter distance raised to 120 (new default).\n", "acct")
if out and os.path.exists(os.path.join(out, "WoW.exe")):
self._log_line("Re-patching WoW.exe for v1.3…\n", "acct")
self._apply_tweaks_worker(out, load_tweaks_config())
# frillDensity (Config.wtf value). Raise to 128 if it's lower
cfg_path = os.path.join(out, "WTF", "Config.wtf") if out else ""
if cfg_path and os.path.exists(cfg_path):
try:
with open(cfg_path, "r", encoding="utf-8") as f:
lines = f.readlines()
changed = False
for i, line in enumerate(lines):
if not line.strip().lower().startswith("set frilldensity "):
continue
parts = line.split('"')
cur = (int(parts[1]) if len(parts) >= 2
and parts[1].strip().lstrip("-").isdigit() else None)
if cur is not None and cur < 128:
lines[i] = 'SET frillDensity "128"\n'
changed = True
break
if changed:
with open(cfg_path, "w", encoding="utf-8") as f:
f.writelines(lines)
self._log_line(
"Ground clutter density raised to 128 (new default).\n",
"acct")
except Exception as e:
self._log_line(
f"Could not update frillDensity in Config.wtf: {e}\n", "err")
# dlls.txt cleanup: earlier versions wrongly registered the loader
# (VfPatcher.dll) and dxvk here — neither is loaded via dlls.txt, so a
# loader that walks the list would try to load itself / a missing file.
dlls_path = _dlls_txt_path(out) if out else ""
if dlls_path and os.path.exists(dlls_path):
try:
present = {l.strip().lower()
for l in open(dlls_path).read().splitlines()}
stale = [n for n in ("VfPatcher.dll", "dxvk")
if n.lower() in present]
for n in stale:
remove_dll(out, n)
if stale:
self._log_line(
f"Removed stale dlls.txt entries: {', '.join(stale)}.\n",
"acct")
except Exception as e:
self._log_line(f"Could not clean dlls.txt: {e}\n", "err")
# dxvk.conf: refresh an existing config to the current template
# (d3d9.presentInterval, no cursor / numBackBuffers). Only when DXVK is
# actually present — a missing dxvk.conf is left alone.
if out and os.path.exists(os.path.join(out, "dxvk.conf")):
_write_dxvk_conf(out)
self._log_line("dxvk.conf updated to the new defaults.\n", "acct")
# Legacy game-hash cache: the torrent-based download no longer keeps a
# per-file SHA-1 cache. Delete the pre-1.3 cache file beside the app.
stale_cache = os.path.join(APP_DIR, "octo_updater_hash_cache.json")
try:
if os.path.exists(stale_cache):
os.remove(stale_cache)
except OSError:
pass
# Drop the vestigial WoW.exe hash keys from config — unused since the
# download revamp (verify is size-based, patching reads the pristine
# base), so they'd otherwise linger in every migrated config.
self._cfg = update_config(
lambda c: (c.pop("expected_patched_wow_hash", None),
c.pop("original_server_wow_hash", None)))
self._set_pending_reconcile("keep-config")
# ── mods panel ───────────────────────────────────────────────────────────────
def _build_mods_panel(self):
PAD = self._px(18)
PANEL_TOP = self._px(119)
PANEL_H = WIN_H - PANEL_TOP - FOOT_H - self._px(10)
outer = tk.Frame(self, bg=C_PANEL,
highlightthickness=1,
highlightbackground=C_PANEL_BDR)
self._mods_panel_frame = outer
note = tk.Frame(outer, bg=C_PANEL)
note.pack(fill="x", padx=self._px(16), pady=(self._px(14), self._px(8)),
anchor="w")
tk.Label(note, text="Mods marked with ",
font=("Segoe UI", 10), fg=C_TEXT_DIM,
bg=C_PANEL).pack(side="left")
tk.Label(note, text="★", font=("Segoe UI", 10),
fg=C_GOLD, bg=C_PANEL).pack(side="left")
tk.Label(note, text=" are essential",
font=("Segoe UI", 10), fg=C_TEXT_DIM,
bg=C_PANEL).pack(side="left")
tk.Frame(outer, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", padx=self._px(16), pady=(0, self._px(4)))
list_frame = tk.Frame(outer, bg=C_PANEL)
list_frame.pack(fill="both", expand=True, padx=self._px(16))
canvas = tk.Canvas(list_frame, bg=C_PANEL, highlightthickness=0)
sb = SlimScrollbar(list_frame, command=canvas.yview, bg=C_PANEL,
width=self._px(10))
self._wheel_canvases.append(canvas)
self._mods_inner = tk.Frame(canvas, bg=C_PANEL)
self._mods_inner.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
mods_win = canvas.create_window((0, 0), window=self._mods_inner,
anchor="nw")
# Stretch rows to the full canvas width so right-side controls
# (Ignore updates) sit flush against the scrollbar.
canvas.bind("<Configure>",
lambda e: canvas.itemconfigure(mods_win, width=e.width))
canvas.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
tk.Frame(outer, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", padx=self._px(16), pady=(self._px(4), 0))
foot = tk.Frame(outer, bg=C_PANEL)
foot.pack(fill="x", padx=self._px(16), pady=(self._px(6), self._px(10)))
# Packed on demand by _refresh_apply_btn_visibility(): shown only
# when there are unapplied checkbox changes or a mod is in error.
self._apply_btn = tk.Label(foot, text="Apply",
font=("Segoe UI", 11),
fg=C_TEXT, bg=C_PANEL_BDR,
cursor="hand2", padx=self._px(16),
pady=self._px(4))
self._apply_btn.bind("<Button-1>", lambda e: self._apply_mods())
self._apply_btn.bind("<Enter>", lambda e: self._apply_btn.configure(bg=C_GOLD, fg="#000"))
self._apply_btn.bind("<Leave>", lambda e: self._apply_btn.configure(bg=C_PANEL_BDR, fg=C_TEXT))
self._mod_row_vars: dict = {}
self._mods_state: list = []
self._mod_pending_state: dict = {}
self._render_mod_rows()
self._refresh_apply_btn_visibility()
def _render_mod_rows(self):
cfg = load_config()
mods_cfg = cfg.get("mods", {})
# Essential mods first, then alphabetical within each group.
mods_sorted = sorted(
MODS_REGISTRY,
key=lambda m: (not m.get("essential", False), m["name"].lower()))
if self._mod_row_vars:
for mod in mods_sorted:
mid = mod["id"]
state = mods_cfg.get(mid, {})
live = next((m for m in self._mods_state if m["id"] == mid), None)
refs = self._mod_row_vars.get(mid, {})
if not refs:
continue
if live is not None and "ver_label" in refs:
# Installed mods show their installed version; others
# show the latest available.
ver = state.get("installed_version") or live.get("latest_version") or "unknown"
refs["ver_label"].configure(text=f" {ver}")
# Checkbox always reflects config only — never a registry default.
# A mod only shows checked if it's actually recorded as installed.
if mid not in self._mod_pending_state:
if "enabled" in refs:
refs["enabled"].set(state.get("enabled", False))
if "ignore" in refs:
refs["ignore"].set(state.get("ignore_updates", False))
has_error = bool(state.get("error"))
installed = bool(state.get("installed_version"))
if "name_label" in refs:
refs["name_label"].configure(
fg=C_ERR if has_error else (C_MOD_HL if installed else C_TEXT))
if "desc_label" in refs:
refs["desc_label"].configure(
fg=C_TEXT if state.get("enabled", False) else C_TEXT_DIM)
if "error_label" in refs:
if has_error:
refs["error_label"].configure(text=f" \u26a0 {state['error']}")
refs["error_label"].pack(fill="x", pady=(0, self._px(4)))
else:
refs["error_label"].pack_forget()
if "update_label" in refs:
self._style_mod_action_label(refs["update_label"],
mod, state, live)
return
for w in self._mods_inner.winfo_children():
w.destroy()
self._mod_row_vars = {}
for mod in mods_sorted:
mid = mod["id"]
state = mods_cfg.get(mid, {})
live = next((m for m in self._mods_state if m["id"] == mid), {})
# Installed mods show their installed version; others show latest.
latest_ver = state.get("installed_version") or live.get("latest_version") or "unknown"
# Checkbox reflects only what's actually recorded in config — never
# a registry default. Pending (not-yet-applied) UI changes still
# win so an in-progress toggle survives a background re-render.
enabled = self._mod_pending_state.get(mid, {}).get("enabled", state.get("enabled", False))
ignore_upd = state.get("ignore_updates", False)
essential = mod.get("essential", False)
installed = bool(state.get("installed_version"))
# Installed mods are highlighted green; the name is neutral text
# otherwise (error state overrides to red in the refresh paths).
name_col = C_MOD_HL if installed else C_TEXT
container = tk.Frame(self._mods_inner, bg=C_PANEL)
container.pack(fill="x")
row = tk.Frame(container, bg=C_PANEL)
row.pack(fill="x", pady=self._px(5))
name_f = tk.Frame(row, bg=C_PANEL, width=self._px(210))
name_f.pack(side="left", fill="y")
name_f.pack_propagate(False)
# Essential mods get a gold star badge; a fixed-width slot keeps
# the names aligned whether or not the star is present.
star = tk.Label(name_f, text="★" if essential else "",
font=("Segoe UI", 9), fg=C_GOLD, bg=C_PANEL,
width=2, anchor="w")
star.pack(side="left")
if essential:
self._add_tooltip(star, "Essential mod")
name_label = tk.Label(name_f, text=mod["name"],
font=("Segoe UI", 10, "bold"),
fg=name_col, bg=C_PANEL, anchor="w")
name_label.pack(side="left")
ver_label = tk.Label(name_f, text=f" {latest_ver}",
font=("Segoe UI", 9), fg=C_TEXT_DIM, bg=C_PANEL)
ver_label.pack(side="left")
enabled_var = tk.BooleanVar(value=enabled)
tk.Checkbutton(row, variable=enabled_var,
bg=C_PANEL, activebackground=C_PANEL,
fg=C_TEXT, selectcolor=C_PANEL,
highlightthickness=0, bd=0,
relief="flat", cursor="hand2",
command=lambda m=mid, v=enabled_var: self._toggle_mod(m, v)
).pack(side="left", padx=(self._px(4), self._px(8)))
# Right-side widgets are packed first so they stay pinned to the
# panel's right edge; the description then fills the middle.
ignore_var = tk.BooleanVar(value=ignore_upd)
ig_f = tk.Frame(row, bg=C_PANEL)
ig_f.pack(side="right", padx=(self._px(8), 0))
tk.Checkbutton(ig_f, variable=ignore_var,
bg=C_PANEL, activebackground=C_PANEL,
fg=C_TEXT, selectcolor=C_PANEL,
highlightthickness=0, bd=0,
relief="flat", cursor="hand2",
command=lambda m=mid, v=ignore_var: self._set_ignore(m, v)
).pack(side="left")
tk.Label(ig_f, text="Ignore updates",
font=("Segoe UI", 9), fg=C_TEXT_DIM, bg=C_PANEL).pack(side="left")
link = tk.Label(row, text="⧉",
font=("Segoe UI", 12), fg=C_TEXT_DIM,
bg=C_PANEL, cursor="hand2")
link.pack(side="right", padx=self._px(4))
link.bind("<Button-1>", lambda e, u=mod["repo_url"]: self._open_url(u))
link.bind("<Enter>", lambda e, l=link: l.configure(fg=C_GOLD))
link.bind("<Leave>", lambda e, l=link: l.configure(fg=C_TEXT_DIM))
update_label = tk.Label(row, text="update",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=C_PANEL, cursor="hand2")
update_label.bind("<Button-1>", lambda e, m=mid: self._update_mod(m))
update_label.bind("<Enter>", lambda e, l=update_label:
l.configure(fg=getattr(l, "_hover", C_GOLD_LT)))
update_label.bind("<Leave>", lambda e, l=update_label:
l.configure(fg=getattr(l, "_base", C_GOLD)))
self._style_mod_action_label(update_label, mod, state, live)
desc_label = tk.Label(row, text=mod["description"],
font=("Segoe UI", 10),
fg=(C_TEXT if enabled else C_TEXT_DIM),
bg=C_PANEL, wraplength=self._px(400),
justify="left", anchor="w")
desc_label.pack(side="left", fill="x", expand=True)
existing_err = state.get("error")
error_label = tk.Label(container, text="",
font=("Segoe UI", 9), fg=C_ERR,
bg=C_PANEL, anchor="w", padx=self._px(16))
if existing_err:
name_label.configure(fg=C_ERR)
error_label.configure(text=f" \u26a0 {existing_err}")
error_label.pack(fill="x", pady=(0, self._px(4)))
divider = tk.Frame(self._mods_inner, bg=C_DIVIDER, height=self._px(1))
divider.pack(fill="x", pady=(self._px(2), 0))
self._mod_row_vars[mid] = {
"enabled": enabled_var,
"ignore": ignore_var,
"ver_label": ver_label,
"name_label": name_label,
"desc_label": desc_label,
"error_label": error_label,
"update_label": update_label,
}
def _load_mods_state(self):
state = []
for mod in MODS_REGISTRY:
try:
v = fetch_mod_latest_version_cached(mod)
except Exception:
v = None
if v:
state.append({"id": mod["id"], "latest_version": v})
self._mods_state = state
self.after(0, self._render_mod_rows)
self.after(0, self._refresh_mods_badge)
def _count_mod_updates(self) -> int:
mods_cfg = load_config().get("mods", {})
count = 0
for mod in MODS_REGISTRY:
state = mods_cfg.get(mod["id"], {})
live = next((m for m in self._mods_state
if m["id"] == mod["id"]), None)
if not state.get("error") and mod_update_available(mod, state, live):
count += 1
return count
def _refresh_mods_badge(self):
try:
count = self._count_mod_updates()
except Exception:
count = 0
if count != self._mod_updates_count:
self._mod_updates_count = count
self._draw_nav_tab("MODS")
# The two game-resolution fixes can't coexist: enabling one prompts to
# replace the other (see _confirm_res_fix_toggle).
_RES_FIX_CONFLICTS = {"no1600x1200": "VanillaMultiMonitorFix",
"VanillaMultiMonitorFix": "no1600x1200"}
# Per-mod (title, message, confirm) shown when enabling either resolution
# fix. confirm=True asks Yes/No and only proceeds on Yes; confirm=False is
# an OK-only acknowledgement that always proceeds.
_RES_FIX_NOTICE = {
"no1600x1200": (
"No1600x1200",
"No1600x1200 mod may cause the game to crash on launch on some "
"systems.\n\n"
"VanillaMultiMonitorFix is the recommended resolution fix. Use "
"No1600x1200 only if VanillaMultiMonitorFix doesn't solve your "
"problem.\n\n"
"Enable No1600x1200 anyway?",
True),
"VanillaMultiMonitorFix": (
"VanillaMultiMonitorFix",
"Enable this mod only if the game doesn't detect your monitor's "
"native resolution.\n\n"
"VanillaMultiMonitorFix may interfere with correct resolution "
"detection. By default, it's recommended to first try launching the "
"game with DXVK, which may fix the resolution detection issue.\n\n"
"Enable VanillaMultiMonitorFix only if you're actually experiencing "
"resolution issues.",
False),
}
def _toggle_mod(self, mod_id: str, var: tk.BooleanVar):
# Turning on one of the conflicting resolution fixes needs confirmation
# before it becomes a pending change.
if var.get() and mod_id in self._RES_FIX_CONFLICTS:
if not self._confirm_res_fix_toggle(mod_id, var):
return # backed out — var + pending already reset
self._mod_pending_state.setdefault(mod_id, {})["enabled"] = var.get()
self._refresh_apply_btn_visibility()
def _mod_is_on(self, mod_id: str) -> bool:
"""Whether a mod is currently on in the UI: a pending toggle wins,
otherwise its saved enabled state."""
pending = self._mod_pending_state.get(mod_id, {})
if "enabled" in pending:
return bool(pending["enabled"])
return bool(load_config().get("mods", {}).get(mod_id, {}).get("enabled"))
def _confirm_res_fix_toggle(self, mod_id: str, var: tk.BooleanVar) -> bool:
"""Confirm enabling no1600x1200 / VanillaMultiMonitorFix. They conflict,
so first offer to replace the other one, then show that mod's own notice
(_RES_FIX_NOTICE). Returns True to proceed, or False if the user backed
out — in which case the checkbox and pending state are reset to 'off'."""
from tkinter import messagebox
other = self._RES_FIX_CONFLICTS[mod_id]
name = next(m["name"] for m in MODS_REGISTRY if m["id"] == mod_id)
oname = next(m["name"] for m in MODS_REGISTRY if m["id"] == other)
def _cancel():
var.set(False)
self._mod_pending_state.pop(mod_id, None)
self._refresh_apply_btn_visibility()
# 1) Conflict — only when the other fix is currently on.
replace_other = self._mod_is_on(other)
if replace_other and not messagebox.askyesno(
"Conflicting mods",
f"{name} and {oname} both affect the game's resolution detection "
f"and can't be used together — only one can be active at a time.\n\n"
f"VanillaMultiMonitorFix is recommended if you experience "
f"resolution issues in the game.\n\n"
f"Replace {oname} with {name}?",
parent=self):
_cancel()
return False
# 2) That mod's own notice (crash risk / usage guidance). A confirm
# notice can still cancel the toggle; an OK-only one just informs.
title, message, confirm = self._RES_FIX_NOTICE[mod_id]
if confirm:
if not messagebox.askyesno(title, message, parent=self):
_cancel()
return False
else:
messagebox.showinfo(title, message, parent=self)
# Confirmed — schedule the other fix for removal and untick its box.
if replace_other:
self._mod_pending_state.setdefault(other, {})["enabled"] = False
other_var = self._mod_row_vars.get(other, {}).get("enabled")
if other_var is not None:
other_var.set(False)
return True
def _set_ignore(self, mod_id: str, var: tk.BooleanVar):
self._mod_pending_state.setdefault(mod_id, {})["ignore_updates"] = var.get()
self._refresh_apply_btn_visibility()
def _refresh_apply_btn_visibility(self):
"""Apply is offered only when there is something to apply: pending
checkbox changes, or a failed mod the user may want to retry."""
has_error = any(bool(s.get("error"))
for s in load_config().get("mods", {}).values())
if self._mod_pending_state or has_error:
if not self._apply_btn.winfo_ismapped():
self._apply_btn.pack(side="left")
else:
self._apply_btn.pack_forget()
def _open_url(self, url: str):
import webbrowser
webbrowser.open(url)
def _style_mod_action_label(self, lbl, mod, state, live):
"""Drive the per-mod action label: 'retry' (red) when the mod is in
an error state, 'update' (gold) when a newer version is available,
hidden otherwise. Both do the same thing — reinstall the mod."""
if state.get("error"):
lbl._base, lbl._hover = C_GOLD, C_GOLD_LT
lbl.configure(text="retry", fg=C_GOLD)
lbl.pack(side="right", padx=(self._px(2), self._px(8)))
elif mod_update_available(mod, state, live):
lbl._base, lbl._hover = C_GOLD, C_GOLD_LT
lbl.configure(text="update", fg=C_GOLD)
lbl.pack(side="right", padx=(self._px(2), self._px(8)))
else:
lbl.pack_forget()
def _update_mod(self, mod_id: str):
"""Download and install the newest release of a single mod (the
per-row "update" label). Runs through the normal apply worker so
errors/versions are recorded exactly like a manual Apply."""
out = self._game_path.get().strip()
if not out:
return
mod = next(m for m in MODS_REGISTRY if m["id"] == mod_id)
self._log_line(f"\nUpdating {mod['name']}...\n", "acct")
self._set_btn_busy("Installing…")
self._status_var.set("Downloading mods…")
threading.Thread(target=self._apply_mods_worker,
args=(out, mod_id), daemon=True).start()
def _apply_mods(self):
out = self._game_path.get().strip()
if not out:
return
self._apply_btn.configure(text="Applying...", bg="#2a2a32", fg=C_TEXT_DIM)
self._set_btn_busy("Installing…")
self._status_var.set("Downloading mods…")
threading.Thread(target=self._apply_mods_worker,
args=(out,), daemon=True).start()
def _maybe_install_default_addons(self):
"""Auto-install the recommended addons the first time this game
folder is ready — the same one-shot mechanism as the default mods:
the absence of the "addons" key in config means "never initialized
for this folder", and a game-folder change wipes it to re-arm.
Runs after the default mods finished (chained from the mods apply
completion) or directly when mods were already initialized."""
if self._default_addons_install_started:
return
if load_config().get("addons") is not None:
return # already initialized for this folder
out = self._game_path.get().strip()
if not out or not os.path.exists(os.path.join(out, "WoW.exe")):
return # game isn't actually installed here yet
self._default_addons_install_started = True
# Mark this folder as initialized even if every install fails, so
# the batch doesn't re-fire on the next verify.
update_config(lambda c: c.setdefault("addons", {}))
# "Install recommended addons" (Settings → General): when
# off, skip the batch install but still verify so the ADDONS tab lists
# them as available for manual install.
if not load_config().get("auto_install_addons", True):
self._addons_verify()
return
ap = addons_path(out)
recs = [{"folder": name, "status": "available", "git": url,
"branch": None, "ref": None, "toc": {},
"description": None, "error": None}
for name, url in RECOMMENDED_ADDONS.items()
if not os.path.isdir(os.path.join(ap, name))]
if not recs:
# Nothing to install (e.g. switched to a folder that already has
# the addons) — still run a verify so the ADDONS tab badge shows
# any available updates without the user opening the tab.
self._addons_verify()
return
self._log_line("\nInstalling recommended addons...\n", "acct")
self._addon_apply(recs)
def _maybe_install_essential_mods(self):
"""Auto-install every mod flagged essential the first time this game
folder is ready to use — i.e. on a brand-new install, or right after
the game folder was changed to a new location. Both cases wipe the
"mods" key from config, which is what this checks for, so once this
has run (successfully or not — failures are recorded per-mod) it
won't fire again until the folder changes.
Reuses the normal apply worker so failures land in config exactly
like a manual Apply would (per-mod "error" field, mod left disabled,
installed_version cached the same way)."""
if self._default_mods_install_started:
return
cfg = load_config()
if cfg.get("mods"):
return # already initialized for this folder
out = self._game_path.get().strip()
if not out or not os.path.exists(os.path.join(out, "WoW.exe")):
return # game isn't actually installed here yet
self._default_mods_install_started = True
# "Install essential mods" (Settings → General) gates the
# full essential set. VanillaFixes is exempt — it's the loader the other
# mods depend on, so it's always auto-installed.
auto_mods = cfg.get("auto_install_mods", True)
for mod in MODS_REGISTRY:
if mod.get("essential", False) and (auto_mods
or mod["id"] == "VanillaFixes"):
self._mod_pending_state.setdefault(mod["id"], {})["enabled"] = True
self._log_line("\nInstalling essential mods...\n", "acct")
self._set_btn_busy("Installing…")
self._status_var.set("Downloading mods…")
threading.Thread(target=self._apply_mods_worker,
args=(out,), daemon=True).start()
def _apply_mods_worker(self, client_dir: str, only_mod_id: str | None = None):
# Work on a detached copy of the "mods" section; it's merged back into
# the live config atomically at the end (update_config).
mods_cfg = load_config().get("mods", {})
# Registry order is install order (VanillaFixes first).
ordered = MODS_REGISTRY
pending = self._mod_pending_state
# Arm the one-time "DXVK first launch" notice if DXVK gets (re)installed
# this run — the new d3d9.dll invalidates the shader cache.
set_dxvk_notice = False
for mod in ordered:
mid = mod["id"]
if only_mod_id is not None and mid != only_mod_id:
continue
state = mods_cfg.get(mid, {})
# Read enabled/ignore from pending UI changes first, fall back to
# saved config. No registry-default fallback here: a mod is only
# ever "enabled" because the user (or the one-time default-mods
# seed in _maybe_install_essential_mods) explicitly said so.
enabled = pending.get(mid, {}).get("enabled", state.get("enabled", False))
ignore_upd = pending.get(mid, {}).get("ignore_updates", state.get("ignore_updates", False))
# A targeted single-mod update/retry always means "install this
# mod". Without this, retrying a failed install is a no-op: the
# error handler recorded enabled=False, so needs_install stays
# False and the mod is skipped (only its error gets cleared).
if only_mod_id is not None and mid == only_mod_id:
enabled = True
installed_ver = state.get("installed_version")
is_installed = (bool(installed_ver) and
mod_installed_files_present(mod, client_dir))
needs_install = enabled and not is_installed
needs_uninstall = not enabled and is_installed
needs_version_lookup = needs_install or (enabled and is_installed and not ignore_upd)
latest_ver = None
mod_release = None
if needs_version_lookup:
try:
if mod["source"]["kind"] in ("github_release", "codeberg_release"):
mod_release = _fetch_release_cached(mod)
latest_ver = _release_version(mod, mod_release) if mod_release else None
else:
latest_ver = fetch_mod_latest_version_cached(mod)
except Exception:
pass
update_avail = (is_installed and
latest_ver is not None and
latest_ver != installed_ver and
not ignore_upd)
needs_update = enabled and update_avail
if not (needs_install or needs_uninstall or needs_update):
if mid in pending:
mods_cfg.setdefault(mid, {}).update({
"enabled": enabled,
"ignore_updates": ignore_upd,
})
# A previously failed mod that the user leaves disabled on a
# later Apply counts as dismissed — clear the error so it
# stops blocking the PLAY button.
if not enabled and state.get("error"):
mods_cfg.setdefault(mid, {})["error"] = None
continue
action = ("Installing" if needs_install else
"Updating" if needs_update else "Removing")
self.after(0, lambda a=action, n=mod["name"]:
self._status_var.set(f"{a} {n}…"))
try:
if needs_install:
log(f"\nInstalling {mod['name']} {latest_ver}...")
written = install_mod(mod, client_dir, release=mod_release)
if mod.get("register_dll"):
add_dll(client_dir, mod["register_dll"])
resolved_ver = mod.pop("_resolved_version", None) or latest_ver or "unknown"
mods_cfg[mid] = {
"enabled": True,
"installed_version": resolved_ver,
"installed_files": written,
"ignore_updates": ignore_upd,
"error": None,
}
if mid == "dxvk":
set_dxvk_notice = True
log(f" \u2713 {mod['name']} installed.")
elif needs_uninstall:
log(f"\nUninstalling {mod['name']}...")
uninstall_mod(mod, client_dir)
if mod.get("register_dll"):
remove_dll(client_dir, mod["register_dll"])
mods_cfg[mid] = {
"enabled": False,
"installed_version": None,
"installed_files": [],
"ignore_updates": ignore_upd,
"error": None,
}
log(f" \u2713 {mod['name']} uninstalled.")
elif needs_update:
log(f"\nUpdating {mod['name']} {installed_ver} \u2192 {latest_ver}...")
uninstall_mod(mod, client_dir)
written = install_mod(mod, client_dir, release=mod_release)
if mod.get("register_dll"):
add_dll(client_dir, mod["register_dll"])
mods_cfg[mid] = {
"enabled": True,
"installed_version": latest_ver,
"installed_files": written,
"ignore_updates": ignore_upd,
"error": None,
}
if mid == "dxvk":
set_dxvk_notice = True
log(f" \u2713 {mod['name']} updated.")
except Exception as e:
err = describe_install_error(e)
log(f" \u2717 {mod['name']}: {err}")
mods_cfg[mid] = {
"enabled": False,
"installed_version": None,
"installed_files": [],
"ignore_updates": ignore_upd,
"error": err,
}
# Merge just the "mods" key into the current on-disk config (which
# other threads may have written to during this long install), rather
# than saving our stale whole-config snapshot.
sorted_mods = dict(sorted(mods_cfg.items(),
key=lambda kv: kv[0].lower()))
def _merge(c):
c["mods"] = sorted_mods
if set_dxvk_notice:
c["dxvk_notice_pending"] = True
fresh_cfg = update_config(_merge)
fresh_mods = fresh_cfg.get("mods", {})
# Keep pending checkbox toggles on a targeted single-mod update —
# they were never applied and would be lost otherwise.
if only_mod_id is None:
self._mod_pending_state = {}
def _do_inplace_update():
for mod in MODS_REGISTRY:
mid = mod["id"]
state = fresh_mods.get(mid, {})
refs = self._mod_row_vars.get(mid, {})
if not refs:
continue
has_error = bool(state.get("error"))
installed = bool(state.get("installed_version"))
if mid not in self._mod_pending_state:
refs["enabled"].set(state.get("enabled", False))
refs["ignore"].set(state.get("ignore_updates", False))
live = next((m for m in self._mods_state if m["id"] == mid), {})
ver = state.get("installed_version") or live.get("latest_version") or "unknown"
if "ver_label" in refs:
refs["ver_label"].configure(text=f" {ver}")
if "name_label" in refs:
refs["name_label"].configure(
fg=C_ERR if has_error else (C_MOD_HL if installed else C_TEXT))
if "desc_label" in refs:
refs["desc_label"].configure(
fg=C_TEXT if state.get("enabled", False) else C_TEXT_DIM)
if "error_label" in refs:
if has_error:
refs["error_label"].configure(
text=f" \u26a0 {state['error']}")
refs["error_label"].pack(fill="x", pady=(0, self._px(4)))
else:
refs["error_label"].pack_forget()
if "update_label" in refs:
self._style_mod_action_label(refs["update_label"],
mod, state, live)
self._apply_btn.configure(text="Apply", bg=C_PANEL_BDR, fg=C_TEXT)
self._refresh_apply_btn_visibility()
self._refresh_mods_badge()
# Fresh setup chain: once the default mods finished installing,
# the recommended addons follow (no-op if already initialized).
self._maybe_install_default_addons()
# Any mod in an error state (download blocked, API limit, AV
# deleted the archive, …) — bring the MODS tab up so the error
# is visible. PLAY stays disabled via _refresh_ready_state.
if any(bool(s.get("error")) for s in fresh_mods.values()):
self._switch_tab("MODS")
self._refresh_ready_state()
self.after(0, _do_inplace_update)
# ── addons panel ─────────────────────────────────────────────────────────
def _build_addons_panel(self):
outer = tk.Frame(self, bg=C_PANEL,
highlightthickness=1,
highlightbackground=C_PANEL_BDR,
highlightcolor=C_PANEL_BDR)
self._addons_panel_frame = outer
top = tk.Frame(outer, bg=C_PANEL)
top.pack(fill="x", padx=self._px(16), pady=(self._px(4), 0))
self._addon_filter_var = tk.StringVar()
self._addon_filter_job = None
self._addon_filter_var.trace_add(
"write", self._on_addon_filter_changed)
ent = tk.Entry(top, textvariable=self._addon_filter_var,
bg="#2b2244", fg=C_TEXT, insertbackground=C_GOLD,
relief="flat", font=("Segoe UI", 10), width=24,
highlightthickness=1,
highlightbackground="#4a3c6e",
highlightcolor=C_GOLD)
tk.Label(top, text="⌕", font=("Segoe UI", 18),
fg=C_TEXT, bg=C_PANEL).pack(side="right")
ent.pack(side="right", ipady=self._px(4), padx=(0, self._px(6)))
legend = tk.Frame(top, bg=C_PANEL)
legend.pack(side="left")
tk.Label(legend, text="Addons marked with ",
font=("Segoe UI", 10), fg=C_TEXT_DIM,
bg=C_PANEL).pack(side="left")
tk.Label(legend, text="★", font=("Segoe UI", 10),
fg=C_GOLD, bg=C_PANEL).pack(side="left")
tk.Label(legend, text=" are recommended",
font=("Segoe UI", 10), fg=C_TEXT_DIM,
bg=C_PANEL).pack(side="left")
list_frame = tk.Frame(outer, bg=C_PANEL)
list_frame.pack(fill="both", expand=True, padx=(self._px(16), self._px(4)))
canvas = tk.Canvas(list_frame, bg=C_PANEL, highlightthickness=0)
sb = SlimScrollbar(list_frame, command=canvas.yview, bg=C_PANEL,
width=self._px(10))
self._wheel_canvases.append(canvas)
self._addons_canvas = canvas
self._addons_win = None
canvas.bind("<Configure>",
lambda e: self._addons_win is not None
and canvas.itemconfigure(self._addons_win, width=e.width))
canvas.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
self._reset_addons_inner()
tk.Frame(outer, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", padx=self._px(16), pady=(self._px(4), 0))
foot = tk.Frame(outer, bg=C_PANEL)
foot.pack(fill="x", padx=self._px(16), pady=(self._px(8), self._px(12)))
foot.columnconfigure(0, weight=1)
foot.columnconfigure(1, weight=1)
foot.columnconfigure(2, weight=1)
chk = tk.Label(foot, text="⟳ Check for updates",
font=("Segoe UI", 10), fg=C_TEXT_DIM, bg=C_PANEL,
cursor="hand2")
chk.grid(row=0, column=0, sticky="w")
chk.bind("<Button-1>", lambda e: self._addons_verify(force=True))
chk.bind("<Enter>", lambda e: chk.configure(fg=C_GOLD))
chk.bind("<Leave>", lambda e: chk.configure(fg=C_TEXT_DIM))
add = tk.Label(foot, text="+ Add custom git addon",
font=("Segoe UI", 10, "bold"), fg="#d76f9e",
bg=C_PANEL, cursor="hand2")
add.grid(row=0, column=1)
add.bind("<Button-1>", lambda e: self._open_custom_addon_dialog())
add.bind("<Enter>", lambda e: add.configure(fg="#eb96ba"))
add.bind("<Leave>", lambda e: add.configure(fg="#d76f9e"))
self._addons_right_lbl = tk.Label(foot, text="",
font=("Segoe UI", 10, "bold"),
bg=C_PANEL, cursor="hand2")
self._addons_right_lbl.grid(row=0, column=2, sticky="e")
self._addons_right_lbl.bind("<Button-1>",
lambda e: self._addon_update_all())
self._render_addons()
# ── MPQ panel ────────────────────────────────────────────────────────────
def _build_mpq_panel(self):
outer = tk.Frame(self, bg=C_PANEL,
highlightthickness=1,
highlightbackground=C_PANEL_BDR,
highlightcolor=C_PANEL_BDR)
self._mpq_panel_frame = outer
list_frame = tk.Frame(outer, bg=C_PANEL)
list_frame.pack(fill="both", expand=True,
padx=(self._px(16), self._px(4)),
pady=(self._px(8), self._px(8)))
canvas = tk.Canvas(list_frame, bg=C_PANEL, highlightthickness=0)
sb = SlimScrollbar(list_frame, command=canvas.yview, bg=C_PANEL,
width=self._px(10))
self._wheel_canvases.append(canvas)
self._mpq_canvas = canvas
self._mpq_win = None
canvas.bind("<Configure>",
lambda e: self._mpq_win is not None
and canvas.itemconfigure(self._mpq_win, width=e.width))
canvas.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
self._mpq_sections_open = {"INSTALLED": True, "AVAILABLE": True}
self._mpq_installed_rows = []
self._mpq_available_rows = list(MPQ_PATCHES)
self._reset_mpq_inner()
self._render_mpq()
def _reset_mpq_inner(self):
cv = self._mpq_canvas
old = getattr(self, "_mpq_inner", None)
if old is not None:
old.destroy()
inner = tk.Frame(cv, bg=C_PANEL)
inner.bind("<Configure>",
lambda e: cv.configure(scrollregion=cv.bbox("all")))
if self._mpq_win is None:
self._mpq_win = cv.create_window((0, 0), window=inner, anchor="nw",
width=cv.winfo_width() or 1)
else:
cv.itemconfigure(self._mpq_win, window=inner)
cv.yview_moveto(0)
self._mpq_inner = inner
def _scan_mpq_patches(self) -> list:
"""Lettered patch archives (patch-<letter>.mpq, case-insensitive) present in the
game's Data folder, e.g. Patch-A.mpq / patch-b.MPQ. Sorted by letter."""
out = self._game_path.get().strip()
data_dir = os.path.join(out, "Data") if out else ""
found = []
if data_dir and os.path.isdir(data_dir):
for name in os.listdir(data_dir):
if re.fullmatch(r"patch-[a-z]\.mpq", name, re.IGNORECASE):
found.append(name)
found.sort(key=str.lower)
return found
def _mpq_check(self):
"""Scan Data/ for lettered patches; for the ones we know (registry),
compare the on-disk SHA256 to the server's to flag updates. Feeds the
INSTALLED/AVAILABLE lists and the tab badge; the sha check runs in the
background so the list appears immediately."""
installed_files = self._scan_mpq_patches()
installed_lc = {f.lower() for f in installed_files}
self._mpq_installed_rows = [
{"file": f, "entry": mpq_patch_for(f), "update": False}
for f in installed_files]
self._mpq_available_rows = [
e for e in MPQ_PATCHES if e["file"].lower() not in installed_lc]
self._render_mpq()
out = self._game_path.get().strip()
data_dir = os.path.join(out, "Data") if out else ""
known = [r for r in self._mpq_installed_rows if r["entry"]]
if not (known and data_dir):
self._mpq_updates_count = 0
self._draw_nav_tab("MPQ")
return
def worker():
results = {}
for row in known:
try:
server = fetch_mpq_sha256(row["entry"]["url"])
local = sha256_file(os.path.join(data_dir, row["file"]))
results[row["file"]] = (server != local)
except Exception:
results[row["file"]] = False
def apply():
count = 0
for row in self._mpq_installed_rows:
if row["file"] in results:
row["update"] = results[row["file"]]
count += bool(row["update"])
self._mpq_updates_count = count
self._draw_nav_tab("MPQ")
self._render_mpq()
self.after(0, apply)
threading.Thread(target=worker, daemon=True).start()
def _render_mpq(self):
if not hasattr(self, "_mpq_inner"):
return
self._reset_mpq_inner()
installed = getattr(self, "_mpq_installed_rows", [])
available = getattr(self, "_mpq_available_rows", [])
busy = getattr(self, "_mpq_busy", None)
self._mpq_section_header("INSTALLED", installed)
if self._mpq_sections_open.get("INSTALLED", True):
for row in installed:
entry = row["entry"]
name = entry["name"] if entry else row["file"]
desc = entry["description"] if entry else ""
action = ("Update", entry) if (row["update"] and entry) else None
self._mpq_row(name, row["file"], desc, action,
busy == row["file"])
self._mpq_section_header("AVAILABLE", available)
if self._mpq_sections_open.get("AVAILABLE", True):
for entry in available:
self._mpq_row(entry["name"], entry["file"],
entry["description"], ("Install", entry),
busy == entry["file"])
def _mpq_section_header(self, title: str, rows: list):
f = self._mpq_inner
is_open = self._mpq_sections_open.get(title, True)
hdr = tk.Frame(f, bg=C_PANEL)
hdr.pack(fill="x", pady=(self._px(10), self._px(2)))
arrow = tk.Label(hdr, text="▾" if is_open else "▸",
font=("Segoe UI", 14, "bold"),
fg=C_GOLD, bg=C_PANEL, cursor="hand2", width=2)
arrow.pack(side="left")
lbl = tk.Label(hdr, text=title, font=("Segoe UI", 12, "bold"),
fg=C_GOLD, bg=C_PANEL, cursor="hand2")
lbl.pack(side="left")
tk.Label(hdr, text=f" {len(rows)}", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="left")
def toggle(_e=None, t=title):
self._mpq_sections_open[t] = \
not self._mpq_sections_open.get(t, True)
self._render_mpq()
arrow.bind("<Button-1>", toggle)
lbl.bind("<Button-1>", toggle)
if is_open and not rows:
tk.Label(f, text="Nothing here.", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(anchor="w",
padx=self._px(8))
def _mpq_row(self, name, filename, description, action, busy):
"""One patch row: name (and filename), an Install/Update button or a
'Downloading…' label, and the description underneath. `action` is
(label, registry_entry) or None."""
f = self._mpq_inner
box = tk.Frame(f, bg=C_PANEL)
box.pack(fill="x", pady=self._px(4))
top = tk.Frame(box, bg=C_PANEL)
top.pack(fill="x")
tk.Label(top, text=name, font=("Segoe UI", 11, "bold"),
fg=C_TEXT, bg=C_PANEL, anchor="w").pack(side="left",
padx=self._px(8))
if filename and filename.lower() != name.lower():
tk.Label(top, text=f" {filename}", font=("Segoe UI", 9),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="left")
if busy:
tk.Label(top, text="Downloading…", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="right",
padx=self._px(8))
elif action:
label, entry = action
btn = tk.Label(top, text=label, font=("Segoe UI", 10, "bold"),
fg="#000", bg=C_GOLD, cursor="hand2",
padx=self._px(12), pady=self._px(2))
btn.pack(side="right", padx=self._px(8))
btn.bind("<Button-1>", lambda e, en=entry: self._mpq_install(en))
btn.bind("<Enter>", lambda e: btn.configure(bg=C_GOLD_LT))
btn.bind("<Leave>", lambda e: btn.configure(bg=C_GOLD))
if description:
tk.Label(box, text=description, font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL, anchor="w",
wraplength=self._px(620), justify="left").pack(
fill="x", padx=self._px(8), pady=(self._px(2), 0))
def _mpq_install(self, entry):
if getattr(self, "_mpq_busy", None):
return # one download at a time
out = self._game_path.get().strip()
if not out:
self._log_line("Set the game folder first.\n", "err")
return
data_dir = os.path.join(out, "Data")
self._mpq_busy = entry["file"]
self._render_mpq()
self._log_line(f"\nDownloading {entry['name']}…\n", "acct")
def worker():
err = None
try:
download_mpq_patch(entry, data_dir)
except Exception as e:
err = str(e)
def done():
self._mpq_busy = None
if err:
self._log_line(f"✗ {entry['name']} failed: {err}\n", "err")
else:
self._log_line(f"✓ {entry['name']} installed.\n", "ok")
self._mpq_check() # re-scan + re-check + badge
self.after(0, done)
threading.Thread(target=worker, daemon=True).start()
# ── addons engine (app side) ─────────────────────────────────────────────
def _addons_verify(self, force=False, remote_checks=True):
"""Scan Interface/AddOns, match against the catalog, and check every
tracked addon's remote commit sha (config-cached). With
remote_checks=False the scan is guaranteed network-free: shas come
from the cache only (used for post-install/update refreshes)."""
if self._addons_busy:
return
# A recent verify result is already rendered — plain tab switches
# within the TTL don't need a rescan or a rebuild at all.
if (not force and self._addons_status["state"] == "done"
and (time.time() - self._addons_verified_ts)
< ADDONS_VERIFY_TTL):
return
client = self._game_path.get().strip()
self._addons_busy = True
had_content = bool(self._addons_status["addons"]
or self._addons_status["available"])
self._addons_status = {**self._addons_status, "state": "verifying"}
if had_content:
# Keep showing the existing list while checking in background.
self._refresh_addons_footer()
else:
self._render_addons()
def worker():
try:
catalog = fetch_addons_catalog(force=force)
except Exception:
# offline — fall back to whatever the config still holds
catalog = (load_config().get("addons_catalog_cache", {})
.get("catalog") or [])
available = [{"folder": a.get("name"), "status": "available",
"git": a.get("git"), "branch": a.get("branch"),
"ref": a.get("ref"), "toc": a.get("toc") or {},
"description": a.get("description"), "error": None}
for a in catalog
if a.get("name") and a["name"] not in BLOCKED_ADDONS]
# Curated recommendations: apply git-URL overrides on top of the
# catalog, and synthesize entries for recommended addons the
# catalog doesn't carry (or has renamed). Overridden forks may
# use a different default branch, so branch/ref are reset.
by_name = {a["folder"]: a for a in available}
for name, override in RECOMMENDED_ADDONS.items():
rec = by_name.get(name)
if rec is None:
available.append(
{"folder": name, "status": "available",
"git": override, "branch": None, "ref": None,
"toc": {}, "description": None, "error": None})
elif not _same_git_repo(rec.get("git"), override):
rec.update(git=override, branch=None, ref=None)
addons = {}
records = load_config().get("addons", {})
ap = addons_path(client) if client else ""
if ap and os.path.isdir(ap):
for name in sorted(os.listdir(ap)):
if name.startswith(("Blizzard_", "Turtle_")):
continue
dirp = os.path.join(ap, name)
if not os.path.isdir(dirp):
continue
rec = {"folder": name, "status": "unknown", "git": None,
"branch": None, "ref": None, "toc": {},
"description": None, "error": None}
toc_path = os.path.join(dirp, f"{name}.toc")
if not os.path.exists(toc_path):
rec.update(status="invalid",
error="Missing .toc file")
addons[name] = rec
continue
rec["toc"] = read_toc_file(toc_path)
avail = next((a for a in available
if a["folder"] == name), None)
if avail:
rec["description"] = avail["description"]
saved = records.get(name)
override = RECOMMENDED_ADDONS.get(name)
if (saved and saved.get("git") and override
and not saved.get("custom")
and not _same_git_repo(saved["git"], override)):
# Installed from a different repo than the curated fork
# — offer an update that migrates to the fork. A
# user-added custom addon (saved["custom"]) is left as
# the user chose it, even when its name matches a
# recommended addon.
rec.update(git=override, branch=None, ref=None,
status="outOfDate")
elif saved and saved.get("git"):
rec.update(git=saved.get("git"),
branch=saved.get("branch"),
ref=saved.get("ref"),
custom=saved.get("custom", False))
if remote_checks:
remote = addon_remote_sha(
rec["git"], rec["branch"], rec["ref"],
force=force)
else:
remote = addon_cached_sha(
rec["git"], rec["branch"], rec["ref"])
if remote is None:
# no cached answer — assume current rather
# than hitting the network
remote = saved.get("sha")
if remote is None:
rec.update(status="invalid",
error="Failed to verify")
elif remote == saved.get("sha"):
rec["status"] = "upToDate"
else:
rec["status"] = "outOfDate"
elif avail:
# Known catalog addon installed outside the updater —
# offer to take it over via a fresh install.
rec.update(git=avail["git"], branch=avail["branch"],
ref=avail["ref"], status="outOfDate")
addons[name] = rec
# Overlay install failures from this session: the rescan drops
# them (a failed install leaves no folder on disk), so re-attach
# errors to the matching available row — or synthesize one for a
# failed custom addon. Errors for now-installed folders are stale
# and dropped.
for folder in [f for f in self._addon_errors if f in addons]:
self._addon_errors.pop(folder, None)
by_name = {a["folder"]: a for a in available}
for folder, info in self._addon_errors.items():
rec = by_name.get(folder)
if rec is None:
rec = {"folder": folder, "status": "available",
"git": info.get("git"), "branch": None,
"ref": None, "toc": {}, "description": None,
"error": None}
available.append(rec)
rec["error"] = info["error"]
def done():
changed = (addons != self._addons_status["addons"]
or available != self._addons_status["available"])
self._addons_status = {"state": "done", "addons": addons,
"available": available}
self._addons_verified_ts = time.time()
self._addons_busy = False
self._refresh_addons_badge()
if changed:
self._render_addons()
else:
self._refresh_addons_footer()
self.after(0, done)
threading.Thread(target=worker, daemon=True).start()
def _addon_apply(self, recs):
"""Install/update the given addon records sequentially."""
client = self._game_path.get().strip()
if not client or self._addons_busy or not recs:
return
self._addons_busy = True
self._addons_installing = True
for rec in recs:
rec["status"] = "downloading"
self._addons_status["addons"].setdefault(rec["folder"], rec)
self._render_addons()
# PLAY is inactive while addons download/install, same as for mods.
self._set_btn_busy("Installing…")
self._status_var.set("Downloading addons…")
def worker():
for rec in recs:
self.after(0, lambda n=rec["folder"]:
self._status_var.set(f"Installing {n}…"))
try:
if not rec.get("git") or not is_allowed_git_url(rec["git"]):
raise RuntimeError("Addon URL is not from an "
"allowed git host")
sha = addon_remote_sha(rec["git"], rec.get("branch"),
rec.get("ref"), raise_errors=True)
if not sha:
raise RuntimeError("Could not resolve remote commit")
install_addon_files(client, rec["folder"], rec["git"], sha)
if rec["folder"] == "pfUI":
patch_pfui_default_profile(client)
record = {"git": rec["git"], "branch": rec.get("branch"),
"ref": rec.get("ref"), "sha": sha}
if rec.get("custom"):
record["custom"] = True
update_config(lambda c, f=rec["folder"], r=record:
c.setdefault("addons", {}).__setitem__(f, r))
self._addon_errors.pop(rec["folder"], None)
log(f" ✓ Addon {rec['folder']} installed.")
except Exception as e:
err = describe_install_error(e)
log(f" ✗ Addon {rec['folder']}: {err}")
rec.update(status="invalid", error=err)
self._addon_errors[rec["folder"]] = {
"error": err, "git": rec.get("git")}
def done():
self._addons_busy = False
self._addons_installing = False
self._addons_verified_ts = 0.0 # make the re-verify run
self._refresh_ready_state() # PLAY active again
# Cache-only refresh: the install itself already resolved and
# cached the shas — no further API requests are needed.
self._addons_verify(remote_checks=False)
self.after(0, done)
threading.Thread(target=worker, daemon=True).start()
def _addon_update_all(self):
recs = [r for r in self._addons_status["addons"].values()
if r["status"] == "outOfDate"]
self._addon_apply(recs)
def _addon_remove(self, folder: str):
from tkinter import messagebox
if not messagebox.askyesno(
"Remove addon",
f"Delete {folder} and all of its files?"):
return
client = self._game_path.get().strip()
if not client or self._addons_busy:
return
try:
dirp = os.path.join(addons_path(client), folder)
if os.path.isdir(dirp):
shutil.rmtree(dirp)
update_config(lambda c: c.get("addons", {}).pop(folder, None))
self._log_line(f"Removed addon {folder}\n", "dim")
except Exception as e:
self._log_line(f"Failed to remove addon {folder}: {e}\n", "err")
self._addons_status["addons"].pop(folder, None)
self._addon_errors.pop(folder, None)
self._refresh_addons_badge()
self._render_addons()
def _open_custom_addon_dialog(self):
if self._settings_overlay is not None:
return
ov = tk.Frame(self, bg="#0a0a0e")
ov.place(x=0, y=0, width=WIN_W, height=WIN_H)
ov.bind("<Button-1>", lambda e: self._close_settings())
self._settings_overlay = ov
self.bind("<Escape>", lambda e: self._close_settings())
# Same purple-dark theme as the Settings modal.
P_BG, P_HDR, P_BDR, P_INP = C_PANEL, C_HDR, C_PANEL_BDR, "#0f0b16"
MW, MH = self._px(560), self._px(230)
panel = tk.Frame(ov, bg=P_BG, highlightthickness=1,
highlightbackground=P_BDR, highlightcolor=P_BDR)
panel.place(x=(WIN_W - MW) // 2, y=(WIN_H - MH) // 2 - self._px(20),
width=MW, height=MH)
hdr = tk.Frame(panel, bg=P_HDR, height=self._px(46))
hdr.pack(fill="x")
hdr.pack_propagate(False)
tk.Label(hdr, text="ADD CUSTOM GIT ADDON",
font=("Segoe UI", 13, "bold"),
fg=C_PURPLE, bg=P_HDR).pack(side="left", padx=self._px(18))
x_btn = tk.Label(hdr, text="✕", font=("Segoe UI", 12),
fg=C_TEXT_DIM, bg=P_HDR, cursor="hand2")
x_btn.pack(side="right", padx=self._px(16))
x_btn.bind("<Button-1>", lambda e: self._close_settings())
x_btn.bind("<Enter>", lambda e: x_btn.configure(fg=C_TEXT))
x_btn.bind("<Leave>", lambda e: x_btn.configure(fg=C_TEXT_DIM))
tk.Frame(panel, bg=P_BDR, height=self._px(1)).pack(fill="x")
body = tk.Frame(panel, bg=P_BG)
body.pack(fill="both", expand=True, padx=self._px(22),
pady=(self._px(16), self._px(12)))
tk.Label(body, text="REPOSITORY URL",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=P_BG).pack(anchor="w")
url_var = tk.StringVar()
tk.Entry(body, textvariable=url_var, bg=P_INP, fg=C_TEXT,
insertbackground=C_GOLD, relief="flat", font=FONT_MONO,
highlightthickness=1, highlightbackground=P_BDR,
highlightcolor=C_GOLD).pack(fill="x", ipady=self._px(7),
pady=(self._px(6), self._px(6)))
tk.Label(body,
text="Allowed hosts: " + ", ".join(ADDON_GIT_HOSTS),
font=("Segoe UI", 9), fg=C_TEXT_DIM, bg=P_BG).pack(anchor="w")
err = tk.Label(body, text="", font=("Segoe UI", 9),
fg=C_ERR, bg=P_BG)
err.pack(anchor="w")
def submit():
url = url_var.get().strip().rstrip("/")
if url.endswith(".git"):
url = url[:-4]
if not is_allowed_git_url(url):
err.configure(text="URL must be https from an allowed host.")
return
folder = url.rsplit("/", 1)[-1]
if not folder or folder in (".", "..") or "\\" in folder:
err.configure(text="Could not derive addon folder name.")
return
self._close_settings()
self._log_line(f"\nInstalling custom addon {folder}…\n", "acct")
self._addon_apply([{"folder": folder, "status": "available",
"git": url, "branch": None, "ref": None,
"toc": {}, "description": None,
"error": None, "custom": True}])
btn = tk.Label(body, text="Install", font=("Segoe UI", 11, "bold"),
fg=C_TEXT, bg=P_BDR, cursor="hand2",
padx=self._px(16), pady=self._px(7))
btn.pack(anchor="e", pady=(self._px(8), 0))
btn.bind("<Button-1>", lambda e: submit())
btn.bind("<Enter>", lambda e: btn.configure(bg=C_GOLD, fg="#000"))
btn.bind("<Leave>", lambda e: btn.configure(bg=P_BDR, fg=C_TEXT))
# ── addons rendering ─────────────────────────────────────────────────────
def _reset_addons_inner(self):
"""Replace the whole rows container with a fresh frame. A single
destroy() tears the old subtree down inside Tk (C code) — far faster
than destroying hundreds of row widgets one by one from Python."""
cv = self._addons_canvas
old = getattr(self, "_addons_inner", None)
if old is not None:
old.destroy()
inner = tk.Frame(cv, bg=C_PANEL)
inner.bind("<Configure>",
lambda e: cv.configure(scrollregion=cv.bbox("all")))
if self._addons_win is None:
self._addons_win = cv.create_window((0, 0), window=inner,
anchor="nw",
width=cv.winfo_width() or 1)
else:
cv.itemconfigure(self._addons_win, window=inner)
cv.yview_moveto(0)
self._addons_inner = inner
def _on_addon_filter_changed(self, *_args):
"""Debounce search input — re-render once typing pauses, not on
every keystroke."""
if self._addon_filter_job is not None:
self.after_cancel(self._addon_filter_job)
self._addon_filter_job = self.after(250, self._apply_addon_filter)
def _apply_addon_filter(self):
self._addon_filter_job = None
self._render_addons()
def _render_addons(self):
"""Rebuild the addons list. Rows are created in small batches on the
Tk event loop so a large catalog doesn't freeze the UI."""
if not hasattr(self, "_addons_inner"):
return
self._addons_render_gen = getattr(self, "_addons_render_gen", 0) + 1
gen = self._addons_render_gen
self._reset_addons_inner()
st = self._addons_status
flt = self._addon_filter_var.get().strip().lower()
flt_compact = flt.replace(" ", "")
def matches(rec):
if not flt:
return True
title = strip_wow_colors((rec.get("toc") or {}).get("Title") or "")
hay = f"{rec['folder']} {title}".lower()
# Space-insensitive both ways: "sell value" finds SellValue,
# "sellvalue" finds "Sell Value".
return flt in hay or flt_compact in hay.replace(" ", "")
def keep(lst):
lst = sorted(lst, key=lambda r: r["folder"].lower())
return [r for r in lst if matches(r)]
installed = keep(st["addons"].values())
# Recommended addons are mixed into Available and marked with a ★
# badge. Sort recommended first so they surface at the top of the list.
available = keep(a for a in st["available"]
if a["folder"] not in st["addons"])
available.sort(key=lambda a: (a["folder"] not in RECOMMENDED_ADDONS,
a["folder"].lower()))
work = []
for title, rows in (("INSTALLED", installed),
("AVAILABLE", available)):
work.append(("header", title, rows))
if self._addon_sections_open.get(title, True):
work.extend(("row", rec) for rec in rows)
self._addons_build_queue = work
self._refresh_addons_footer()
self._addons_build_step(gen)
def _addons_build_step(self, gen: int):
"""Create up to one batch of queued headers/rows, then yield to the
event loop; abandons the queue if a newer render has started."""
if gen != self._addons_render_gen:
return
queue = self._addons_build_queue
built = 0
while queue and built < 14:
item = queue.pop(0)
if item[0] == "header":
self._addon_section_header(item[1], item[2])
else:
self._addon_row(item[1])
built += 1
if queue:
self.after(1, lambda: self._addons_build_step(gen))
def _addon_section_header(self, title: str, rows: list):
f = self._addons_inner
is_open = self._addon_sections_open.get(title, True)
hdr = tk.Frame(f, bg=C_PANEL)
hdr.pack(fill="x", pady=(self._px(10), self._px(2)))
arrow = tk.Label(hdr, text="▾" if is_open else "▸",
font=("Segoe UI", 14, "bold"),
fg=C_GOLD, bg=C_PANEL, cursor="hand2", width=2)
arrow.pack(side="left")
lbl = tk.Label(hdr, text=title,
font=("Segoe UI", 12, "bold"),
fg=C_GOLD, bg=C_PANEL, cursor="hand2")
lbl.pack(side="left")
tk.Label(hdr, text=f" {len(rows)}", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="left")
def toggle(_e=None, t=title):
self._addon_sections_open[t] = \
not self._addon_sections_open.get(t, True)
self._render_addons()
arrow.bind("<Button-1>", toggle)
lbl.bind("<Button-1>", toggle)
if is_open and not rows:
msg = ("Verifying…" if self._addons_status["state"] == "verifying"
else "Nothing here.")
tk.Label(f, text=msg, font=("Segoe UI", 10), fg=C_TEXT_DIM,
bg=C_PANEL).pack(anchor="w", padx=self._px(8))
def _addon_row(self, rec: dict):
f = self._addons_inner
installed = rec["folder"] in self._addons_status["addons"]
toc = rec.get("toc") or {}
warnings = []
if toc.get("Interface") and toc["Interface"] != "11200":
warnings.append(f"Made for client {toc['Interface']}")
# pfUI bundles its own modules, so its .toc dependencies aren't real
# missing addons — never warn about them.
if installed and rec["folder"] != "pfUI":
deps = [d.strip() for d in
(toc.get("Dependencies") or "").replace(";", ",").split(",")
if d.strip()]
missing = [d for d in deps
if d not in self._addons_status["addons"]]
if missing:
warnings.append("Missing deps: " + ", ".join(missing))
row = tk.Frame(f, bg=C_PANEL)
row.pack(fill="x", pady=self._px(3))
# right side first so it stays pinned to the edge
if installed:
# Trash can drawn as canvas shapes (handle, lid, tapered body
# with slats) — same fixed-size approach as the download arrow.
rm = tk.Canvas(row, width=self._px(20), height=self._px(18),
bg=C_PANEL, highlightthickness=0, cursor="hand2")
rm.pack(side="right", padx=(self._px(8), self._px(2)))
rm.create_rectangle(*self._px(8, 2, 12, 4), fill="#8a4a4a",
outline="", tags="trash")
rm.create_rectangle(*self._px(4, 4, 16, 6), fill="#8a4a4a",
outline="", tags="trash")
rm.create_polygon(*self._px(5, 8, 15, 8, 14, 16, 6, 16),
fill="#8a4a4a", outline="", tags="trash")
for x in (8, 10, 12):
rm.create_line(*self._px(x, 10, x, 14), fill=C_PANEL)
rm.bind("<Button-1>",
lambda e, n=rec["folder"]: self._addon_remove(n))
rm.bind("<Enter>", lambda e, c=rm:
c.itemconfigure("trash", fill=C_ERR))
rm.bind("<Leave>", lambda e, c=rm:
c.itemconfigure("trash", fill="#8a4a4a"))
else:
# Download arrow drawn as a polygon — exact size and centering,
# independent of any font, without inflating the row height.
dl = tk.Canvas(row, width=self._px(20), height=self._px(18),
bg=C_PANEL, highlightthickness=0, cursor="hand2")
dl.pack(side="right", padx=(self._px(8), self._px(2)))
dl_item = dl.create_polygon(
*self._px(8, 3, 12, 3, 12, 9, 16, 9, 10, 15, 4, 9, 8, 9),
fill=C_OK, outline="")
dl.bind("<Button-1>",
lambda e, r=rec: self._addon_apply([dict(r)]))
dl.bind("<Enter>", lambda e, c=dl, i=dl_item:
c.itemconfigure(i, fill="#8fdf8e"))
dl.bind("<Leave>", lambda e, c=dl, i=dl_item:
c.itemconfigure(i, fill=C_OK))
# Repo link on the right (like the Mods tab), between the status text
# and the install/remove icon.
if rec.get("git"):
repo_url = rec["git"][:-4] if rec["git"].endswith(".git") \
else rec["git"]
lnk = tk.Label(row, text="⧉", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL, cursor="hand2")
lnk.pack(side="right", padx=(self._px(4), self._px(2)))
lnk.bind("<Button-1>", lambda e, u=repo_url: self._open_url(u))
lnk.bind("<Enter>", lambda e, w=lnk: w.configure(fg=C_GOLD))
lnk.bind("<Leave>", lambda e, w=lnk: w.configure(fg=C_TEXT_DIM))
status = rec["status"]
if status == "downloading":
tk.Label(row, text="downloading…", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="right", padx=self._px(4))
elif status == "invalid" or rec.get("error"):
# Short marker on the right; the full reason gets its own line
# under the row (long messages would squeeze the description).
tk.Label(row, text="⛔ Addon error", font=("Segoe UI", 10),
fg=C_ERR, bg=C_PANEL).pack(side="right", padx=self._px(4))
elif status == "outOfDate" and installed:
upd = tk.Label(row, text="Update", font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=C_PANEL, cursor="hand2")
upd.pack(side="right", padx=self._px(4))
upd.bind("<Button-1>",
lambda e, r=rec: self._addon_apply([r]))
upd.bind("<Enter>", lambda e, w=upd: w.configure(fg=C_GOLD_LT))
upd.bind("<Leave>", lambda e, w=upd: w.configure(fg=C_GOLD))
elif warnings:
tk.Label(row, text=f"⚠ {warnings[0]}", font=("Segoe UI", 10),
fg="#d4b43c", bg=C_PANEL).pack(side="right", padx=self._px(4))
elif status == "upToDate":
tk.Label(row, text="Up to date", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="right", padx=self._px(4))
elif status == "unknown":
tk.Label(row, text="Not versioned", font=("Segoe UI", 10),
fg=C_TEXT_DIM, bg=C_PANEL).pack(side="right", padx=self._px(4))
# name (WoW colour codes honoured) + repo link
name_f = tk.Frame(row, bg=C_PANEL, width=self._px(250))
name_f.pack(side="left", fill="y")
name_f.pack_propagate(False)
# Gold ★ badge for recommended addons; fixed-width slot keeps the
# titles aligned whether or not the star is present.
is_recommended = rec["folder"] in RECOMMENDED_ADDONS
star = tk.Label(name_f, text="★" if is_recommended else "",
font=("Segoe UI", 9), fg=C_GOLD, bg=C_PANEL,
width=2, anchor="w")
star.pack(side="left")
if is_recommended:
self._add_tooltip(star, "Recommended addon")
title = toc.get("Title") or rec["folder"]
for seg, col in parse_wow_colored(title)[:6]:
tk.Label(name_f, text=seg, font=("Segoe UI", 10, "bold"),
fg=col or C_TEXT, bg=C_PANEL).pack(side="left")
desc = strip_wow_colors(toc.get("Notes")
or rec.get("description") or "")
tk.Label(row, text=desc, font=("Segoe UI", 10), fg=C_TEXT_DIM,
bg=C_PANEL, wraplength=self._px(430), justify="left",
anchor="w").pack(side="left", fill="x", expand=True)
if rec.get("error"):
tk.Label(f, text=f" ⚠ {rec['error']}",
font=("Segoe UI", 9), fg=C_ERR, bg=C_PANEL,
wraplength=self._px(840), justify="left",
anchor="w").pack(fill="x", pady=(0, self._px(3)))
tk.Frame(f, bg=C_DIVIDER, height=self._px(1)).pack(
fill="x", pady=(self._px(3), 0))
def _refresh_addons_badge(self):
count = sum(1 for r in self._addons_status["addons"].values()
if r["status"] == "outOfDate")
if count != self._addon_updates_count:
self._addon_updates_count = count
self._draw_nav_tab("ADDONS")
def _refresh_addons_footer(self):
lbl = self._addons_right_lbl
st = self._addons_status
if st["state"] == "verifying" or self._addons_busy:
lbl.configure(text="Checking…", fg=C_TEXT_DIM, cursor="arrow")
elif any(r["status"] == "outOfDate"
for r in st["addons"].values()):
lbl.configure(text="Update all", fg=C_OK, cursor="hand2")
else:
lbl.configure(text="Everything up to date", fg=C_TEXT_DIM,
cursor="arrow")
# ── footer ────────────────────────────────────────────────────────────────
def _build_footer(self):
foot = tk.Frame(self, bg=C_BG, height=FOOT_H)
foot.place(x=0, y=WIN_H - FOOT_H, width=WIN_W, height=FOOT_H)
# Bottom-left column: status message on top, PLAY/UPDATE button in
# the middle, client version at the bottom (with a bottom margin so
# the content doesn't sit flush against the window edge).
left = tk.Frame(foot, bg=C_BG)
left.place(x=self._px(40), y=self._px(6))
self._status_var = tk.StringVar(value="Ready to update")
tk.Label(left, textvariable=self._status_var,
font=("Segoe UI", 10, "bold"),
fg=C_TEXT, bg=C_BG, width=26, anchor="w").pack(anchor="w")
# Thin halo frame around the button gives a soft glow that follows
# the button state (gold for UPDATE, green for PLAY).
self._btn_mode = "update"
self._btn_glow = tk.Frame(left, bg="#4a3812")
self._btn_glow.pack(anchor="w", pady=(self._px(6), self._px(6)))
self._upd_btn = tk.Label(self._btn_glow, text="UPDATE",
font=("Segoe UI", 11, "bold"),
fg="#ffffff", bg=C_GOLD,
cursor="hand2",
width=14, pady=self._px(7),
anchor="center")
self._upd_btn.pack(padx=self._px(3), pady=self._px(3))
self._upd_btn.bind("<Button-1>", lambda e: self._btn_click())
self._upd_btn.bind("<Enter>", lambda e: self._btn_hover(True))
self._upd_btn.bind("<Leave>", lambda e: self._btn_hover(False))
self._client_ver_var = tk.StringVar(value="")
tk.Label(left, textvariable=self._client_ver_var,
font=FONT_VER, fg=C_TEXT_DIM, bg=C_BG).pack(
anchor="w", pady=(0, self._px(36)))
pb_frame = tk.Frame(foot, bg=C_BG)
pb_frame.place(x=self._px(250), y=0,
width=WIN_W - self._px(250) - self._px(40), height=FOOT_H)
self._pb_canvas = tk.Canvas(pb_frame,
height=self._px(6), bg=C_BG,
highlightthickness=0)
self._pb_canvas.pack(fill="x", side="bottom", padx=0,
ipady=0, pady=(0, self._px(56)))
self._pb_width = WIN_W - self._px(250) - self._px(40)
self._pb_val = 0.0
self._prog_label_var = tk.StringVar(value="")
tk.Label(pb_frame, textvariable=self._prog_label_var,
font=("Segoe UI", 10), fg=C_TEXT, bg=C_BG).pack(
side="bottom", pady=(0, self._px(6)))
tk.Label(foot, text=f"v{UPDATER_VERSION}",
font=("Courier New", 8),
fg="#555560", bg=C_BG).place(relx=1.0, rely=1.0,
x=self._px(-10), y=self._px(-6),
anchor="se")
# Align the progress bar's bottom edge exactly with the PLAY/UPDATE
# button's bottom edge once real geometry is known.
def _align_pb():
self.update_idletasks()
gap = (foot.winfo_rooty() + FOOT_H) - (
self._btn_glow.winfo_rooty() + self._btn_glow.winfo_height())
if gap > 0:
self._pb_canvas.pack_configure(pady=(0, gap))
self.after(60, _align_pb)
def _draw_progress(self, value: float):
self._pb_val = max(0.0, min(1.0, value))
c = self._pb_canvas
w = self._pb_width
c.delete("all")
if not self._running:
return
c.create_rectangle(0, 0, w, self._px(6), fill="#1e1e26", outline="")
filled = int(w * self._pb_val)
if filled > 0:
for x in range(filled):
t = x / max(filled - 1, 1)
r_val = int(0xc8 + t * (0xe8 - 0xc8))
g_val = int(0x92 + t * (0xb8 - 0x92))
b_val = int(0x2a + t * (0x4b - 0x2a))
col = f"#{r_val:02x}{g_val:02x}{b_val:02x}"
c.create_line(x, 0, x, self._px(6), fill=col)
# ── helpers ───────────────────────────────────────────────────────────────
def _game_folder_changed(self, new_val: str):
"""Wipe all state tied to the previous game folder — the folder-scoped
config (mods/addons install records) and session TTLs/badges — and
point everything at new_val. Called from _close_settings when the user
has switched to a different folder."""
if os.path.exists(os.path.join(new_val, "WoW.exe")):
remove_wdb(new_val)
# Wipe folder-scoped config and set the new path in one atomic merge.
# This also re-arms the default-mods / recommended-addons auto-install.
def _wipe(c):
c["out_dir"] = new_val
for k in ("mods", "addons"):
c.pop(k, None)
self._cfg = update_config(_wipe)
self._mod_pending_state = {}
self._default_mods_install_started = False
self._default_addons_install_started = False
self._client_ready = False
# Reset session-level state so nothing from the previous folder is
# served from memory: addons verify + rendered list, news timers, badges.
self._addons_verified_ts = 0.0
self._addons_status = {"state": "idle", "addons": {}, "available": []}
self._addon_errors = {}
self._feat_ts = 0.0
self._patch_ts = 0.0
self._mod_updates_count = 0
self._addon_updates_count = 0
self._mpq_updates_count = 0
self._mpq_installed_rows = []
self._mpq_available_rows = list(MPQ_PATCHES)
self._draw_nav_tab("MODS")
self._draw_nav_tab("ADDONS")
self._draw_nav_tab("MPQ")
self._render_addons()
self._render_mpq()
self._log_line("\nGame folder changed — state reset.\n", "acct")
def _set_pending_reconcile(self, mode):
"""Set the pending-reconcile mode (None / 'full' / 'keep-config') and
persist it, so a reconcile offered but not applied before quitting is
still pending next launch."""
self._pending_reconcile = mode or None
self._cfg = update_config(
lambda c: c.__setitem__("pending_reconcile", self._pending_reconcile))
def _offer_reconcile(self):
"""Surface the Update button for the pending reconcile (mode already
set) WITHOUT running a verify first. Clicking Update then reconciles:
an integrity check + WoW.exe re-patch (+ a fresh Config.wtf when the
mode is 'full') — meaningful even when the game files are already
intact."""
self._running = False
self._client_ready = False
self._status_var.set("Update available!")
self._set_btn_update()
def _prompt_av_exclusion(self):
"""Ask whether to add the current game folder to Windows Defender
exclusions (some mods can be mistakenly flagged by antivirus)."""
from tkinter import messagebox
if messagebox.askyesno(
"Game folder changed",
"It is highly recommended to add the game folder to your "
"antivirus exclusions. Antivirus software may incorrectly "
"detect some mods as threats and prevent them from being "
"downloaded or installed properly.\n\n"
"Do you want to add the game folder to Defender exclusions?",
parent=self):
self._allow_through_antivirus()
def _render_log(self, msg: str, tag: str = ""):
"""Normalize a raw log message (ensure a trailing newline, auto-tag
when untagged) and append it to the log panel. Main thread only."""
line = msg if msg.endswith("\n") else msg + "\n"
if not tag:
ml = line.lower()
if "✓" in line or "success" in ml or "complete" in ml or "up to date" in ml:
tag = "ok"
elif "✗" in line or "error" in ml or "fail" in ml or "mismatch" in ml:
tag = "err"
elif line.strip().startswith("["):
tag = "acct"
self._log_line(line, tag)
def _log_line(self, text: str, tag: str = ""):
self._log_buffer.append((text, tag))
txt = self._logwin_text
if txt is not None:
try:
txt.configure(state="normal")
if tag:
txt.insert("end", text, tag)
else:
txt.insert("end", text)
txt.see("end")
txt.configure(state="disabled")
except tk.TclError:
self._logwin_text = None
# ── settings ─────────────────────────────────────────────────────────────
def _open_settings(self, event=None):
if self._settings_overlay is not None:
return
ov = tk.Frame(self, bg="#0a0a0e")
ov.place(x=0, y=0, width=WIN_W, height=WIN_H)
ov.bind("<Button-1>", lambda e: self._close_settings())
self._settings_overlay = ov
self.bind("<Escape>", lambda e: self._close_settings())
P_BG, P_HDR, P_BDR, P_INP = C_PANEL, C_HDR, C_PANEL_BDR, "#0f0b16"
MW, MH = self._px(800), self._px(500)
panel = tk.Frame(ov, bg=P_BG, highlightthickness=1,
highlightbackground=P_BDR, highlightcolor=P_BDR)
panel.place(x=(WIN_W - MW) // 2, y=(WIN_H - MH) // 2 - self._px(20),
width=MW, height=MH)
hdr = tk.Frame(panel, bg=P_HDR, height=self._px(46))
hdr.pack(fill="x")
hdr.pack_propagate(False)
tk.Label(hdr, text="SETTINGS", font=("Segoe UI", 13, "bold"),
fg=C_PURPLE, bg=P_HDR).pack(side="left", padx=self._px(18))
x_btn = tk.Label(hdr, text="✕", font=("Segoe UI", 12),
fg=C_TEXT_DIM, bg=P_HDR, cursor="hand2")
x_btn.pack(side="right", padx=self._px(16))
x_btn.bind("<Button-1>", lambda e: self._close_settings())
x_btn.bind("<Enter>", lambda e: x_btn.configure(fg=C_TEXT))
x_btn.bind("<Leave>", lambda e: x_btn.configure(fg=C_TEXT_DIM))
tk.Frame(panel, bg=P_BDR, height=self._px(1)).pack(fill="x")
PADX = self._px(22)
body = tk.Frame(panel, bg=P_BG)
body.pack(fill="both", expand=True, padx=PADX,
pady=(self._px(16), self._px(12)))
loc_row = tk.Frame(body, bg=P_BG)
loc_row.pack(fill="x")
tk.Label(loc_row, text="GAME FOLDER",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=P_BG).pack(side="left")
opn = tk.Label(loc_row, text="Open folder", font=FONT_BODY,
fg=C_TEXT_DIM, bg=P_BG, cursor="hand2")
opn.pack(side="left", padx=(self._px(16), 0))
opn.bind("<Button-1>", lambda e: self._open_client_folder())
opn.bind("<Enter>", lambda e: opn.configure(fg=C_GOLD))
opn.bind("<Leave>", lambda e: opn.configure(fg=C_TEXT_DIM))
# Same StringVar as the Update tab's Game folder entry — changing it
# here fires the exact same folder-change mechanics immediately.
path_row = tk.Frame(body, bg=P_BG)
path_row.pack(fill="x", pady=(self._px(8), 0))
ent = tk.Entry(path_row, textvariable=self._game_path,
bg=P_INP, fg=C_TEXT, relief="flat", font=FONT_MONO,
state="readonly", readonlybackground=P_INP,
highlightthickness=1, highlightbackground=P_BDR,
highlightcolor=P_BDR)
ent.pack(side="left", fill="x", expand=True, ipady=self._px(7))
chg = tk.Label(path_row, text="Change",
font=("Segoe UI", 10, "bold"),
fg=C_TEXT, bg=P_BDR, cursor="hand2",
padx=self._px(16), pady=self._px(7))
chg.pack(side="left", padx=(self._px(8), 0))
chg.bind("<Button-1>", lambda e: self._settings_change_dir())
chg.bind("<Enter>", lambda e: chg.configure(bg=C_GOLD, fg="#000"))
chg.bind("<Leave>", lambda e: chg.configure(bg=P_BDR, fg=C_TEXT))
# Two equal-width columns via grid, so the right column keeps a fixed
# position and reaches toward the right edge — regardless of how wide
# the left column's text is.
cols = tk.Frame(body, bg=P_BG)
cols.pack(fill="x", pady=(self._px(20), 0))
cols.columnconfigure(0, weight=3, uniform="s")
cols.columnconfigure(1, weight=2, uniform="s")
lcol = tk.Frame(cols, bg=P_BG)
lcol.grid(row=0, column=0, sticky="nw")
rcol = tk.Frame(cols, bg=P_BG)
rcol.grid(row=0, column=1, sticky="nw")
tk.Label(lcol, text="DOWNLOAD MIRROR",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=P_BG).pack(anchor="w", pady=(0, self._px(4)))
mir = tk.Frame(lcol, bg=P_BG)
mir.pack(anchor="w")
tk.Label(mir, text="●", font=("Segoe UI", 9),
fg=C_OK, bg=P_BG).pack(side="left")
tk.Label(mir, text=" Iceland", font=("Segoe UI", 10, "bold"),
fg=C_TEXT, bg=P_BG).pack(side="left")
self._mirror_status_lbl = tk.Label(mir, text="checking…",
font=("Segoe UI", 9),
fg=C_TEXT_DIM, bg=P_BG)
self._mirror_status_lbl.pack(side="left", padx=(self._px(8), 0))
rf = tk.Label(mir, text="⟳", font=("Segoe UI", 11),
fg=C_TEXT_DIM, bg=P_BG, cursor="hand2")
rf.pack(side="left", padx=(self._px(6), 0))
rf.bind("<Button-1>", lambda e: self._check_mirror_status())
rf.bind("<Enter>", lambda e: rf.configure(fg=C_GOLD))
rf.bind("<Leave>", lambda e: rf.configure(fg=C_TEXT_DIM))
self._check_mirror_status()
tk.Label(lcol, text="TROUBLESHOOTING",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=P_BG).pack(anchor="w", pady=(self._px(22), 0))
def _titem(icon, text, cmd, icon_color=C_GOLD):
r = tk.Frame(lcol, bg=P_BG, cursor="hand2")
r.pack(anchor="w", pady=(self._px(12), 0))
# Monochrome glyphs in a fixed-width slot so all icons line up
# and read at the same size (color emoji would render larger).
ic = tk.Label(r, text=icon, font=("Segoe UI Symbol", 11),
fg=icon_color, bg=P_BG, width=2, anchor="w")
ic.pack(side="left")
tl = tk.Label(r, text=text, font=("Segoe UI", 10),
fg=C_TEXT, bg=P_BG)
tl.pack(side="left")
for w in (r, ic, tl):
w.bind("<Button-1>", lambda e: cmd())
w.bind("<Enter>", lambda e: tl.configure(fg=C_GOLD))
w.bind("<Leave>", lambda e: tl.configure(fg=C_TEXT))
_titem("✓", "Verify game files", self._settings_verify)
_titem("☰", "Show logs", self._show_logs)
_titem("⛊", "Add game folder to Defender exclusions",
self._allow_through_antivirus)
tk.Label(lcol, text="SUPPORT THE DEVELOPER",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=P_BG).pack(anchor="w", pady=(self._px(22), 0))
_titem("♥", "Ko-fi",
lambda: self._open_url("https://ko-fi.com/rebased"),
icon_color="#e8615f")
_titem("☕", "Buy Me a Coffee",
lambda: self._open_url("https://buymeacoffee.com/rebased"),
icon_color="#b5854f")
tk.Label(rcol, text="GENERAL",
font=("Segoe UI", 10, "bold"),
fg=C_GOLD, bg=P_BG).pack(anchor="w")
tk.Checkbutton(rcol, text=" Clear WDB on game launch",
variable=self._clear_wdb_var,
command=self._toggle_clear_wdb,
font=("Segoe UI", 10), fg=C_TEXT, bg=P_BG,
activebackground=P_BG, activeforeground=C_TEXT,
selectcolor=P_INP, highlightthickness=0, bd=0,
cursor="hand2").pack(anchor="w", pady=(self._px(10), 0))
tk.Checkbutton(rcol, text=" Minimize Octo Updater on game launch",
variable=self._close_on_launch_var,
command=self._toggle_close_on_launch,
font=("Segoe UI", 10), fg=C_TEXT, bg=P_BG,
activebackground=P_BG, activeforeground=C_TEXT,
selectcolor=P_INP, highlightthickness=0, bd=0,
cursor="hand2").pack(anchor="w", pady=(self._px(10), 0))
cb_auto_mods = tk.Checkbutton(
rcol, text=" Install essential mods",
variable=self._auto_mods_var, command=self._toggle_auto_mods,
font=("Segoe UI", 10), fg=C_TEXT, bg=P_BG,
activebackground=P_BG, activeforeground=C_TEXT,
selectcolor=P_INP, highlightthickness=0, bd=0, cursor="hand2")
cb_auto_mods.pack(anchor="w", pady=(self._px(10), 0))
self._add_tooltip(
cb_auto_mods,
"VanillaFixes will always be installed, even when this "
"option is turned off")
tk.Checkbutton(rcol, text=" Install recommended addons",
variable=self._auto_addons_var,
command=self._toggle_auto_addons,
font=("Segoe UI", 10), fg=C_TEXT, bg=P_BG,
activebackground=P_BG, activeforeground=C_TEXT,
selectcolor=P_INP, highlightthickness=0, bd=0,
cursor="hand2").pack(anchor="w", pady=(self._px(10), 0))
cb_ignore_speech = tk.Checkbutton(
rcol, text=" Ignore speech.mpq",
variable=self._ignore_speech_var,
command=self._toggle_ignore_speech,
font=("Segoe UI", 10), fg=C_TEXT, bg=P_BG,
activebackground=P_BG, activeforeground=C_TEXT,
selectcolor=P_INP, highlightthickness=0, bd=0, cursor="hand2")
cb_ignore_speech.pack(anchor="w", pady=(self._px(10), 0))
self._add_tooltip(
cb_ignore_speech,
"Ignores verification and updates for speech.mpq, allowing custom "
"speech sounds")
def _close_settings(self):
self.unbind("<Escape>")
if self._settings_overlay is not None:
self._settings_overlay.destroy()
self._settings_overlay = None
# Closing Settings is the "Confirm" action. It needs a reconcile when
# this is the first run, or the chosen folder now differs from the saved
# one — the two cases are handled identically from here on.
new_path = os.path.normpath(self._game_path.get().strip())
saved = os.path.normpath(self._cfg.get("out_dir", ""))
folder_changed = bool(new_path) and new_path != saved
needs_reconcile = self._first_run or folder_changed
self._first_run = False
if folder_changed:
self._game_folder_changed(new_path)
if needs_reconcile:
self._set_pending_reconcile("full")
self._offer_reconcile()
# The reconcile installs mods/addons itself — drop any pending
# auto-install retriggers so they don't race it.
self._auto_mods_retrigger = self._auto_addons_retrigger = False
else:
# A settings change with no reconcile: apply an auto-install option
# the user turned on this session (idempotent — no-op if nothing
# missing).
if self._auto_mods_retrigger:
self._auto_mods_retrigger = False
self._install_missing_essential_mods()
if self._auto_addons_retrigger:
self._auto_addons_retrigger = False
self._install_missing_recommended_addons()
# Offer a Defender exclusion at each reconcile, unless the user already
# added one via Settings since the last reconcile. Reset after, so a
# later reconcile (e.g. another folder change) offers it again.
if needs_reconcile:
if not self._av_excluded:
self._prompt_av_exclusion()
self._av_excluded = False
def _open_client_folder(self):
import subprocess
path = os.path.normpath(self._game_path.get().strip())
if os.path.isdir(path):
# Explicit explorer.exe, not os.startfile: ShellExecute resolves
# extensionless paths against PATHEXT/.lnk, so a Desktop shortcut
# named like the folder (e.g. "OctoWoW.lnk") gets *executed*
# instead of the folder being opened.
open_directory(path)
self._log_line(f"Opened folder: {path}\n", "dim")
else:
self._log_line(f"Folder not found: {path}\n", "err")
def _settings_change_dir(self):
cur = self._game_path.get()
initial = cur if os.path.isdir(cur) else os.path.expanduser("~")
chosen = filedialog.askdirectory(
title="Select game client folder",
initialdir=initial, mustexist=False)
if chosen:
# normpath → backslashes; fires the folder-change reset
self._game_path.set(os.path.normpath(chosen))
def _settings_verify(self):
self._close_settings()
self._verify_game_files()
def _allow_through_antivirus(self):
"""Add a Windows Defender exclusion for the game folder (asks for
admin elevation via UAC)."""
client_dir = os.path.normpath(self._game_path.get().strip())
if not client_dir or client_dir == ".":
return
if request_defender_exclusion(client_dir):
self._av_excluded = True
self._log_line(
f"Requested Defender exclusion for: {client_dir}\n", "ok")
else:
self._log_line("Antivirus exclusion cancelled.\n", "err")
def _maybe_show_aria2_firewall_notice(self):
"""One-time heads-up, shown before the first download, that the bundled
aria2c downloader may trigger a Windows Firewall prompt — so it doesn't
look sketchy to a non-technical user. Purely informational; tracked in
the config so it appears only once."""
if self._cfg.get("aria2_firewall_notice_shown"):
return
from tkinter import messagebox
messagebox.showinfo(
"Downloading the game",
"Octo Updater downloads the game with aria2c, the same tool the "
"official launcher uses.\n\n"
"You may be asked to allow aria2c through the firewall. You can "
"safely reject the request, but download speeds may be slightly "
"lower.\n\n"
"This message is shown only once.",
parent=self)
self._cfg = update_config(
lambda c: c.__setitem__("aria2_firewall_notice_shown", True))
def _check_mirror_status(self):
lbl = self._mirror_status_lbl
lbl.configure(text="checking…", fg=C_TEXT_DIM)
def worker():
ok = False
try:
req = urllib.request.Request(
CLIENT_TORRENT_URL, headers={"User-Agent": UA})
with secure_urlopen(req, timeout=6,
allowed_hosts=ALLOWED_DOWNLOAD_HOSTS):
ok = True
except Exception:
ok = False
def upd():
try:
lbl.configure(text="online" if ok else "offline",
fg=C_OK if ok else C_ERR)
except tk.TclError:
pass
self.after(0, upd)
threading.Thread(target=worker, daemon=True).start()
def _toggle_clear_wdb(self):
val = self._clear_wdb_var.get()
self._cfg = update_config(
lambda c: c.__setitem__("clear_wdb_on_launch", val))
def _toggle_close_on_launch(self):
val = self._close_on_launch_var.get()
self._cfg = update_config(
lambda c: c.__setitem__("close_on_launch", val))
def _toggle_auto_mods(self):
val = self._auto_mods_var.get()
self._cfg = update_config(
lambda c: c.__setitem__("auto_install_mods", val))
# Install the missing essential mods only when Settings is closed;
# turning it back off cancels the pending install.
self._auto_mods_retrigger = val
def _toggle_auto_addons(self):
val = self._auto_addons_var.get()
self._cfg = update_config(
lambda c: c.__setitem__("auto_install_addons", val))
self._auto_addons_retrigger = val
def _toggle_ignore_speech(self):
self._cfg = update_config(
lambda c: c.__setitem__("ignore_speech",
self._ignore_speech_var.get()))
def _install_missing_essential_mods(self):
"""Install every essential mod not already present. Used when the user
turns 'Install essential mods' on after the fact."""
if self._running:
return
out = self._game_path.get().strip()
if not out or not os.path.exists(os.path.join(out, "WoW.exe")):
return # no client yet — the fresh-folder auto-install handles it
mods_cfg = load_config().get("mods", {})
pending = False
for mod in MODS_REGISTRY:
if not mod.get("essential", False):
continue
state = mods_cfg.get(mod["id"], {})
if (state.get("installed_version")
and mod_installed_files_present(mod, out)):
continue # already installed
self._mod_pending_state.setdefault(mod["id"], {})["enabled"] = True
pending = True
if not pending:
return
self._log_line("\nInstalling essential mods...\n", "acct")
self._set_btn_busy("Installing…")
self._status_var.set("Downloading mods…")
threading.Thread(target=self._apply_mods_worker,
args=(out,), daemon=True).start()
def _install_missing_recommended_addons(self):
"""Install every recommended addon not already present. Used when the
user turns 'Install recommended addons' on afterwards."""
if self._addons_busy:
return
out = self._game_path.get().strip()
if not out or not os.path.exists(os.path.join(out, "WoW.exe")):
return
ap = addons_path(out)
recs = [{"folder": name, "status": "available", "git": url,
"branch": None, "ref": None, "toc": {},
"description": None, "error": None}
for name, url in RECOMMENDED_ADDONS.items()
if not os.path.isdir(os.path.join(ap, name))]
if not recs:
return
self._log_line("\nInstalling recommended addons...\n", "acct")
self._addon_apply(recs)
def _verify_game_files(self):
"""Full integrity pass: aria2 hash-checks every game file's pieces
against the torrent and re-downloads only the bad/missing ones, then
re-patches WoW.exe. Catches same-size corruption the routine size-based
sync can't. Installed mods are left alone."""
if self._running:
return
self._client_ready = False
self._log_line(
"\nVerify game files — hash-checking every file against the torrent.\n",
"acct")
self._start_update(check_integrity=True)
def _show_logs(self):
if self._logwin is not None:
try:
self._logwin.deiconify()
self._logwin.lift()
self._logwin.focus_force()
return
except tk.TclError:
self._logwin = None
self._logwin_text = None
win = tk.Toplevel(self)
win.title("Octo Updater — Logs")
LW, LH = self._px(760), self._px(420)
sw, sh = win.winfo_screenwidth(), win.winfo_screenheight()
win.geometry(f"{LW}x{LH}+{(sw - LW) // 2}+{(sh - LH) // 2}")
win.configure(bg=C_BG)
top = tk.Frame(win, bg=C_BG)
top.pack(fill="x", padx=self._px(12), pady=(self._px(10), self._px(4)))
tk.Label(top, text="SESSION LOG", font=("Segoe UI", 9, "bold"),
fg=C_GOLD, bg=C_BG).pack(side="left")
outer = tk.Frame(win, bg=C_BG)
outer.pack(fill="both", expand=True, padx=self._px(12),
pady=(0, self._px(12)))
sb = SlimScrollbar(outer, bg=C_BG, width=self._px(10))
sb.pack(side="right", fill="y")
txt = tk.Text(outer, bg=C_LOG_BG, fg=C_TEXT,
insertbackground=C_TEXT, relief="flat",
font=FONT_MONO, wrap="word", state="disabled",
padx=self._px(10), pady=self._px(8), yscrollcommand=sb.set,
cursor="arrow", selectbackground=C_PANEL_BDR)
txt.pack(side="left", fill="both", expand=True)
sb.command = txt.yview
for t, c in (("ok", C_OK), ("err", C_ERR),
("dim", C_TEXT_DIM), ("acct", C_GOLD)):
txt.tag_config(t, foreground=c)
txt.configure(state="normal")
for text, tag in self._log_buffer:
if tag:
txt.insert("end", text, tag)
else:
txt.insert("end", text)
txt.see("end")
txt.configure(state="disabled")
def _close():
self._logwin = None
self._logwin_text = None
win.destroy()
win.protocol("WM_DELETE_WINDOW", _close)
self._logwin = win
self._logwin_text = txt
# ── button helpers ───────────────────────────────────────────────────────────
def _set_btn_play(self):
self._btn_mode = "play"
self._upd_btn.configure(text="PLAY", bg=C_GREEN_BTN, fg="#ffffff")
self._btn_glow.configure(bg="#2b511d")
def _set_btn_update(self):
self._btn_mode = "update"
self._upd_btn.configure(text="UPDATE", bg=C_GOLD, fg="#ffffff")
self._btn_glow.configure(bg="#4a3812")
def _set_btn_busy(self, label="…"):
self._btn_mode = "busy"
self._upd_btn.configure(text=label, bg="#2a2434", fg=C_TEXT_DIM)
self._btn_glow.configure(bg="#211c2c")
def _btn_hover(self, entering: bool):
if self._btn_mode == "busy":
return
if entering:
col = C_GREEN_HOV if self._btn_mode == "play" else C_GOLD_LT
glow = "#397024" if self._btn_mode == "play" else "#5c4a16"
else:
col = C_GREEN_BTN if self._btn_mode == "play" else C_GOLD
glow = "#2b511d" if self._btn_mode == "play" else "#4a3812"
self._upd_btn.configure(bg=col)
self._btn_glow.configure(bg=glow)
def _mods_have_errors(self) -> bool:
return any(bool(s.get("error"))
for s in load_config().get("mods", {}).values())
def _refresh_ready_state(self):
"""Recompute the footer status/button after an operation finishes.
PLAY is only offered when the client files are up to date AND no mod
is in an error state — otherwise the button stays grey and inactive."""
# Never re-enable PLAY while addons are actively installing — this
# guards against a stray call during the mods→addons setup chain or a
# post-install verify flipping the button back on mid-download.
if self._addons_installing:
self._set_btn_busy("Installing…")
self._status_var.set("Downloading addons…")
return
if not self._client_ready:
self._status_var.set("Update available!")
self._set_btn_update()
return
if self._mods_have_errors():
self._set_btn_busy("PLAY")
self._status_var.set("Mod errors — check MODS tab")
else:
self._set_btn_play()
self._status_var.set("Everything up to date!")
def _btn_click(self):
if self._btn_mode == "play":
self._launch_game()
elif self._btn_mode == "update":
self._start_update()
def _launch_game(self):
"""Launch the game detached.
If VanillaFixes is installed use VanillaFixes.exe (it injects dlls then
starts WoW.exe itself). Otherwise fall back to WoW.exe directly."""
import subprocess
client_dir = self._game_path.get().strip()
cfg = load_config()
vf_state = cfg.get("mods", {}).get("VanillaFixes", {})
vf_installed = (vf_state.get("enabled") and
vf_state.get("installed_version") and
os.path.exists(os.path.join(client_dir, "VanillaFixes.exe")))
if vf_installed:
exe = os.path.join(client_dir, "VanillaFixes.exe")
exe_lbl = "VanillaFixes.exe"
else:
exe = os.path.join(client_dir, "WoW.exe")
exe_lbl = "WoW.exe"
if not os.path.exists(exe):
self._log_line(f"{exe_lbl} not found at: {exe}\n", "err")
return
# One-time DXVK first-launch notice (armed when DXVK was installed).
if cfg.get("dxvk_notice_pending"):
self._cfg = update_config(
lambda c: c.pop("dxvk_notice_pending", None))
from tkinter import messagebox
messagebox.showinfo(
"DXVK mod first launch",
"Initial shader compilation may cause temporary in-game "
"stuttering during the first launch. This is a normal process "
"while the game builds its shader cache.\n\n"
"Users with AMD GPUs experiencing stability issues can switch "
"to DXVK 2.5.3",
parent=self)
if self._cfg.get("clear_wdb_on_launch", False):
remove_wdb(client_dir)
try:
launch_executable(exe, client_dir)
self._log_line(f"Launched {exe_lbl}!\n", "ok")
# Briefly disable PLAY so a double-click can't spawn two clients.
self._set_btn_busy("PLAY")
self._status_var.set("Launching...")
# Optionally minimize (close) the updater to the taskbar shortly after launch
if self._cfg.get("close_on_launch", False):
self.after(1000, self.iconify) # self._on_close and return
self.after(5000, self._refresh_ready_state)
except Exception as e:
self._log_line(f"Failed to launch {exe_lbl}: {e}\n", "err")
# ── verify lifecycle ──────────────────────────────────────────────────────────
def _start_verify(self):
out = self._game_path.get().strip()
if not out:
self._set_btn_update()
return
# Cancel any verify already in flight before swapping the queues, so
# a stale worker can't keep writing to a queue we no longer poll.
prev = getattr(self, "_verify_worker", None)
if prev is not None:
prev.cancel()
self._running = True
self._set_btn_busy("Checking…")
self._status_var.set("Checking for updates…")
self._log_q = queue.Queue()
self._prog_q = queue.Queue()
worker = VerifyWorker(out, self._log_q, self._prog_q)
self._verify_worker = worker
threading.Thread(target=worker.run, daemon=True).start()
# ── update lifecycle ──────────────────────────────────────────────────────────
def _start_update(self, check_integrity: bool = False,
overwrite_config: bool = False):
if self._running:
return
if self._pending_reconcile:
check_integrity = True
overwrite_config = (self._pending_reconcile == "full")
self._set_pending_reconcile(None)
out = self._game_path.get().strip()
if not out:
self._log_line("✗ Please set the game folder first.\n", "err")
return
self._cfg = update_config(lambda c: c.__setitem__("out_dir", out))
self._maybe_show_aria2_firewall_notice()
self._log_line(f"\nGame folder: {out}\n", "dim")
self._running = True
self._set_btn_busy("Verifying…" if check_integrity else "Updating…")
self._status_var.set("Verifying game files…" if check_integrity
else "Updating game files…")
self._draw_progress(0.0)
self._prog_label_var.set("")
self._log_q = queue.Queue()
self._prog_q = queue.Queue()
self._worker = UpdateWorker(out, self._log_q, self._prog_q,
check_integrity=check_integrity,
overwrite_config=overwrite_config)
t = threading.Thread(target=self._worker.run, daemon=True)
t.start()
def _finish(self, success: bool):
self._running = False
if success:
self._client_ready = True
self._draw_progress(1.0)
self._refresh_ready_state()
# Game files are confirmed present now — install essential mods
# if this is a fresh folder (first launch or folder just changed).
self._maybe_install_essential_mods()
# When mods were already initialized, the addons chain from
# _do_inplace_update never runs — trigger it directly.
if load_config().get("mods"):
self._maybe_install_default_addons()
else:
self._client_ready = False
self._status_var.set("Update failed — check the log")
self._draw_progress(0.0)
self._set_btn_update()
# ── queue polling ─────────────────────────────────────────────────────────
def _poll(self):
try:
while True:
msg, tag = self._log_q.get_nowait()
if msg == "__DONE__":
self._finish(True)
elif msg == "__ERROR__":
self._finish(False)
elif msg == "__UP_TO_DATE__":
self._running = False
self._client_ready = True
self._draw_progress(1.0)
self._refresh_ready_state()
# Files were already fine (no download needed) — still need
# to check whether essential mods should be auto-installed,
# e.g. right after switching to a folder that already has
# the client but has never had mods installed via this
# updater.
self._maybe_install_essential_mods()
if load_config().get("mods"):
self._maybe_install_default_addons()
elif msg == "__UPDATE_NEEDED__":
self._running = False
self._client_ready = False
self._status_var.set("Update available!")
self._draw_progress(0.0)
self._set_btn_update()
elif msg.startswith("__VERSION__"):
ver = msg[len("__VERSION__"):]
self._client_ver_var.set(ver)
else:
self._render_log(msg, tag)
except queue.Empty:
pass
# Drain the global app-log queue that helper functions write to.
try:
while True:
msg, tag = _LOG_Q.get_nowait()
self._render_log(msg, tag)
except queue.Empty:
pass
latest = None
try:
while True:
latest = self._prog_q.get_nowait()
except queue.Empty:
pass
if latest is not None:
val, lbl = latest[0], latest[1]
status = latest[2] if len(latest) > 2 else None
self._draw_progress(val)
self._prog_label_var.set(lbl)
if status:
self._status_var.set(status)
self._set_btn_busy("Verifying…" if status.startswith("Verifying")
else "Updating…")
self.after(80, self._poll)
# ──────────────────────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────────────────────