more refactoring , more unit tests...sigh
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
Refactor v2
|
||||
|
||||
Copy these four files into src/:
|
||||
torrent.py
|
||||
client_sync.py
|
||||
client_patch.py
|
||||
mpq_patches.py
|
||||
|
||||
UI imports:
|
||||
from .client_sync import VerifyWorker, UpdateWorker
|
||||
from .torrent import stop_aria2c
|
||||
|
||||
If the UI uses optional MPQ helpers, import them from .mpq_patches.
|
||||
|
||||
Validated in this archive:
|
||||
- all four generated modules compile
|
||||
- clear_torrent_resume_state exists in torrent.py
|
||||
- run_aria2c exists in torrent.py
|
||||
- stop_aria2c and parse_aria_progress exist in torrent.py
|
||||
- VerifyWorker and UpdateWorker exist in client_sync.py
|
||||
- patch_exe exists in client_patch.py
|
||||
- download_mpq_patch exists in mpq_patches.py
|
||||
|
||||
The original supplied torrent.py referenced some names without defining/importing
|
||||
them there. This refactor assigns those existing project symbols as follows:
|
||||
.mods: MODS_REGISTRY
|
||||
.config: TWEAKS_DEFAULTS, load_tweaks_config
|
||||
.client: _LOCALE_ASSERT_OFFSET, get_client_version, remove_wdb
|
||||
|
||||
If those names currently live in different sibling modules in your project,
|
||||
change those import lines to match their actual owners.
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Pristine WoW.exe caching and binary tweak application."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
from .app_log import log
|
||||
from .config import APP_DATA_DIR
|
||||
from .client import _LOCALE_ASSERT_OFFSET, locale_patches, TWEAKS_DEFAULTS, load_tweaks_config
|
||||
|
||||
PRISTINE_WOW_PATH = os.path.join(APP_DATA_DIR, "base-WoW.exe")
|
||||
|
||||
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 build_tweaks(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("<H", buf, 0x126)[0] | 0x20
|
||||
|
||||
nameplate = float(tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"]))
|
||||
far_clip = float(tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"]))
|
||||
frill = float(tweaks.get("frillDistance", TWEAKS_DEFAULTS["frillDistance"]))
|
||||
cam_dist = float(tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"]))
|
||||
snd_bg = 0x27 if tweaks.get("soundInBackground", TWEAKS_DEFAULTS["soundInBackground"]) else 0x14
|
||||
|
||||
always_loot = tweaks.get("alwaysAutoLoot", TWEAKS_DEFAULTS["alwaysAutoLoot"])
|
||||
locale = tweaks.get("locale", TWEAKS_DEFAULTS["locale"])
|
||||
|
||||
# fmt: off
|
||||
return [
|
||||
("gameLanguage", "bytes", None, locale_patches(locale)),
|
||||
("largeAddress", "uint16", 0x126, flags),
|
||||
("fieldOfView", "float", 0x4089b4, fov),
|
||||
("cameraDistance", "float", 0x4089a4, cam_dist),
|
||||
("farClip", "float", 0x40fed8, far_clip),
|
||||
("frillDistance", "float", 0x467958, frill),
|
||||
("nameplateRange", "float", 0x40c448, nameplate),
|
||||
("soundInBackground", "int8", 0x3a4869, snd_bg),
|
||||
("alwaysAutoLoot", "bytes", None, [
|
||||
(0x0c1ecf, bytes([0x75 if always_loot else 0x74])),
|
||||
(0x0c2b25, bytes([0x75 if always_loot else 0x74])),
|
||||
]),
|
||||
# cameraSkipFix is baked into the torrent's WoW.exe, so we don't
|
||||
# apply it. skillUiGateHijack and octowowUrlAllowlist below are
|
||||
# baked in too, but the official launcher still applies these 2
|
||||
# specific patches, so we mirror it in case the Octo devs drop them
|
||||
# from WoW.exe again.
|
||||
("octowowUrlAllowlist", "bytes", None, [
|
||||
(0x45ccd8, bytes([
|
||||
0x6f,0x63,0x74,0x6f,0x77,0x6f,0x77,0x2e,0x73,0x74,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
])),
|
||||
]),
|
||||
("skillUiGateHijack", "bytes", None, [
|
||||
(0x002ddf90, bytes([
|
||||
0x55,0x8b,0xec,0x83,0xec,0x08,0x53,0x56,0x57,0x8b,0x3d,0x60,0xab,0xce,0x00,0x83,
|
||||
0xff,0xff,0x89,0x55,0xfc,0x89,0x4d,0xf8,0x74,0x79,0x8b,0x75,0x08,0x8b,0x15,0x58,
|
||||
0xab,0xce,0x00,0x8b,0xc7,0x23,0xc6,0x8d,0x04,0x40,0x8b,0x4c,0x82,0x08,0xf6,0xc1,
|
||||
0x01,0x8d,0x44,0x82,0x04,0x75,0x04,0x85,0xc9,0x75,0x05,0x33,0xc9,0x8d,0x49,0x00,
|
||||
0xf6,0xc1,0x01,0x75,0x4e,0x85,0xc9,0x74,0x4a,0x39,0x31,0x74,0x13,0x8b,0xc7,0x23,
|
||||
0xc6,0x8d,0x04,0x40,0x8d,0x04,0x82,0x8b,0x00,0x03,0xc1,0x8b,0x48,0x04,0xeb,0xe0,
|
||||
0x8b,0x59,0x1c,0x8b,0x71,0x18,0x33,0xff,0x85,0xdb,0x7e,0x27,0x8d,0x64,0x24,0x00,
|
||||
0x8b,0x4e,0x0c,0x8b,0x56,0x08,0x6a,0x00,0x6a,0x00,0x51,0x8b,0x4d,0xf8,0x52,0x8b,
|
||||
0x55,0xfc,0xe8,0xb9,0xfd,0xff,0xff,0x84,0xc0,0x75,0x13,0x47,0x83,0xc6,0x20,0x3b,
|
||||
0xfb,0x7c,0xdd,0x5f,0x5e,0x33,0xc0,0x5b,0x8b,0xe5,0x5d,0xc2,0x04,0x00,0x5f,0x8b,
|
||||
0xc6,0x5e,0x5b,0x8b,0xe5,0x5d,0xc2,0x04,0x00,0x90,0x90,0x90,0x90,0x90,0x90,0x90,
|
||||
])),
|
||||
]),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
def patch_exe(client_dir: str, tweaks: dict | None = None, log_fn=log):
|
||||
exe = os.path.join(client_dir, "WoW.exe")
|
||||
if not os.path.exists(exe):
|
||||
raise RuntimeError(f"WoW.exe not found in {client_dir}")
|
||||
log_fn("\nApplying binary tweaks to WoW.exe…")
|
||||
# Patch the pristine (unpatched) base rather than the on-disk exe, so a
|
||||
# re-patch (tweak or language change) never stacks on patched bytes.
|
||||
buf = bytearray(read_pristine_wow(client_dir))
|
||||
for label, kind, offset, value in build_tweaks(buf, tweaks):
|
||||
log_fn(f" {label}", "dim")
|
||||
if kind == "float":
|
||||
struct.pack_into("<f", buf, offset, value)
|
||||
elif kind == "int8":
|
||||
struct.pack_into("<b", buf, offset, value)
|
||||
elif kind == "uint16":
|
||||
struct.pack_into("<H", buf, offset, value)
|
||||
elif kind == "bytes":
|
||||
for off, data in value:
|
||||
buf[off: off + len(data)] = data
|
||||
with open(exe, "wb") as f:
|
||||
f.write(buf)
|
||||
log_fn("WoW.exe patched.", "ok")
|
||||
@@ -0,0 +1,305 @@
|
||||
"""High-level client verification and update workers."""
|
||||
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
|
||||
from .client import get_client_version, remove_wdb, write_config_wtf
|
||||
from .client_patch import PRISTINE_WOW_PATH, patch_exe, refresh_pristine_wow
|
||||
from .config import load_config, update_config
|
||||
from .torrent import (
|
||||
clear_torrent_resume_state,
|
||||
fetch_torrent,
|
||||
recover_protected_files,
|
||||
run_aria2c,
|
||||
shield_protected_files,
|
||||
torrent_all_selection,
|
||||
torrent_selection,
|
||||
torrent_tree_intact,
|
||||
torrent_version,
|
||||
unshield_protected_files,
|
||||
)
|
||||
|
||||
|
||||
def fmt_size(value: float) -> str:
|
||||
value = float(value)
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if abs(value) < 1024.0 or unit == "TiB":
|
||||
return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}"
|
||||
value /= 1024.0
|
||||
|
||||
|
||||
def fmt_speed(value: float) -> str:
|
||||
return f"{fmt_size(value)}/s"
|
||||
|
||||
|
||||
# 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/<locale>/ 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
|
||||
|
||||
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 run(self):
|
||||
# The selection is recomputed here from the live torrent so an
|
||||
# interrupted sync always resumes against the current file set.
|
||||
try:
|
||||
self.log("\nStarting client sync…\n", "acct")
|
||||
self.progress(0.0, "Preparing…")
|
||||
raw, files = fetch_torrent()
|
||||
version = torrent_version(raw)
|
||||
|
||||
# aria2's saved control state pins a torrent revision and its
|
||||
# completed pieces. When the torrent was re-rolled (new identity) or
|
||||
# the folder changed, that state is stale — an 'info hash mismatch'
|
||||
# error, or skipped re-downloads. Clear it and (for a new revision)
|
||||
# drop wrong-sized files so they re-fetch clean.
|
||||
cfg = load_config()
|
||||
stale = (cfg.get("active_torrent_hash") != version or
|
||||
cfg.get("active_client_dir") != os.path.abspath(self.out_dir))
|
||||
if stale:
|
||||
clear_torrent_resume_state()
|
||||
update_config(lambda c: c.update({
|
||||
"active_torrent_hash": version,
|
||||
"active_client_dir": os.path.abspath(self.out_dir)}))
|
||||
|
||||
# Config.wtf is user game config, not in the torrent — (re)write it
|
||||
# on a reconcile (overwrite_config), or when missing.
|
||||
cfg_wtf = os.path.join(self.out_dir, "WTF", "Config.wtf")
|
||||
if self.overwrite_config or not os.path.exists(cfg_wtf):
|
||||
write_config_wtf(self.out_dir)
|
||||
|
||||
# WoW.exe on disk is patched, so an integrity check always flags it
|
||||
# and re-fetches its pieces over the network. Restore the cached
|
||||
# pristine base first: the check then passes with no re-download when
|
||||
# the client is unchanged (aria2 still repairs it if the torrent's
|
||||
# WoW.exe genuinely changed). It's re-patched after the check.
|
||||
wow_path = os.path.join(self.out_dir, "WoW.exe")
|
||||
if (self.check_integrity and os.path.exists(PRISTINE_WOW_PATH)
|
||||
and os.path.exists(wow_path)):
|
||||
shutil.copyfile(PRISTINE_WOW_PATH, wow_path)
|
||||
|
||||
# Put back any file a prior run shielded but couldn't restore (crash
|
||||
# mid-sync), so its version isn't stranded as a .octobak.
|
||||
recover_protected_files(self.out_dir, files)
|
||||
|
||||
# speech.MPQ is left unverified/un-updated when the user keeps a
|
||||
# custom one (Settings → Ignore speech.mpq).
|
||||
ignore_speech = bool(load_config().get("ignore_speech", False))
|
||||
if self.check_integrity:
|
||||
# Full piece-hash verify + repair of every non-mod file — aria2
|
||||
# re-hashes them and re-fetches only the bad/missing pieces.
|
||||
need, missing = torrent_all_selection(
|
||||
self.out_dir, files, ignore_speech=ignore_speech), False
|
||||
else:
|
||||
need, missing = torrent_selection(
|
||||
self.out_dir, files, drop_mismatched=stale,
|
||||
ignore_speech=ignore_speech)
|
||||
# A file the user deleted leaves its pieces marked done in the
|
||||
# control file, so aria2 skips it forever — clear resume state.
|
||||
if missing:
|
||||
clear_torrent_resume_state()
|
||||
wow_downloaded = any(files[i - 1][0] == ["WoW.exe"] for i in need)
|
||||
|
||||
if need:
|
||||
self.log(
|
||||
(f"Verifying {len(need)} file(s) via torrent…"
|
||||
if self.check_integrity
|
||||
else f"Syncing {len(need)} file(s) via torrent…"), "acct")
|
||||
|
||||
# A reconcile runs two aria2 phases: it hash-checks every file
|
||||
# (p["checking"]), then fetches the bad/missing pieces. Flip the
|
||||
# status from "Verifying" to "Updating" between them so it doesn't
|
||||
# read "Verifying" while files are actually being updated. Only
|
||||
# sent on a phase change.
|
||||
_phase = {"status": None}
|
||||
|
||||
def _prog(p):
|
||||
label = f"{fmt_size(p['done'])} / {fmt_size(p['total'])}"
|
||||
if p["bps"]:
|
||||
label += " • " + fmt_speed(p["bps"])
|
||||
frac = p["done"] / p["total"] if p["total"] else 0.0
|
||||
status = None
|
||||
if self.check_integrity:
|
||||
want = ("Verifying game files…" if p["checking"]
|
||||
else "Updating game files…")
|
||||
if want != _phase["status"]:
|
||||
_phase["status"] = status = want
|
||||
self.progress(min(frac, 1.0), label, status)
|
||||
|
||||
# Excluded files (mod-owned + kept speech.MPQ) can still be
|
||||
# rewritten by aria2 through a shared torrent piece. Move them
|
||||
# aside for the sync and put them back after, so the Mods tab /
|
||||
# custom speech stays authoritative.
|
||||
shielded = shield_protected_files(self.out_dir, files,
|
||||
ignore_speech)
|
||||
try:
|
||||
run_aria2c(self.out_dir, select_files=need,
|
||||
check_integrity=self.check_integrity,
|
||||
on_progress=_prog,
|
||||
should_cancel=lambda: self._cancel,
|
||||
log_fn=self.log)
|
||||
finally:
|
||||
for name in unshield_protected_files(shielded):
|
||||
self.log(f" kept mod file: {name}", "dim")
|
||||
else:
|
||||
self.log("All game files already present.", "dim")
|
||||
|
||||
if self._cancel:
|
||||
self.log("\nUpdate cancelled.", "err")
|
||||
self.progress(0.0, "Cancelled")
|
||||
self.log_q.put(("__ERROR__", ""))
|
||||
return
|
||||
|
||||
self.progress(1.0, "Verifying…")
|
||||
if not torrent_tree_intact(self.out_dir, files,
|
||||
ignore_speech=ignore_speech):
|
||||
self.log("\n✗ Download incomplete — click Update to finish.", "err")
|
||||
self.log_q.put(("__ERROR__", ""))
|
||||
return
|
||||
|
||||
self.log("\nDownload complete.", "ok")
|
||||
remove_wdb(self.out_dir)
|
||||
|
||||
# Drop legacy leftovers the current torrent no longer ships
|
||||
for gone in prune_stale_client_files(self.out_dir, files):
|
||||
self.log(f"Removed legacy file: {gone}", "dim")
|
||||
|
||||
# Cache the fresh pristine exe, then patch WoW.exe from that
|
||||
# clean base — but only when the sync actually (re)downloaded it.
|
||||
refresh_pristine_wow(self.out_dir)
|
||||
if wow_downloaded:
|
||||
self.progress(1.0, "Patching…")
|
||||
patch_exe(self.out_dir, log_fn=self.log)
|
||||
else:
|
||||
self.log("\nWoW.exe unchanged — skipping patch.", "dim")
|
||||
|
||||
self.progress(1.0, "")
|
||||
self.log("\n✓ Everything is up to date!", "ok")
|
||||
client_ver = get_client_version(self.out_dir)
|
||||
if client_ver:
|
||||
self.log(f"Client version: {client_ver}", "dim")
|
||||
self.log_q.put((f"__VERSION__{client_ver}", ""))
|
||||
else:
|
||||
self.log("Could not read client version from WoW.exe", "dim")
|
||||
self.log_q.put(("__DONE__", ""))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"\n✗ {e}", "err")
|
||||
self.progress(0.0, "")
|
||||
self.log_q.put(("__ERROR__", ""))
|
||||
@@ -46,6 +46,8 @@ APP_CONFIG_DIR = user_config_dir()
|
||||
|
||||
CONFIG_FILE = os.path.join(APP_CONFIG_DIR, "config.json") # TODO: Change this to point to ~/.config/OctoUpdater
|
||||
|
||||
# TODO: Create the config directories on a linux host if they don't exist
|
||||
|
||||
# First-run default game folder, anchored to the app dir (not the CWD).
|
||||
DEFAULT_GAME_DIR = os.path.join(APP_DIR, "OctoWoW") # NOTE: May need to change later to point to other dir
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Optional MPQ content downloads using HTTPS and SHA-256 sidecars."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
from .config import UA, DOWNLOAD_TIMEOUT, ensure_dir
|
||||
from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
|
||||
|
||||
# Downloadable MPQ content patches (not shipped with the client). Each is
|
||||
# a single HTTP file with a `<url>.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 `<url>.sha256` sidecar."""
|
||||
req = urllib.request.Request(url + ".sha256", headers={"User-Agent": UA})
|
||||
with secure_urlopen(req, timeout=DOWNLOAD_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 <data_dir>/<file>, 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"]))
|
||||
@@ -18,7 +18,6 @@ import queue
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from .config import UA
|
||||
|
||||
|
||||
|
||||
|
||||
+57
-1
@@ -4,7 +4,10 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import hashlib
|
||||
import platform
|
||||
#from .config import APP_DATA_DIR, DOWNLOAD_TIMEOUT, ensure_dir
|
||||
from .app_log import log
|
||||
|
||||
|
||||
# Windows: %LOCALAPPDATA%\octo-updater\
|
||||
@@ -143,4 +146,57 @@ def display_info() -> dict:
|
||||
|
||||
|
||||
|
||||
# NOTE: Returns a path to an aria2c executable , otherwise returns none
|
||||
def find_aria2c() -> str | None:
|
||||
return shutil.which("aria2c")
|
||||
|
||||
|
||||
# NOTE: Function that installs the exe for aria2c on windows devices, takes path as input , returns
|
||||
# path to aria2c exe
|
||||
def install_aria2c_windows( APP_DATA_DIR,
|
||||
ALLOWED_DOWNLOAD_HOSTS,
|
||||
log_fn) -> Path:
|
||||
|
||||
ARIA2C_PATH = os.path.join(APP_DATA_DIR, "aria2c.exe")
|
||||
|
||||
ARIA2_ZIP_SHA256 = "35f6514cc5dd7e98a87b3c4c2d25a0754b9b063dbe59bc0f22d483464f61e5b6"
|
||||
|
||||
ARIA2_ZIP_URL = (
|
||||
"https://github.com/aria2/aria2/releases/download/"
|
||||
"release-1.37.0/aria2-1.37.0-win-32bit-build1.zip"
|
||||
)
|
||||
|
||||
#req = urllib.request.Request(ARIA2_ZIP_URL, headers={"User-Agent": UA})
|
||||
req = urllib.request.Request(ARIA2_ZIP_URL)
|
||||
log_fn("Fetching aria2c (one-time, ~2.5 MB)…", "acct")
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Run from the project root:
|
||||
|
||||
python3 -m pytest -q
|
||||
|
||||
These tests use pytest, tmp_path, and unittest.mock. Network access and aria2c
|
||||
execution are mocked; no real torrent/MPQ downloads are performed.
|
||||
|
||||
Place these four files under your existing src/tests/ directory.
|
||||
@@ -0,0 +1,89 @@
|
||||
import struct
|
||||
from unittest.mock import patch
|
||||
|
||||
from src import client_patch
|
||||
|
||||
|
||||
def test_read_pristine_prefers_cached_copy(tmp_path):
|
||||
cache = tmp_path / "base-WoW.exe"
|
||||
cache.write_bytes(b"cached")
|
||||
client = tmp_path / "client"
|
||||
client.mkdir()
|
||||
(client / "WoW.exe").write_bytes(b"live")
|
||||
|
||||
with patch.object(client_patch, "PRISTINE_WOW_PATH", str(cache)):
|
||||
assert client_patch.read_pristine_wow(str(client)) == b"cached"
|
||||
|
||||
|
||||
def test_read_pristine_falls_back_to_live_exe(tmp_path):
|
||||
client = tmp_path / "client"
|
||||
client.mkdir()
|
||||
(client / "WoW.exe").write_bytes(b"live")
|
||||
|
||||
with patch.object(client_patch, "PRISTINE_WOW_PATH",
|
||||
str(tmp_path / "missing")):
|
||||
assert client_patch.read_pristine_wow(str(client)) == b"live"
|
||||
|
||||
|
||||
def test_refresh_pristine_wow_copies_when_assert_byte_matches(tmp_path):
|
||||
client = tmp_path / "client"
|
||||
client.mkdir()
|
||||
exe = client / "WoW.exe"
|
||||
exe.write_bytes(b"\xa1rest")
|
||||
cache = tmp_path / "base.exe"
|
||||
|
||||
with patch.object(client_patch, "_LOCALE_ASSERT_OFFSET", 0), \
|
||||
patch.object(client_patch, "PRISTINE_WOW_PATH", str(cache)):
|
||||
client_patch.refresh_pristine_wow(str(client))
|
||||
|
||||
assert cache.read_bytes() == b"\xa1rest"
|
||||
|
||||
|
||||
def test_build_tweaks_contains_expected_core_tweaks():
|
||||
# Large enough for the uint16 read at 0x126.
|
||||
buf = bytearray(0x200)
|
||||
tweaks = {
|
||||
"fieldOfView": 90,
|
||||
"nameplateRange": 40,
|
||||
"farClip": 500,
|
||||
"frillDistance": 100,
|
||||
"cameraDistance": 30,
|
||||
"soundInBackground": True,
|
||||
"alwaysAutoLoot": True,
|
||||
"locale": "enUS",
|
||||
}
|
||||
|
||||
with patch.object(client_patch, "locale_patches", return_value=[]):
|
||||
result = client_patch.build_tweaks(buf, tweaks)
|
||||
|
||||
by_name = {item[0]: item for item in result}
|
||||
assert "fieldOfView" in by_name
|
||||
assert "largeAddress" in by_name
|
||||
assert "alwaysAutoLoot" in by_name
|
||||
assert by_name["largeAddress"][3] & 0x20
|
||||
|
||||
|
||||
def test_patch_exe_writes_supported_patch_types(tmp_path):
|
||||
client = tmp_path / "client"
|
||||
client.mkdir()
|
||||
exe = client / "WoW.exe"
|
||||
exe.write_bytes(b"\x00" * 32)
|
||||
|
||||
fake_tweaks = [
|
||||
("f", "float", 0, 1.5),
|
||||
("i8", "int8", 4, 7),
|
||||
("u16", "uint16", 6, 0x1234),
|
||||
("raw", "bytes", None, [(8, b"AB")]),
|
||||
]
|
||||
|
||||
with patch.object(client_patch, "read_pristine_wow",
|
||||
return_value=b"\x00" * 32), \
|
||||
patch.object(client_patch, "build_tweaks",
|
||||
return_value=fake_tweaks):
|
||||
client_patch.patch_exe(str(client), log_fn=lambda *a: None)
|
||||
|
||||
data = exe.read_bytes()
|
||||
assert struct.unpack_from("<f", data, 0)[0] == 1.5
|
||||
assert struct.unpack_from("<b", data, 4)[0] == 7
|
||||
assert struct.unpack_from("<H", data, 6)[0] == 0x1234
|
||||
assert data[8:10] == b"AB"
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import queue
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from src import client_sync
|
||||
|
||||
|
||||
def test_fmt_size_and_speed():
|
||||
assert client_sync.fmt_size(1024) == "1.0KiB"
|
||||
assert client_sync.fmt_speed(1024) == "1.0KiB/s"
|
||||
|
||||
|
||||
def test_prune_stale_client_files_removes_known_archive(tmp_path):
|
||||
data = tmp_path / "Data"
|
||||
data.mkdir()
|
||||
p = data / "patch-6.mpq"
|
||||
legacy_size = client_sync._LEGACY_ARCHIVES["patch-6.mpq"]
|
||||
p.write_bytes(b"x")
|
||||
# Avoid allocating a 451 MB fixture.
|
||||
with patch.object(os.path, "getsize", return_value=legacy_size):
|
||||
removed = client_sync.prune_stale_client_files(str(tmp_path), [])
|
||||
assert "patch-6.mpq" in removed
|
||||
assert not p.exists()
|
||||
|
||||
|
||||
def test_prune_keeps_archive_when_current_torrent_contains_it(tmp_path):
|
||||
data = tmp_path / "Data"
|
||||
data.mkdir()
|
||||
p = data / "patch-6.mpq"
|
||||
p.write_bytes(b"x")
|
||||
files = [(["Data", "patch-6.mpq"], 1)]
|
||||
|
||||
removed = client_sync.prune_stale_client_files(str(tmp_path), files)
|
||||
assert removed == []
|
||||
assert p.exists()
|
||||
|
||||
|
||||
def test_verify_worker_reports_up_to_date(tmp_path):
|
||||
(tmp_path / "WoW.exe").write_bytes(b"x")
|
||||
log_q, prog_q = queue.Queue(), queue.Queue()
|
||||
worker = client_sync.VerifyWorker(str(tmp_path), log_q, prog_q)
|
||||
|
||||
with patch.object(client_sync, "fetch_torrent",
|
||||
return_value=(b"raw", [(["WoW.exe"], 1)])), \
|
||||
patch.object(client_sync, "torrent_selection",
|
||||
return_value=([], False)), \
|
||||
patch.object(client_sync, "load_config", return_value={}):
|
||||
worker.run()
|
||||
|
||||
messages = []
|
||||
while not log_q.empty():
|
||||
messages.append(log_q.get()[0])
|
||||
assert "__UP_TO_DATE__" in messages
|
||||
|
||||
|
||||
def test_verify_worker_reports_update_needed(tmp_path):
|
||||
log_q, prog_q = queue.Queue(), queue.Queue()
|
||||
worker = client_sync.VerifyWorker(str(tmp_path), log_q, prog_q)
|
||||
|
||||
with patch.object(client_sync, "fetch_torrent",
|
||||
return_value=(b"raw", [(["WoW.exe"], 1)])), \
|
||||
patch.object(client_sync, "torrent_selection",
|
||||
return_value=([1], True)), \
|
||||
patch.object(client_sync, "load_config", return_value={}):
|
||||
worker.run()
|
||||
|
||||
messages = []
|
||||
while not log_q.empty():
|
||||
messages.append(log_q.get()[0])
|
||||
assert "__UPDATE_NEEDED__" in messages
|
||||
|
||||
|
||||
def test_update_worker_cancel_sets_flag(tmp_path):
|
||||
worker = client_sync.UpdateWorker(
|
||||
str(tmp_path), queue.Queue(), queue.Queue()
|
||||
)
|
||||
assert worker._cancel is False
|
||||
worker.cancel()
|
||||
assert worker._cancel is True
|
||||
@@ -0,0 +1,68 @@
|
||||
import hashlib
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src import mpq_patches
|
||||
|
||||
|
||||
def response_cm(data: bytes, length=None):
|
||||
response = Mock()
|
||||
response.read.side_effect = [data, b""] if length is not None else None
|
||||
if length is None:
|
||||
response.read.return_value = data
|
||||
response.headers = {"Content-Length": str(length)} if length is not None else {}
|
||||
cm = Mock()
|
||||
cm.__enter__ = Mock(return_value=response)
|
||||
cm.__exit__ = Mock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
def test_mpq_patch_for_case_insensitive():
|
||||
assert mpq_patches.mpq_patch_for("PATCH-o.MPQ")["file"] == "patch-O.mpq"
|
||||
assert mpq_patches.mpq_patch_for("missing.mpq") is None
|
||||
|
||||
|
||||
def test_sha256_file(tmp_path):
|
||||
p = tmp_path / "x"
|
||||
p.write_bytes(b"abc")
|
||||
assert mpq_patches.sha256_file(str(p)) == hashlib.sha256(b"abc").hexdigest()
|
||||
|
||||
|
||||
def test_fetch_mpq_sha256_parses_sidecar():
|
||||
digest = "a" * 64
|
||||
with patch.object(
|
||||
mpq_patches, "secure_urlopen",
|
||||
return_value=response_cm((digest + " patch.mpq\n").encode())
|
||||
):
|
||||
assert mpq_patches.fetch_mpq_sha256("https://example/x") == digest
|
||||
|
||||
|
||||
def test_download_mpq_patch_success(tmp_path):
|
||||
data = b"payload"
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
entry = {"url": "https://example/x", "file": "patch.mpq"}
|
||||
progress = Mock()
|
||||
|
||||
with patch.object(mpq_patches, "fetch_mpq_sha256", return_value=digest), \
|
||||
patch.object(mpq_patches, "secure_urlopen",
|
||||
return_value=response_cm(data, len(data))):
|
||||
mpq_patches.download_mpq_patch(entry, str(tmp_path), progress)
|
||||
|
||||
assert (tmp_path / "patch.mpq").read_bytes() == data
|
||||
assert not (tmp_path / "patch.mpq.part").exists()
|
||||
progress.assert_called()
|
||||
|
||||
|
||||
def test_download_mpq_patch_bad_checksum_removes_part(tmp_path):
|
||||
data = b"payload"
|
||||
entry = {"url": "https://example/x", "file": "patch.mpq"}
|
||||
|
||||
with patch.object(mpq_patches, "fetch_mpq_sha256", return_value="0" * 64), \
|
||||
patch.object(mpq_patches, "secure_urlopen",
|
||||
return_value=response_cm(data, len(data))):
|
||||
with pytest.raises(RuntimeError, match="checksum"):
|
||||
mpq_patches.download_mpq_patch(entry, str(tmp_path))
|
||||
|
||||
assert not (tmp_path / "patch.mpq").exists()
|
||||
assert not (tmp_path / "patch.mpq.part").exists()
|
||||
@@ -1,7 +1,17 @@
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from src import platform_ops
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
import pytest
|
||||
from src import config
|
||||
from src import net
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_user_config_dir_windows(monkeypatch):
|
||||
@@ -27,7 +37,7 @@ def test_user_config_dir_linux_with_xdg_config_home(monkeypatch):
|
||||
|
||||
result = platform_ops.user_config_dir()
|
||||
|
||||
assert result == Path("/tmp/test-config") / "octo-updater"
|
||||
assert result == Path("/tmp/test-config") / "OctoUpdater"
|
||||
|
||||
|
||||
def test_user_config_dir_linux_without_xdg_config_home(monkeypatch):
|
||||
@@ -41,5 +51,879 @@ def test_user_config_dir_linux_without_xdg_config_home(monkeypatch):
|
||||
|
||||
result = platform_ops.user_config_dir()
|
||||
|
||||
assert result == Path("/home/testuser/.config/octo-updater")
|
||||
assert result == Path("/home/testuser/.config/OctoUpdater")
|
||||
|
||||
|
||||
|
||||
|
||||
# install_aria2c_windows() currently keeps this checksum inside the function,
|
||||
# so successful tests mock hashlib.sha256() to return the checksum expected by
|
||||
# the implementation.
|
||||
ARIA2_EXPECTED_SHA256 = (
|
||||
"35f6514cc5dd7e98a87b3c4c2d25a075"
|
||||
"4b9b063dbe59bc0f22d483464f61e5b6"
|
||||
)
|
||||
|
||||
|
||||
def make_aria2_zip(
|
||||
exe_data=b"fake aria2c executable",
|
||||
exe_name="aria2-1.37.0-win-32bit-build1/aria2c.exe",
|
||||
):
|
||||
"""Create an in-memory ZIP containing a fake aria2c.exe."""
|
||||
buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
zf.writestr(exe_name, exe_data)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def make_response(data):
|
||||
"""Create a mocked context-manager HTTP response."""
|
||||
response = Mock()
|
||||
response.read.return_value = data
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def allow_fake_zip_checksum(monkeypatch):
|
||||
"""
|
||||
Make hashlib.sha256(...).hexdigest() return the checksum expected by
|
||||
install_aria2c_windows().
|
||||
"""
|
||||
digest = Mock()
|
||||
digest.hexdigest.return_value = ARIA2_EXPECTED_SHA256
|
||||
|
||||
sha_mock = Mock(return_value=digest)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops.hashlib,
|
||||
"sha256",
|
||||
sha_mock,
|
||||
)
|
||||
|
||||
return sha_mock
|
||||
|
||||
|
||||
class TestInstallAria2cWindows:
|
||||
def test_downloads_and_installs_aria2c(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
exe_data = b"fake aria2c executable"
|
||||
zip_data = make_aria2_zip(exe_data)
|
||||
|
||||
response = make_response(zip_data)
|
||||
urlopen_mock = Mock(return_value=response)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
net,
|
||||
"secure_urlopen",
|
||||
urlopen_mock,
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
result = platform_ops.install_aria2c_windows()
|
||||
|
||||
expected = tmp_path / "aria2c.exe"
|
||||
|
||||
assert Path(result) == expected
|
||||
assert expected.exists()
|
||||
assert expected.read_bytes() == exe_data
|
||||
|
||||
def test_returns_installed_aria2c_path(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
net,
|
||||
"secure_urlopen",
|
||||
Mock(return_value=make_response(zip_data)),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
result = platform_ops.install_aria2c_windows()
|
||||
|
||||
assert Path(result) == tmp_path / "aria2c.exe"
|
||||
|
||||
def test_download_request_uses_user_agent(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
urlopen_mock = Mock(
|
||||
return_value=make_response(zip_data)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
net,
|
||||
"secure_urlopen",
|
||||
urlopen_mock,
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
request = urlopen_mock.call_args.args[0]
|
||||
|
||||
assert (
|
||||
request.get_header("User-agent")
|
||||
== platform_ops.UA
|
||||
)
|
||||
|
||||
def test_download_request_uses_expected_url(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
urlopen_mock = Mock(
|
||||
return_value=make_response(zip_data)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
urlopen_mock,
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
request = urlopen_mock.call_args.args[0]
|
||||
|
||||
assert request.full_url == (
|
||||
"https://github.com/aria2/aria2/releases/download/"
|
||||
"release-1.37.0/"
|
||||
"aria2-1.37.0-win-32bit-build1.zip"
|
||||
)
|
||||
|
||||
def test_uses_sixty_second_timeout(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
urlopen_mock = Mock(
|
||||
return_value=make_response(zip_data)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
urlopen_mock,
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert (
|
||||
urlopen_mock.call_args.kwargs["timeout"]
|
||||
== 60
|
||||
)
|
||||
|
||||
def test_restricts_download_hosts(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
urlopen_mock = Mock(
|
||||
return_value=make_response(zip_data)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
urlopen_mock,
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert (
|
||||
urlopen_mock.call_args.kwargs["allowed_hosts"]
|
||||
== platform_ops.ALLOWED_DOWNLOAD_HOSTS
|
||||
)
|
||||
|
||||
def test_hashes_downloaded_archive(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
sha_mock = allow_fake_zip_checksum(
|
||||
monkeypatch
|
||||
)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
sha_mock.assert_called_once_with(zip_data)
|
||||
|
||||
def test_rejects_checksum_mismatch(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
bad_digest = Mock()
|
||||
bad_digest.hexdigest.return_value = "0" * 64
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops.hashlib,
|
||||
"sha256",
|
||||
Mock(return_value=bad_digest),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="aria2 checksum mismatch",
|
||||
):
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe"
|
||||
).exists()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe.part"
|
||||
).exists()
|
||||
|
||||
def test_checksum_failure_happens_before_extraction(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = b"not even a valid zip"
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
bad_digest = Mock()
|
||||
bad_digest.hexdigest.return_value = "0" * 64
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops.hashlib,
|
||||
"sha256",
|
||||
Mock(return_value=bad_digest),
|
||||
)
|
||||
|
||||
# If checksum validation occurs first, we get RuntimeError
|
||||
# instead of zipfile.BadZipFile.
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="aria2 checksum mismatch",
|
||||
):
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
def test_rejects_archive_without_aria2c(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
zf.writestr(
|
||||
"README.txt",
|
||||
b"no executable here",
|
||||
)
|
||||
|
||||
zip_data = buffer.getvalue()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="aria2c.exe not found",
|
||||
):
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe"
|
||||
).exists()
|
||||
|
||||
def test_finds_executable_inside_nested_directory(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
exe_data = b"nested executable"
|
||||
|
||||
zip_data = make_aria2_zip(
|
||||
exe_data=exe_data,
|
||||
exe_name=(
|
||||
"aria2-1.37.0-win-32bit-build1/"
|
||||
"some/nested/path/aria2c.exe"
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
result = platform_ops.install_aria2c_windows()
|
||||
|
||||
assert Path(result).read_bytes() == exe_data
|
||||
|
||||
def test_finds_exe_case_insensitively(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
exe_data = b"aria binary"
|
||||
|
||||
zip_data = make_aria2_zip(
|
||||
exe_data=exe_data,
|
||||
exe_name="aria2-build/ARIA2C.EXE",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
result = platform_ops.install_aria2c_windows()
|
||||
|
||||
assert Path(result).read_bytes() == exe_data
|
||||
|
||||
def test_only_extracts_aria2c_executable(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
exe_data = b"aria executable"
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(buffer, "w") as zf:
|
||||
zf.writestr(
|
||||
"aria2-build/aria2c.exe",
|
||||
exe_data,
|
||||
)
|
||||
zf.writestr(
|
||||
"aria2-build/README.txt",
|
||||
b"readme",
|
||||
)
|
||||
zf.writestr(
|
||||
"aria2-build/other.dll",
|
||||
b"dll",
|
||||
)
|
||||
|
||||
zip_data = buffer.getvalue()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert (
|
||||
tmp_path / "aria2c.exe"
|
||||
).read_bytes() == exe_data
|
||||
|
||||
assert not (
|
||||
tmp_path / "README.txt"
|
||||
).exists()
|
||||
|
||||
assert not (
|
||||
tmp_path / "other.dll"
|
||||
).exists()
|
||||
|
||||
def test_creates_app_data_directory(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
app_dir = tmp_path / "new-app-data"
|
||||
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
ensure_mock = Mock(
|
||||
side_effect=lambda path: os.makedirs(
|
||||
path,
|
||||
exist_ok=True,
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(app_dir),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"ensure_dir",
|
||||
ensure_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
ensure_mock.assert_called_once_with(
|
||||
str(app_dir)
|
||||
)
|
||||
|
||||
assert (
|
||||
app_dir / "aria2c.exe"
|
||||
).exists()
|
||||
|
||||
def test_writes_to_part_file_before_replace(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
real_replace = os.replace
|
||||
replace_mock = Mock(
|
||||
side_effect=real_replace
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops.os,
|
||||
"replace",
|
||||
replace_mock,
|
||||
)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
replace_mock.assert_called_once_with(
|
||||
str(tmp_path / "aria2c.exe.part"),
|
||||
str(tmp_path / "aria2c.exe"),
|
||||
)
|
||||
|
||||
def test_part_file_removed_after_success(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe.part"
|
||||
).exists()
|
||||
|
||||
assert (
|
||||
tmp_path / "aria2c.exe"
|
||||
).exists()
|
||||
|
||||
def test_replaces_existing_aria2c(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
old_exe = tmp_path / "aria2c.exe"
|
||||
old_exe.write_bytes(b"old executable")
|
||||
|
||||
new_data = b"new executable"
|
||||
zip_data = make_aria2_zip(new_data)
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert old_exe.read_bytes() == new_data
|
||||
|
||||
def test_logs_fetch_and_ready_messages(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
logger = Mock()
|
||||
|
||||
platform_ops.install_aria2c_windows(
|
||||
log_fn=logger,
|
||||
)
|
||||
|
||||
assert logger.call_count == 2
|
||||
|
||||
logger.assert_any_call(
|
||||
"Fetching aria2c (one-time, ~2.5 MB)…",
|
||||
"acct",
|
||||
)
|
||||
|
||||
logger.assert_any_call(
|
||||
"aria2c ready.",
|
||||
"ok",
|
||||
)
|
||||
|
||||
def test_does_not_log_ready_on_checksum_failure(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
bad_digest = Mock()
|
||||
bad_digest.hexdigest.return_value = "0" * 64
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops.hashlib,
|
||||
"sha256",
|
||||
Mock(return_value=bad_digest),
|
||||
)
|
||||
|
||||
logger = Mock()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
platform_ops.install_aria2c_windows(
|
||||
log_fn=logger,
|
||||
)
|
||||
|
||||
logger.assert_any_call(
|
||||
"Fetching aria2c (one-time, ~2.5 MB)…",
|
||||
"acct",
|
||||
)
|
||||
|
||||
assert (
|
||||
("aria2c ready.", "ok")
|
||||
not in [
|
||||
call.args
|
||||
for call in logger.call_args_list
|
||||
]
|
||||
)
|
||||
|
||||
def test_propagates_network_failure(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
side_effect=OSError(
|
||||
"network unavailable"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
OSError,
|
||||
match="network unavailable",
|
||||
):
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe"
|
||||
).exists()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe.part"
|
||||
).exists()
|
||||
|
||||
def test_propagates_invalid_zip_error(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
invalid_zip = b"not a zip archive"
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(
|
||||
invalid_zip
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Allow the invalid data through checksum verification so
|
||||
# ZipFile itself gets exercised.
|
||||
allow_fake_zip_checksum(monkeypatch)
|
||||
|
||||
with pytest.raises(
|
||||
zipfile.BadZipFile
|
||||
):
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
assert not (
|
||||
tmp_path / "aria2c.exe"
|
||||
).exists()
|
||||
|
||||
def test_does_not_create_destination_before_validation(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
app_dir = tmp_path / "app-data"
|
||||
|
||||
zip_data = make_aria2_zip()
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"APP_DATA_DIR",
|
||||
str(app_dir),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"secure_urlopen",
|
||||
Mock(
|
||||
return_value=make_response(zip_data)
|
||||
),
|
||||
)
|
||||
|
||||
bad_digest = Mock()
|
||||
bad_digest.hexdigest.return_value = "0" * 64
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops.hashlib,
|
||||
"sha256",
|
||||
Mock(return_value=bad_digest),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
platform_ops.install_aria2c_windows()
|
||||
|
||||
# ensure_dir() occurs after checksum + ZIP validation in the
|
||||
# implementation shown, so a rejected download should not
|
||||
# create the application directory.
|
||||
assert not app_dir.exists()
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import hashlib
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
from src import torrent
|
||||
from pathlib import Path
|
||||
|
||||
def bencode(value):
|
||||
if isinstance(value, int):
|
||||
return b"i" + str(value).encode() + b"e"
|
||||
if isinstance(value, bytes):
|
||||
return str(len(value)).encode() + b":" + value
|
||||
if isinstance(value, list):
|
||||
return b"l" + b"".join(bencode(v) for v in value) + b"e"
|
||||
if isinstance(value, dict):
|
||||
return b"d" + b"".join(bencode(k) + bencode(v)
|
||||
for k, v in sorted(value.items())) + b"e"
|
||||
raise TypeError(type(value))
|
||||
|
||||
|
||||
def test_bdecode_nested_values():
|
||||
raw = bencode({b"a": 3, b"b": [b"x", 7]})
|
||||
decoded, pos = torrent._bdecode(raw)
|
||||
assert decoded == {b"a": 3, b"b": [b"x", 7]}
|
||||
assert pos == len(raw)
|
||||
|
||||
|
||||
def test_torrent_version_is_sha1_of_raw_bytes():
|
||||
raw = b"torrent bytes"
|
||||
assert torrent.torrent_version(raw) == hashlib.sha1(raw).hexdigest()
|
||||
|
||||
|
||||
def test_fetch_torrent_extracts_paths_and_lengths():
|
||||
raw = bencode({
|
||||
b"info": {
|
||||
b"files": [
|
||||
{b"length": 10, b"path": [b"WoW.exe"]},
|
||||
{b"length": 20, b"path": [b"Data", b"patch.MPQ"]},
|
||||
]
|
||||
}
|
||||
})
|
||||
response = Mock()
|
||||
response.read.return_value = raw
|
||||
cm = Mock()
|
||||
cm.__enter__ = Mock(return_value=response)
|
||||
cm.__exit__ = Mock(return_value=False)
|
||||
|
||||
with patch.object(torrent, "secure_urlopen", return_value=cm):
|
||||
got_raw, files = torrent.fetch_torrent()
|
||||
|
||||
assert got_raw == raw
|
||||
assert files == [(["WoW.exe"], 10), (["Data", "patch.MPQ"], 20)]
|
||||
|
||||
|
||||
def test_torrent_selection_finds_missing_and_wrong_size(tmp_path):
|
||||
files = [(["a.bin"], 3), (["b.bin"], 4), (["c.bin"], 5)]
|
||||
(tmp_path / "a.bin").write_bytes(b"abc")
|
||||
(tmp_path / "b.bin").write_bytes(b"xx")
|
||||
|
||||
with patch.object(torrent, "_torrent_skip", return_value=False):
|
||||
need, missing = torrent.torrent_selection(str(tmp_path), files)
|
||||
|
||||
assert need == [2, 3]
|
||||
assert missing is True
|
||||
|
||||
|
||||
def test_torrent_selection_removes_oversized_file(tmp_path):
|
||||
p = tmp_path / "a.bin"
|
||||
p.write_bytes(b"too long")
|
||||
with patch.object(torrent, "_torrent_skip", return_value=False):
|
||||
need, missing = torrent.torrent_selection(
|
||||
str(tmp_path), [(["a.bin"], 2)]
|
||||
)
|
||||
assert need == [1]
|
||||
assert missing is False
|
||||
assert not p.exists()
|
||||
|
||||
|
||||
def test_torrent_all_selection_skips_protected(tmp_path):
|
||||
files = [(["a"], 1), (["b"], 1), (["c"], 1)]
|
||||
with patch.object(torrent, "_torrent_skip",
|
||||
side_effect=[False, True, False]):
|
||||
assert torrent.torrent_all_selection(str(tmp_path), files) == [1, 3]
|
||||
|
||||
|
||||
def test_torrent_tree_intact(tmp_path):
|
||||
(tmp_path / "a").write_bytes(b"123")
|
||||
with patch.object(torrent, "_torrent_skip", return_value=False):
|
||||
assert torrent.torrent_tree_intact(
|
||||
str(tmp_path), [(["a"], 3)]
|
||||
)
|
||||
assert not torrent.torrent_tree_intact(
|
||||
str(tmp_path), [(["a"], 4)]
|
||||
)
|
||||
|
||||
|
||||
def test_shield_and_unshield_existing_file(tmp_path):
|
||||
original = tmp_path / "mod.dll"
|
||||
original.write_bytes(b"ours")
|
||||
files = [(["mod.dll"], 4)]
|
||||
|
||||
with patch.object(torrent, "_torrent_skip", return_value=True):
|
||||
shielded = torrent.shield_protected_files(
|
||||
str(tmp_path), files, False
|
||||
)
|
||||
|
||||
assert not original.exists()
|
||||
assert (tmp_path / "mod.dll.octobak").exists()
|
||||
|
||||
# Simulate aria2 creating/replacing the original path.
|
||||
original.write_bytes(b"aria")
|
||||
restored = torrent.unshield_protected_files(shielded)
|
||||
|
||||
assert original.read_bytes() == b"ours"
|
||||
assert restored == ["mod.dll"]
|
||||
|
||||
|
||||
def test_unshield_removes_stub_for_previously_absent_file(tmp_path):
|
||||
p = tmp_path / "mod.dll"
|
||||
shielded = [(str(p), None)]
|
||||
p.write_bytes(b"stub")
|
||||
|
||||
assert torrent.unshield_protected_files(shielded) == []
|
||||
assert not p.exists()
|
||||
|
||||
|
||||
def test_recover_protected_files(tmp_path):
|
||||
p = tmp_path / "x.dll"
|
||||
bak = tmp_path / "x.dll.octobak"
|
||||
bak.write_bytes(b"saved")
|
||||
torrent.recover_protected_files(str(tmp_path), [(["x.dll"], 5)])
|
||||
assert p.read_bytes() == b"saved"
|
||||
assert not bak.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "checking", "progress", "bps"),
|
||||
[
|
||||
("1MiB/4MiB(25%) DL:2MiB", False, .25, 2 * 1024**2),
|
||||
("0B/4MiB(0%) [Checksum:#abc 2MiB/4MiB(50%)]",
|
||||
True, .50, 0),
|
||||
],
|
||||
)
|
||||
def test_parse_aria_progress(line, checking, progress, bps):
|
||||
got = torrent.parse_aria_progress(line)
|
||||
assert got["checking"] is checking
|
||||
assert got["progress"] == progress
|
||||
assert got["bps"] == bps
|
||||
|
||||
|
||||
def test_parse_aria_progress_ignores_non_progress_line():
|
||||
assert torrent.parse_aria_progress("download started") is None
|
||||
|
||||
|
||||
def test_clear_torrent_resume_state(tmp_path):
|
||||
(tmp_path / "a.aria2").write_text("x")
|
||||
(tmp_path / "b.torrent").write_text("x")
|
||||
(tmp_path / "keep.txt").write_text("x")
|
||||
|
||||
with patch.object(torrent, "TORRENT_STAGING_DIR", str(tmp_path)):
|
||||
torrent.clear_torrent_resume_state()
|
||||
|
||||
assert not (tmp_path / "a.aria2").exists()
|
||||
assert not (tmp_path / "b.torrent").exists()
|
||||
assert (tmp_path / "keep.txt").exists()
|
||||
|
||||
|
||||
def test_ensure_aria2c_returns_existing_binary(tmp_path):
|
||||
exe = tmp_path / "aria2c.exe"
|
||||
exe.write_bytes(b"x")
|
||||
with patch.object(torrent, "ARIA2C_PATH", str(exe)):
|
||||
assert torrent.ensure_aria2c() == str(exe)
|
||||
|
||||
|
||||
def test_ensure_aria2c_rejects_bad_checksum(tmp_path):
|
||||
response = Mock()
|
||||
response.read.return_value = b"not the expected archive"
|
||||
cm = Mock()
|
||||
cm.__enter__ = Mock(return_value=response)
|
||||
cm.__exit__ = Mock(return_value=False)
|
||||
|
||||
with patch.object(torrent, "ARIA2C_PATH", str(tmp_path / "aria2c.exe")), \
|
||||
patch.object(torrent, "secure_urlopen", return_value=cm):
|
||||
with pytest.raises(RuntimeError, match="checksum mismatch"):
|
||||
torrent.ensure_aria2c(log_fn=Mock())
|
||||
|
||||
|
||||
|
||||
|
||||
class TestEnsureAria2c:
|
||||
def test_returns_existing_aria2c(self, monkeypatch):
|
||||
"""An aria2c already discoverable on PATH should be used directly."""
|
||||
expected = Path("/usr/bin/aria2c")
|
||||
|
||||
find_mock = Mock(return_value=expected)
|
||||
monkeypatch.setattr(torrent, "find_aria2c", find_mock)
|
||||
|
||||
result = torrent.ensure_aria2c()
|
||||
|
||||
assert result == expected
|
||||
find_mock.assert_called_once_with()
|
||||
|
||||
def test_returns_existing_windows_aria2c(self, monkeypatch):
|
||||
"""A Windows aria2c.exe discovered on PATH should also be returned."""
|
||||
expected = Path(r"C:\Program Files\aria2\aria2c.exe")
|
||||
|
||||
find_mock = Mock(return_value=expected)
|
||||
monkeypatch.setattr(torrent, "find_aria2c", find_mock)
|
||||
|
||||
result = torrent.ensure_aria2c()
|
||||
|
||||
assert result == expected
|
||||
find_mock.assert_called_once_with()
|
||||
|
||||
def test_falls_back_to_windows_installer(self, monkeypatch):
|
||||
"""Missing aria2c should invoke install_aria2c_windows()."""
|
||||
expected = Path(r"C:\AppData\OctoUpdater\aria2c.exe")
|
||||
|
||||
find_mock = Mock(return_value=None)
|
||||
monkeypatch.setattr(torrent, "find_aria2c", find_mock)
|
||||
|
||||
# ensure_aria2c imports install_aria2c_windows inside the function,
|
||||
# so patch the source module rather than torrent.
|
||||
from src import platform_ops
|
||||
|
||||
installer = Mock(return_value=expected)
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"install_aria2c_windows",
|
||||
installer,
|
||||
)
|
||||
|
||||
result = torrent.ensure_aria2c()
|
||||
|
||||
assert result == expected
|
||||
find_mock.assert_called_once_with()
|
||||
installer.assert_called_once()
|
||||
|
||||
def test_passes_custom_logger_to_installer(self, monkeypatch):
|
||||
"""The caller's logger should be forwarded to the installer."""
|
||||
monkeypatch.setattr(
|
||||
torrent,
|
||||
"find_aria2c",
|
||||
Mock(return_value=None),
|
||||
)
|
||||
|
||||
from src import platform_ops
|
||||
|
||||
logger = Mock()
|
||||
installer = Mock(return_value=Path("aria2c.exe"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"install_aria2c_windows",
|
||||
installer,
|
||||
)
|
||||
|
||||
torrent.ensure_aria2c(log_fn=logger)
|
||||
|
||||
installer.assert_called_once_with(log_fn=logger)
|
||||
|
||||
def test_does_not_install_when_aria2c_exists(self, monkeypatch):
|
||||
"""Finding aria2c should short-circuit before installation."""
|
||||
expected = Path("/usr/bin/aria2c")
|
||||
|
||||
monkeypatch.setattr(
|
||||
torrent,
|
||||
"find_aria2c",
|
||||
Mock(return_value=expected),
|
||||
)
|
||||
|
||||
from src import platform_ops
|
||||
|
||||
installer = Mock()
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"install_aria2c_windows",
|
||||
installer,
|
||||
)
|
||||
|
||||
result = torrent.ensure_aria2c()
|
||||
|
||||
assert result == expected
|
||||
installer.assert_not_called()
|
||||
|
||||
def test_propagates_find_aria2c_error(self, monkeypatch):
|
||||
"""Unexpected errors from executable discovery should propagate."""
|
||||
find_mock = Mock(
|
||||
side_effect=OSError("PATH lookup failed")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
torrent,
|
||||
"find_aria2c",
|
||||
find_mock,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
OSError,
|
||||
match="PATH lookup failed",
|
||||
):
|
||||
torrent.ensure_aria2c()
|
||||
|
||||
def test_propagates_installer_error(self, monkeypatch):
|
||||
"""Installation failures should propagate to the caller."""
|
||||
monkeypatch.setattr(
|
||||
torrent,
|
||||
"find_aria2c",
|
||||
Mock(return_value=None),
|
||||
)
|
||||
|
||||
from src import platform_ops
|
||||
|
||||
installer = Mock(
|
||||
side_effect=RuntimeError("download failed")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"install_aria2c_windows",
|
||||
installer,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="download failed",
|
||||
):
|
||||
torrent.ensure_aria2c()
|
||||
|
||||
def test_empty_string_from_find_triggers_install(self, monkeypatch):
|
||||
"""Any false-y discovery result should trigger installation."""
|
||||
monkeypatch.setattr(
|
||||
torrent,
|
||||
"find_aria2c",
|
||||
Mock(return_value=""),
|
||||
)
|
||||
|
||||
from src import platform_ops
|
||||
|
||||
expected = Path("aria2c.exe")
|
||||
installer = Mock(return_value=expected)
|
||||
|
||||
monkeypatch.setattr(
|
||||
platform_ops,
|
||||
"install_aria2c_windows",
|
||||
installer,
|
||||
)
|
||||
|
||||
result = torrent.ensure_aria2c()
|
||||
|
||||
assert result == expected
|
||||
installer.assert_called_once()
|
||||
+19
-510
@@ -1,81 +1,43 @@
|
||||
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
|
||||
import json
|
||||
"""Torrent metadata, file selection, protected-file handling, and aria2c execution."""
|
||||
|
||||
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
|
||||
import urllib.request
|
||||
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 .config import APP_DATA_DIR, UA, DOWNLOAD_TIMEOUT, ensure_dir
|
||||
from .mods import MODS_REGISTRY
|
||||
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")
|
||||
# NOTE: Windows specific ZIP file
|
||||
|
||||
# The torrent's top-level folder name: aria2 writes files under <dir>/<name>/…,
|
||||
# so a junction <staging>/client → the real client dir lands them in place.
|
||||
TORRENT_NAME = "client"
|
||||
# NOTE: Windows sha hash for exe
|
||||
#ARIA2C_PATH = os.path.join(APP_DATA_DIR, "aria2c.exe")
|
||||
|
||||
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):
|
||||
ARIA2C_PATH = find_aria2c()
|
||||
|
||||
if 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
|
||||
|
||||
|
||||
|
||||
return platform_ops.install_aria2c_windows( APP_DATA_DIR=APP_DATA_DIR,
|
||||
ALLOWED_DOWNLOAD_HOSTS=ALLOWED_DOWNLOAD_HOSTS,
|
||||
log_fn=log)
|
||||
|
||||
|
||||
def _bdecode(buf: bytes, pos: int = 0):
|
||||
@@ -270,135 +232,6 @@ def torrent_tree_intact(client_dir: str, files, ignore_speech=False) -> bool:
|
||||
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/<locale>/ 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 `<url>.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 `<url>.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 <data_dir>/<file>, 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
|
||||
@@ -415,32 +248,6 @@ def clear_torrent_resume_state():
|
||||
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 <staging>/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
|
||||
@@ -607,301 +414,3 @@ def run_aria2c(client_dir, select_files=None, check_integrity=False,
|
||||
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("<H", buf, 0x126)[0] | 0x20
|
||||
|
||||
nameplate = float(tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"]))
|
||||
far_clip = float(tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"]))
|
||||
frill = float(tweaks.get("frillDistance", TWEAKS_DEFAULTS["frillDistance"]))
|
||||
cam_dist = float(tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"]))
|
||||
snd_bg = 0x27 if tweaks.get("soundInBackground", TWEAKS_DEFAULTS["soundInBackground"]) else 0x14
|
||||
|
||||
always_loot = tweaks.get("alwaysAutoLoot", TWEAKS_DEFAULTS["alwaysAutoLoot"])
|
||||
locale = tweaks.get("locale", TWEAKS_DEFAULTS["locale"])
|
||||
|
||||
# fmt: off
|
||||
return [
|
||||
("gameLanguage", "bytes", None, locale_patches(locale)),
|
||||
("largeAddress", "uint16", 0x126, flags),
|
||||
("fieldOfView", "float", 0x4089b4, fov),
|
||||
("cameraDistance", "float", 0x4089a4, cam_dist),
|
||||
("farClip", "float", 0x40fed8, far_clip),
|
||||
("frillDistance", "float", 0x467958, frill),
|
||||
("nameplateRange", "float", 0x40c448, nameplate),
|
||||
("soundInBackground", "int8", 0x3a4869, snd_bg),
|
||||
("alwaysAutoLoot", "bytes", None, [
|
||||
(0x0c1ecf, bytes([0x75 if always_loot else 0x74])),
|
||||
(0x0c2b25, bytes([0x75 if always_loot else 0x74])),
|
||||
]),
|
||||
# cameraSkipFix is baked into the torrent's WoW.exe, so we don't
|
||||
# apply it. skillUiGateHijack and octowowUrlAllowlist below are
|
||||
# baked in too, but the official launcher still applies these 2
|
||||
# specific patches, so we mirror it in case the Octo devs drop them
|
||||
# from WoW.exe again.
|
||||
("octowowUrlAllowlist", "bytes", None, [
|
||||
(0x45ccd8, bytes([
|
||||
0x6f,0x63,0x74,0x6f,0x77,0x6f,0x77,0x2e,0x73,0x74,
|
||||
0x00,0x00,0x00,0x00,0x00,0x00,
|
||||
])),
|
||||
]),
|
||||
("skillUiGateHijack", "bytes", None, [
|
||||
(0x002ddf90, bytes([
|
||||
0x55,0x8b,0xec,0x83,0xec,0x08,0x53,0x56,0x57,0x8b,0x3d,0x60,0xab,0xce,0x00,0x83,
|
||||
0xff,0xff,0x89,0x55,0xfc,0x89,0x4d,0xf8,0x74,0x79,0x8b,0x75,0x08,0x8b,0x15,0x58,
|
||||
0xab,0xce,0x00,0x8b,0xc7,0x23,0xc6,0x8d,0x04,0x40,0x8b,0x4c,0x82,0x08,0xf6,0xc1,
|
||||
0x01,0x8d,0x44,0x82,0x04,0x75,0x04,0x85,0xc9,0x75,0x05,0x33,0xc9,0x8d,0x49,0x00,
|
||||
0xf6,0xc1,0x01,0x75,0x4e,0x85,0xc9,0x74,0x4a,0x39,0x31,0x74,0x13,0x8b,0xc7,0x23,
|
||||
0xc6,0x8d,0x04,0x40,0x8d,0x04,0x82,0x8b,0x00,0x03,0xc1,0x8b,0x48,0x04,0xeb,0xe0,
|
||||
0x8b,0x59,0x1c,0x8b,0x71,0x18,0x33,0xff,0x85,0xdb,0x7e,0x27,0x8d,0x64,0x24,0x00,
|
||||
0x8b,0x4e,0x0c,0x8b,0x56,0x08,0x6a,0x00,0x6a,0x00,0x51,0x8b,0x4d,0xf8,0x52,0x8b,
|
||||
0x55,0xfc,0xe8,0xb9,0xfd,0xff,0xff,0x84,0xc0,0x75,0x13,0x47,0x83,0xc6,0x20,0x3b,
|
||||
0xfb,0x7c,0xdd,0x5f,0x5e,0x33,0xc0,0x5b,0x8b,0xe5,0x5d,0xc2,0x04,0x00,0x5f,0x8b,
|
||||
0xc6,0x5e,0x5b,0x8b,0xe5,0x5d,0xc2,0x04,0x00,0x90,0x90,0x90,0x90,0x90,0x90,0x90,
|
||||
])),
|
||||
]),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
def patch_exe(self, tweaks: dict | None = None):
|
||||
exe = os.path.join(self.out_dir, "WoW.exe")
|
||||
if not os.path.exists(exe):
|
||||
raise RuntimeError(f"WoW.exe not found in {self.out_dir}")
|
||||
self.log("\nApplying binary tweaks to WoW.exe…")
|
||||
# Patch the pristine (unpatched) base rather than the on-disk exe, so a
|
||||
# re-patch (tweak or language change) never stacks on patched bytes.
|
||||
buf = bytearray(read_pristine_wow(self.out_dir))
|
||||
for label, kind, offset, value in self.build_tweaks(buf, tweaks):
|
||||
self.log(f" {label}", "dim")
|
||||
if kind == "float":
|
||||
struct.pack_into("<f", buf, offset, value)
|
||||
elif kind == "int8":
|
||||
struct.pack_into("<b", buf, offset, value)
|
||||
elif kind == "uint16":
|
||||
struct.pack_into("<H", buf, offset, value)
|
||||
elif kind == "bytes":
|
||||
for off, data in value:
|
||||
buf[off: off + len(data)] = data
|
||||
with open(exe, "wb") as f:
|
||||
f.write(buf)
|
||||
self.log("WoW.exe patched.", "ok")
|
||||
|
||||
def run(self):
|
||||
# The selection is recomputed here from the live torrent so an
|
||||
# interrupted sync always resumes against the current file set.
|
||||
try:
|
||||
self.log("\nStarting client sync…\n", "acct")
|
||||
self.progress(0.0, "Preparing…")
|
||||
raw, files = fetch_torrent()
|
||||
version = torrent_version(raw)
|
||||
|
||||
# aria2's saved control state pins a torrent revision and its
|
||||
# completed pieces. When the torrent was re-rolled (new identity) or
|
||||
# the folder changed, that state is stale — an 'info hash mismatch'
|
||||
# error, or skipped re-downloads. Clear it and (for a new revision)
|
||||
# drop wrong-sized files so they re-fetch clean.
|
||||
cfg = load_config()
|
||||
stale = (cfg.get("active_torrent_hash") != version or
|
||||
cfg.get("active_client_dir") != os.path.abspath(self.out_dir))
|
||||
if stale:
|
||||
clear_torrent_resume_state()
|
||||
update_config(lambda c: c.update({
|
||||
"active_torrent_hash": version,
|
||||
"active_client_dir": os.path.abspath(self.out_dir)}))
|
||||
|
||||
# Config.wtf is user game config, not in the torrent — (re)write it
|
||||
# on a reconcile (overwrite_config), or when missing.
|
||||
cfg_wtf = os.path.join(self.out_dir, "WTF", "Config.wtf")
|
||||
if self.overwrite_config or not os.path.exists(cfg_wtf):
|
||||
write_config_wtf(self.out_dir)
|
||||
|
||||
# WoW.exe on disk is patched, so an integrity check always flags it
|
||||
# and re-fetches its pieces over the network. Restore the cached
|
||||
# pristine base first: the check then passes with no re-download when
|
||||
# the client is unchanged (aria2 still repairs it if the torrent's
|
||||
# WoW.exe genuinely changed). It's re-patched after the check.
|
||||
wow_path = os.path.join(self.out_dir, "WoW.exe")
|
||||
if (self.check_integrity and os.path.exists(PRISTINE_WOW_PATH)
|
||||
and os.path.exists(wow_path)):
|
||||
shutil.copyfile(PRISTINE_WOW_PATH, wow_path)
|
||||
|
||||
# Put back any file a prior run shielded but couldn't restore (crash
|
||||
# mid-sync), so its version isn't stranded as a .octobak.
|
||||
recover_protected_files(self.out_dir, files)
|
||||
|
||||
# speech.MPQ is left unverified/un-updated when the user keeps a
|
||||
# custom one (Settings → Ignore speech.mpq).
|
||||
ignore_speech = bool(load_config().get("ignore_speech", False))
|
||||
if self.check_integrity:
|
||||
# Full piece-hash verify + repair of every non-mod file — aria2
|
||||
# re-hashes them and re-fetches only the bad/missing pieces.
|
||||
need, missing = torrent_all_selection(
|
||||
self.out_dir, files, ignore_speech=ignore_speech), False
|
||||
else:
|
||||
need, missing = torrent_selection(
|
||||
self.out_dir, files, drop_mismatched=stale,
|
||||
ignore_speech=ignore_speech)
|
||||
# A file the user deleted leaves its pieces marked done in the
|
||||
# control file, so aria2 skips it forever — clear resume state.
|
||||
if missing:
|
||||
clear_torrent_resume_state()
|
||||
wow_downloaded = any(files[i - 1][0] == ["WoW.exe"] for i in need)
|
||||
|
||||
if need:
|
||||
self.log(
|
||||
(f"Verifying {len(need)} file(s) via torrent…"
|
||||
if self.check_integrity
|
||||
else f"Syncing {len(need)} file(s) via torrent…"), "acct")
|
||||
|
||||
# A reconcile runs two aria2 phases: it hash-checks every file
|
||||
# (p["checking"]), then fetches the bad/missing pieces. Flip the
|
||||
# status from "Verifying" to "Updating" between them so it doesn't
|
||||
# read "Verifying" while files are actually being updated. Only
|
||||
# sent on a phase change.
|
||||
_phase = {"status": None}
|
||||
|
||||
def _prog(p):
|
||||
label = f"{fmt_size(p['done'])} / {fmt_size(p['total'])}"
|
||||
if p["bps"]:
|
||||
label += " • " + fmt_speed(p["bps"])
|
||||
frac = p["done"] / p["total"] if p["total"] else 0.0
|
||||
status = None
|
||||
if self.check_integrity:
|
||||
want = ("Verifying game files…" if p["checking"]
|
||||
else "Updating game files…")
|
||||
if want != _phase["status"]:
|
||||
_phase["status"] = status = want
|
||||
self.progress(min(frac, 1.0), label, status)
|
||||
|
||||
# Excluded files (mod-owned + kept speech.MPQ) can still be
|
||||
# rewritten by aria2 through a shared torrent piece. Move them
|
||||
# aside for the sync and put them back after, so the Mods tab /
|
||||
# custom speech stays authoritative.
|
||||
shielded = shield_protected_files(self.out_dir, files,
|
||||
ignore_speech)
|
||||
try:
|
||||
run_aria2c(self.out_dir, select_files=need,
|
||||
check_integrity=self.check_integrity,
|
||||
on_progress=_prog,
|
||||
should_cancel=lambda: self._cancel,
|
||||
log_fn=self.log)
|
||||
finally:
|
||||
for name in unshield_protected_files(shielded):
|
||||
self.log(f" kept mod file: {name}", "dim")
|
||||
else:
|
||||
self.log("All game files already present.", "dim")
|
||||
|
||||
if self._cancel:
|
||||
self.log("\nUpdate cancelled.", "err")
|
||||
self.progress(0.0, "Cancelled")
|
||||
self.log_q.put(("__ERROR__", ""))
|
||||
return
|
||||
|
||||
self.progress(1.0, "Verifying…")
|
||||
if not torrent_tree_intact(self.out_dir, files,
|
||||
ignore_speech=ignore_speech):
|
||||
self.log("\n✗ Download incomplete — click Update to finish.", "err")
|
||||
self.log_q.put(("__ERROR__", ""))
|
||||
return
|
||||
|
||||
self.log("\nDownload complete.", "ok")
|
||||
remove_wdb(self.out_dir)
|
||||
|
||||
# Drop legacy leftovers the current torrent no longer ships
|
||||
for gone in prune_stale_client_files(self.out_dir, files):
|
||||
self.log(f"Removed legacy file: {gone}", "dim")
|
||||
|
||||
# Cache the fresh pristine exe, then patch WoW.exe from that
|
||||
# clean base — but only when the sync actually (re)downloaded it.
|
||||
refresh_pristine_wow(self.out_dir)
|
||||
if wow_downloaded:
|
||||
self.progress(1.0, "Patching…")
|
||||
self.patch_exe()
|
||||
else:
|
||||
self.log("\nWoW.exe unchanged — skipping patch.", "dim")
|
||||
|
||||
self.progress(1.0, "")
|
||||
self.log("\n✓ Everything is up to date!", "ok")
|
||||
client_ver = get_client_version(self.out_dir)
|
||||
if client_ver:
|
||||
self.log(f"Client version: {client_ver}", "dim")
|
||||
self.log_q.put((f"__VERSION__{client_ver}", ""))
|
||||
else:
|
||||
self.log("Could not read client version from WoW.exe", "dim")
|
||||
self.log_q.put(("__DONE__", ""))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"\n✗ {e}", "err")
|
||||
self.progress(0.0, "")
|
||||
self.log_q.put(("__ERROR__", ""))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user