3874 lines
176 KiB
Python
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
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|