"""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 from .app_log import log from .config import APP_DATA_DIR, UA, DOWNLOAD_RETRY, DOWNLOAD_TIMEOUT, ensure_dir, load_config, update_config from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen from .client import locale_patches, write_config_wtf, update_config_wtf from .platform_ops import create_directory_link # Torrent-based client sync (aria2c) # ────────────────────────────────────────────────────────────────────────────── # Client files are synced over BitTorrent with aria2c, fetched on first use. # The download is differential: only files that are missing or the wrong size # are pulled. CLIENT_TORRENT_URL = "https://dl.octowow.st/download/client.torrent" # Pinned aria2 Windows build. aria2 is GPLv2+, fetched and run unmodified; only # aria2c.exe is used. The sha256 is of the release .zip (verified once). ARIA2_ZIP_URL = ("https://github.com/aria2/aria2/releases/download/" "release-1.37.0/aria2-1.37.0-win-32bit-build1.zip") ARIA2_ZIP_SHA256 = "35f6514cc5dd7e98a87b3c4c2d25a0754b9b063dbe59bc0f22d483464f61e5b6" ARIA2C_PATH = os.path.join(APP_DATA_DIR, "aria2c.exe") # The torrent's top-level folder name: aria2 writes files under //…, # so a junction /client → the real client dir lands them in place. TORRENT_NAME = "client" TORRENT_STAGING_DIR = os.path.join(APP_DATA_DIR, "torrent-root") PRISTINE_WOW_PATH = os.path.join(APP_DATA_DIR, "base-WoW.exe") _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) def ensure_aria2c(log_fn=log) -> str: """Return the path to aria2c.exe, downloading + checksum-verifying it into APP_DATA_DIR on first use. Raises on failure.""" if os.path.exists(ARIA2C_PATH): return ARIA2C_PATH log_fn("Fetching aria2c (one-time, ~2.5 MB)…", "acct") req = urllib.request.Request(ARIA2_ZIP_URL, headers={"User-Agent": UA}) with secure_urlopen(req, timeout=60, allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r: data = r.read() digest = hashlib.sha256(data).hexdigest() if digest != ARIA2_ZIP_SHA256: raise RuntimeError( f"aria2 checksum mismatch (got {digest[:12]}…); refusing to run it") import zipfile, io with zipfile.ZipFile(io.BytesIO(data)) as zf: name = next((n for n in zf.namelist() if n.lower().endswith("aria2c.exe")), None) if not name: raise RuntimeError("aria2c.exe not found in the aria2 archive") exe = zf.read(name) ensure_dir(APP_DATA_DIR) tmp = ARIA2C_PATH + ".part" with open(tmp, "wb") as f: f.write(exe) os.replace(tmp, ARIA2C_PATH) log_fn("aria2c ready.", "ok") return ARIA2C_PATH def _bdecode(buf: bytes, pos: int = 0): """Minimal bencode decoder → (value, next_pos). Strings stay bytes.""" ch = buf[pos] if ch == 0x69: # ie end = buf.index(b"e", pos) return int(buf[pos + 1:end]), end + 1 if ch == 0x6c: # le lst, p = [], pos + 1 while buf[p] != 0x65: v, p = _bdecode(buf, p) lst.append(v) return lst, p + 1 if ch == 0x64: # de d, p = {}, pos + 1 while buf[p] != 0x65: k, p = _bdecode(buf, p) v, p = _bdecode(buf, p) d[k] = v return d, p + 1 colon = buf.index(b":", pos) # : n = int(buf[pos:colon]) start = colon + 1 return buf[start:start + n], start + n def fetch_torrent(url: str = CLIENT_TORRENT_URL) -> tuple: """Download the .torrent → (raw_bytes, files). `files` is a list of (path_parts, length). The raw bytes' SHA-1 is the client 'version'.""" req = urllib.request.Request(url, headers={"User-Agent": UA}) with secure_urlopen(req, timeout=DOWNLOAD_TIMEOUT, allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r: raw = r.read() decoded, _ = _bdecode(raw) info = decoded.get(b"info", {}) files = [([p.decode("latin1") for p in f.get(b"path", [])], int(f.get(b"length", 0))) for f in info.get(b"files", [])] return raw, files def torrent_version(raw: bytes) -> str: """SHA-1 of the whole .torrent file — the client 'version'. Changes whenever the torrent is re-rolled, so it's what we compare to detect a new build and decide whether aria2's resume state is stale. An occasional re-check when only the announce/date changed (content unchanged) is harmless.""" return hashlib.sha1(raw).hexdigest() @cache def _torrent_excluded_files() -> frozenset: """Lower-cased basenames of every file the Mods tab installs, across all mods — the client-root files the torrent sync leaves to the mod system. The torrent ships several of them (VfPatcher.dll, nampower.dll, d3d9.dll, …), but the Mods tab is the single source of truth for installing / disabling / versioning them, so the sync must never fetch, re-add, or flag them. Computed once and memoized (MODS_REGISTRY is static at import).""" return frozenset( os.path.basename(f).lower() for mod in MODS_REGISTRY for f in mod.get("installed_files", []) ) def _is_torrent_excluded(parts) -> bool: return len(parts) == 1 and parts[0].lower() in _torrent_excluded_files() def _torrent_skip(parts, ignore_speech: bool, client_dir: str) -> bool: """Files the sync must leave alone: mod-owned client-root files, plus a speech.MPQ the user opted to keep (Settings) — but only when it already exists on disk. A missing speech.MPQ is still downloaded so a clean client can launch; 'ignore' protects a custom copy, it doesn't skip a required file that isn't there.""" if _is_torrent_excluded(parts): return True if ignore_speech and parts and parts[-1].lower() == "speech.mpq": return os.path.exists(os.path.join(client_dir, *parts)) return False # Files the sync must not rewrite (mod-owned client-root files, plus a kept # custom speech.MPQ). Excluding them from --select-file stops aria2 fetching # them on their own, but a torrent piece can straddle a file boundary, so # repairing a selected neighbour re-downloads the shared piece and rewrites the # excluded file's bytes too. We move them aside (rename) for the sync and put # them back — instant and RAM-free, unlike copying a large speech.MPQ. _SHIELD_SUFFIX = ".octobak" def shield_protected_files(client_dir: str, files, ignore_speech: bool) -> list: """Prepare each sync-protected file (see _torrent_skip) for the sync. An existing one is moved aside via a same-dir rename; an absent one is recorded with backup=None, because the sync must never create it (only the Mods tab installs these) yet aria2 may write a partial stub for it through a shared piece. Returns [(orig, backup_or_None)] for unshielding.""" shielded = [] for parts, _length in files: if not _torrent_skip(parts, ignore_speech, client_dir): continue p = os.path.join(client_dir, *parts) if os.path.exists(p): bak = p + _SHIELD_SUFFIX try: os.replace(p, bak) # same-fs, instant; aria2 sees p missing shielded.append((p, bak)) except OSError: pass else: shielded.append((p, None)) # must stay absent afterwards return shielded def unshield_protected_files(shielded) -> list: """Move each shielded file back, or delete the stub the sync created for a file that was absent before. Returns the basenames restored.""" restored = [] for p, bak in shielded: if bak is None: try: os.remove(p) # drop aria2's partial stub; stay absent except OSError: pass continue try: os.replace(bak, p) # our version wins over aria2's partial restored.append(os.path.basename(p)) except OSError: pass return restored def recover_protected_files(client_dir: str, files): """Undo a shield interrupted by a crash: an orphaned '.octobak' beside a torrent file is the real file — move it back into place.""" for parts, _length in files: p = os.path.join(client_dir, *parts) bak = p + _SHIELD_SUFFIX if os.path.exists(bak): try: os.replace(bak, p) except OSError: pass def torrent_selection(client_dir: str, files, drop_mismatched=False, ignore_speech=False): """1-indexed list of torrent files that are missing or the wrong size on disk (aria2 --select-file), plus whether any were entirely missing.""" need, missing = [], False for i, (parts, length) in enumerate(files): if _torrent_skip(parts, ignore_speech, client_dir): continue dest = os.path.join(client_dir, *parts) try: size = os.path.getsize(dest) except OSError: missing = True need.append(i + 1) continue if size != length: # oversized/corrupt file poisons resume — drop it; a short file is # kept so aria2 can resume it if drop_mismatched or size > length: try: os.remove(dest) except OSError: pass need.append(i + 1) return need, missing def torrent_all_selection(client_dir: str, files, ignore_speech=False) -> list: """1-indexed list of every non-mod file in the torrent. Used for an integrity pass: aria2 --check-integrity verifies each selected file's piece hashes and re-downloads only the bad/missing pieces — catching same-size but corrupted files that the size-based torrent_selection can't.""" return [i + 1 for i, (parts, _) in enumerate(files) if not _torrent_skip(parts, ignore_speech, client_dir)] def torrent_tree_intact(client_dir: str, files, ignore_speech=False) -> bool: for parts, length in files: if _torrent_skip(parts, ignore_speech, client_dir): continue try: if os.path.getsize(os.path.join(client_dir, *parts)) != length: return False except OSError: return False return True # Legacy leftovers the current client no longer ships. Locale data folders are # matched by name; the old patch archives by name AND exact size, so a player # mod that reused one of these names is never deleted. _LOCALE_DATA_DIRS = { "enus", "engb", "encn", "entw", "kokr", "frfr", "dede", "zhcn", "zhtw", "eses", "esmx", "ruru", "ptbr", "ptpt", "itit", } _LEGACY_ARCHIVES = { "patch-6.mpq": 451195806, "patch-7.mpq": 175256564, "patch-8.mpq": 484649870, "patch-9.mpq": 506808141, "patch-a.mpq": 241751337, } def prune_stale_client_files(client_dir: str, files) -> list: """Remove legacy Data// folders and known old-client patch MPQs the current torrent no longer ships. Folders are removed by name (unless the torrent still uses them); archives only when the name AND the exact size match a known legacy one. Returns removed names.""" data_dir = os.path.join(client_dir, "Data") if not os.path.isdir(data_dir): return [] # what the current torrent puts directly in Data/ (.mpq files and subdirs) expected, used_dirs = set(), set() for parts, _length in files: if not parts or parts[0] != "Data": continue if len(parts) == 2 and parts[1].lower().endswith(".mpq"): expected.add(parts[1].lower()) elif len(parts) >= 3: used_dirs.add(parts[1].lower()) removed = [] for name in os.listdir(data_dir): lc, full = name.lower(), os.path.join(data_dir, name) if os.path.isdir(full): if lc in _LOCALE_DATA_DIRS and lc not in used_dirs: try: shutil.rmtree(full) removed.append(name + "/") except OSError: pass continue if not lc.endswith(".mpq") or lc in expected: continue try: size = os.path.getsize(full) except OSError: continue if _LEGACY_ARCHIVES.get(lc) == size: try: os.remove(full) removed.append(name) except OSError: pass return removed # Downloadable MPQ content patches (not shipped with the client). Each is # a single HTTP file with a `.sha256` sidecar; an update is available when # that published sha differs from the on-disk file's. MPQ_PATCHES = [ { "file": "patch-O.mpq", "name": "Octo Raid Visuals", "description": "Adds ground markers and sounds for boss abilities in raids.", "url": "https://dl.octowow.st/client/latest/Data/patch-O.mpq", }, ] def mpq_patch_for(filename: str): """The registry entry whose file matches `filename` (case-insensitive).""" lc = filename.lower() return next((e for e in MPQ_PATCHES if e["file"].lower() == lc), None) def sha256_file(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest().lower() def fetch_mpq_sha256(url: str) -> str: """The published SHA256 of an MPQ patch, from its `.sha256` sidecar.""" req = urllib.request.Request(url + ".sha256", headers={"User-Agent": UA}) with secure_urlopen(req, timeout=NEWS_TIMEOUT, allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r: return r.read().decode("ascii", "ignore").strip().split()[0].lower() def download_mpq_patch(entry: dict, data_dir: str, on_progress=None): """Download the patch into /, verifying its published SHA256. Writes to a .part file and renames on success. on_progress(done, total) is called as bytes arrive.""" url = entry["url"] want = fetch_mpq_sha256(url) ensure_dir(data_dir) tmp = os.path.join(data_dir, entry["file"] + ".part") h = hashlib.sha256() req = urllib.request.Request(url, headers={"User-Agent": UA}) with secure_urlopen(req, timeout=DOWNLOAD_TIMEOUT, allowed_hosts=ALLOWED_DOWNLOAD_HOSTS) as r: total = int(r.headers.get("Content-Length") or 0) done = 0 with open(tmp, "wb") as f: while True: chunk = r.read(256 * 1024) if not chunk: break f.write(chunk) h.update(chunk) done += len(chunk) if on_progress: on_progress(done, total) if h.hexdigest().lower() != want: try: os.remove(tmp) except OSError: pass raise RuntimeError("checksum verification failed") os.replace(tmp, os.path.join(data_dir, entry["file"])) def clear_torrent_resume_state(): """Delete aria2's saved control (.aria2) and metadata (.torrent) files in the staging dir. They pin a specific torrent revision (info-hash) and record which pieces are 'done', so a stale one causes an 'info hash mismatch' error after the client torrent is re-rolled, or blocks re-downloading a file the user deleted. Safe to call when nothing is there.""" try: for name in os.listdir(TORRENT_STAGING_DIR): if name.endswith((".aria2", ".torrent")): try: os.remove(os.path.join(TORRENT_STAGING_DIR, name)) except OSError: pass except OSError: pass def refresh_pristine_wow(client_dir: str): """Cache the freshly-synced (unpatched) WoW.exe as the pristine base, so a later re-patch always starts from clean bytes.""" exe = os.path.join(client_dir, "WoW.exe") try: with open(exe, "rb") as f: f.seek(_LOCALE_ASSERT_OFFSET) pristine = f.read(1) == b"\xa1" if pristine: shutil.copyfile(exe, PRISTINE_WOW_PATH) log("Cached pristine WoW.exe base.", "dim") except OSError: pass def read_pristine_wow(client_dir: str) -> bytes: """The clean base to patch from: the cached pristine exe if present, else the on-disk WoW.exe.""" if os.path.exists(PRISTINE_WOW_PATH): with open(PRISTINE_WOW_PATH, "rb") as f: return f.read() with open(os.path.join(client_dir, "WoW.exe"), "rb") as f: return f.read() def _ensure_torrent_link(client_dir: str) -> str: """Point /client at client_dir at client_dir via a platform-appropriate directory link so aria2 writes the torrent's files straight into the real client dir. Returns the staging dir to pass as aria2 --dir.""" staging = TORRENT_STAGING_DIR ensure_dir(staging) link = os.path.join(staging, TORRENT_NAME) target = os.path.abspath(client_dir) try: if os.path.isdir(link) and \ os.path.abspath(os.path.realpath(link)) == target: return staging except OSError: pass # remove a stale junction/link (rmdir drops the reparse point, not its # target's contents) then recreate it try: os.rmdir(link) except OSError: try: os.remove(link) except OSError: pass create_directory_link(link, target) if not os.path.isdir(link): raise RuntimeError("could not create torrent download link") return staging _SIZE_UNITS = {"B": 1, "KiB": 1024, "MiB": 1024 ** 2, "GiB": 1024 ** 3, "TiB": 1024 ** 4} _UNIT = "|".join(_SIZE_UNITS) # B|KiB|MiB|GiB|TiB _SIZE = rf"[\d.]+(?:{_UNIT})" # e.g. 8.8GiB _FRAC = rf"({_SIZE})/({_SIZE})\((\d+)%\)" # done/total(percent%) _ARIA_FRAC = re.compile(_FRAC) _ARIA_DL = re.compile(rf"DL:({_SIZE})") # During --check-integrity aria2 prints the hash-check progress in a separate # field, e.g. '… [Checksum:#f66a3e 236MiB/1.5GiB(15%)]', while the leading # completed/total stays at 0%/0B (a complete client downloads nothing). Read the # Checksum fraction so the bar tracks the check instead of freezing at 0. _ARIA_CHK = re.compile(rf"Checksum:#\w+\s+{_FRAC}") def _to_bytes(s: str) -> float: m = re.match(rf"^([\d.]+)({_UNIT})$", s.strip()) return float(m.group(1)) * _SIZE_UNITS[m.group(2)] if m else 0.0 def parse_aria_progress(line: str): """Parse an aria2 summary line like '1.2GiB/8.8GiB(13%) … DL:5.0MiB' → {progress, done, total, bps, checking}, or None for non-progress lines. A checksum-check line is preferred over the (idle) download fraction.""" chk = _ARIA_CHK.search(line) m = chk or _ARIA_FRAC.search(line) if not m: return None dl = _ARIA_DL.search(line) return {"progress": int(m.group(3)) / 100.0, "done": _to_bytes(m.group(1)), "total": _to_bytes(m.group(2)), "bps": _to_bytes(dl.group(1)) if dl else 0.0, "checking": chk is not None} # The currently-running aria2c child, so it can be killed when the app quits # (a daemon worker thread dying would otherwise orphan it, still downloading # headless). --stop-with-process is aria2's own belt-and-suspenders for this, # but it's unreliable on Windows — hence the explicit kill too. _active_aria2: "subprocess.Popen | None" = None _active_aria2_lock = threading.Lock() def stop_aria2c(): """Terminate the running aria2c child, if any. Safe to call from any thread (e.g. the app's close handler).""" global _active_aria2 with _active_aria2_lock: proc, _active_aria2 = _active_aria2, None if proc and proc.poll() is None: try: proc.terminate() except Exception: pass def run_aria2c(client_dir, select_files=None, check_integrity=False, on_progress=None, should_cancel=None, log_fn=log): """Sync the client torrent into client_dir with aria2c (leech-only). Blocks until aria2c exits; raises on a non-zero exit or cancellation. Calls on_progress(dict) per update and should_cancel()->bool to abort. aria2 is handed the .torrent URL (not a local copy), so it always fetches the server's current torrent at download time — no chance of running a stale local .torrent if the user starts the update long after the verify.""" global _active_aria2 exe = ensure_aria2c(log_fn) staging = _ensure_torrent_link(client_dir) args = [ exe, f"--dir={staging}", # aria2 exits when this PID (the updater) does — stops an orphaned # download if we're killed before the explicit stop_aria2c() runs. f"--stop-with-process={os.getpid()}", "--seed-time=0", f"--check-integrity={'true' if check_integrity else 'false'}", "--bt-remove-unselected-file=false", "--continue=true", "--allow-overwrite=true", "--auto-file-renaming=false", "--file-allocation=none", "--disk-cache=128M", "--stream-piece-selector=inorder", "--max-tries=0", "--retry-wait=5", "--bt-stop-timeout=120", "--auto-save-interval=15", "--summary-interval=1", "--human-readable=false", "--truncate-console-readout=false", "--console-log-level=warn", "--enable-dht=true", "--bt-enable-lpd=true", "--max-connection-per-server=8", "--split=16", "--min-split-size=1M", ] if select_files: args.append("--select-file=" + ",".join(str(i) for i in select_files)) args.append(CLIENT_TORRENT_URL) proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, creationflags=_NO_WINDOW) with _active_aria2_lock: _active_aria2 = proc try: for line in proc.stdout: if should_cancel and should_cancel(): proc.terminate() raise RuntimeError("Cancelled") line = line.strip() if not line: continue p = parse_aria_progress(line) if p: # aria2's console readout floods idle '0B/total(0%)' lines (no # bytes, no checksum) between the once-a-second summary blocks # that carry the real figure. Drop the idle ones so they can't # stomp progress back to 0 under the UI's latest-wins draining. informative = (p["done"] or p["progress"] or p["bps"] or p["checking"]) if on_progress and informative: on_progress(p) else: log_fn(f"[aria2] {line}", "dim") finally: try: proc.stdout.close() except Exception: pass with _active_aria2_lock: if _active_aria2 is proc: _active_aria2 = None code = proc.wait() if code != 0: raise RuntimeError(f"aria2c exited with code {code}") class VerifyWorker: def __init__(self, out_dir: str, log_q: queue.Queue, prog_q: queue.Queue): self.out_dir = out_dir self.log_q = log_q self.prog_q = prog_q self._cancel = False def cancel(self): self._cancel = True def log(self, msg, tag=""): self.log_q.put((msg, tag)) def progress(self, value, label=""): self.prog_q.put((value, label)) def run(self): try: self.log("Checking for updates…", "acct") raw, files = fetch_torrent() self.log("Checking game files…", "acct") # aria2 selects by size (a patched WoW.exe keeps the torrent's size, # so it's never flagged); need == files missing or wrong-sized. ignore_speech = bool(load_config().get("ignore_speech", False)) need, _missing = torrent_selection(self.out_dir, files, ignore_speech=ignore_speech) have_exe = os.path.exists(os.path.join(self.out_dir, "WoW.exe")) if have_exe and not need: self.log("Everything is up to date!", "ok") self.log_q.put(("__UP_TO_DATE__", "")) else: self.log("Update available.", "acct") self.log_q.put(("__UPDATE_NEEDED__", "")) except Exception as e: self.log(f"Verification failed: {e}", "err") self.log_q.put(("__UPDATE_NEEDED__", "")) class UpdateWorker: def __init__(self, out_dir: str, log_q: queue.Queue, prog_q: queue.Queue, check_integrity: bool = False, overwrite_config: bool = False): self.out_dir = out_dir self.log_q = log_q self.prog_q = prog_q self._cancel = False # Integrity mode: aria2 hash-checks every file's pieces and repairs # them (catches same-size corruption size-based selection misses). self.check_integrity = check_integrity # Write a fresh Config.wtf on a reconcile. self.overwrite_config = overwrite_config def cancel(self): self._cancel = True def log(self, msg: str, tag: str = ""): self.log_q.put((msg, tag)) def progress(self, value: float, label: str = "", status: str | None = None): # status (when given) updates the big status line; None leaves it as-is. self.prog_q.put((value, label, status)) def build_tweaks(self, buf, tweaks: dict | None = None): if tweaks is None: tweaks = load_tweaks_config() fov_deg = tweaks.get("fieldOfView", TWEAKS_DEFAULTS["fieldOfView"]) fov = fov_deg * (math.pi / 180.0) flags = struct.unpack_from("