Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d8c2dd06 | ||
|
|
adbedf7d57 | ||
|
|
bf938b838e | ||
|
|
919145569a | ||
|
|
82f9342918 | ||
|
|
8ed33d52e3 | ||
|
|
b5a40903ff | ||
|
|
f2f4c372c3 | ||
|
|
ce3154c563 | ||
|
|
0af5235c98 | ||
|
|
8770b5a269 | ||
|
|
dcaedf7f6c |
Regular → Executable
+1
@@ -17,3 +17,4 @@ dist/
|
|||||||
.idea/
|
.idea/
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.venv/
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
The updater is split by responsibility rather than by framework or class hierarchy.
|
||||||
|
|
||||||
|
- `octo_updater.py` — executable entry point only.
|
||||||
|
- `ui.py` — Tk presentation and user interaction.
|
||||||
|
- `config.py` — application paths and atomic configuration persistence.
|
||||||
|
- `net.py` — HTTPS validation and secure URL opening.
|
||||||
|
- `torrent.py` — torrent parsing, selection, verification, aria2 synchronization, and update workers.
|
||||||
|
- `client.py` — WoW client inspection, locale/config patching, tweaks, and client-specific transformations.
|
||||||
|
- `mods.py` — mod and addon catalogs, release lookup, installation, and removal.
|
||||||
|
- `platform_ops.py` — operating-system operations such as directory links, folder opening, DPI, Defender, process launching, and display APIs.
|
||||||
|
- `app_log.py` — the small thread-safe application log channel shared by workers and the UI.
|
||||||
|
|
||||||
|
## Dependency rules
|
||||||
|
|
||||||
|
`ui.py` may depend on the domain modules. Domain modules must not import `ui.py`.
|
||||||
|
|
||||||
|
`platform_ops.py` contains OS-specific process/API behavior and must not depend on Tk.
|
||||||
|
|
||||||
|
`net.py` contains generic network policy and must not contain WoW, torrent, mod, or UI policy.
|
||||||
|
|
||||||
|
`client.py`, `torrent.py`, and `mods.py` expose ordinary functions/data and do not require the Tk application to be running.
|
||||||
|
|
||||||
|
No platform class hierarchy, factories, dependency-injection container, repository layer, or plugin framework is used. New abstractions should only be introduced when a concrete requirement requires them.
|
||||||
Regular → Executable
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
Regular → Executable
+11
-6559
File diff suppressed because one or more lines are too long
Regular → Executable
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -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,7 @@
|
|||||||
|
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
|
||||||
|
import queue
|
||||||
|
|
||||||
|
LOG_QUEUE: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
def log(msg: str, tag: str = "") -> None:
|
||||||
|
LOG_QUEUE.put((msg, tag))
|
||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
"""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 load_config, update_config
|
||||||
|
from .platform_ops import display_info, ensure_dir
|
||||||
|
|
||||||
|
def remove_wdb(client_dir: str):
|
||||||
|
"""Delete the client's WDB folder (server-data cache, safe to drop)."""
|
||||||
|
wdb = os.path.join(client_dir, "WDB")
|
||||||
|
if not os.path.isdir(wdb):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
shutil.rmtree(wdb)
|
||||||
|
log("WDB cache cleared.", "dim")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Could not clear WDB: {e}", "err")
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_version(out_dir: str) -> str:
|
||||||
|
"""Read version + build from fixed offsets in the client's WoW.exe."""
|
||||||
|
exe_path = os.path.join(out_dir, "WoW.exe")
|
||||||
|
if not os.path.exists(exe_path):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
# Read only the two small fields, not the whole ~5 MB binary.
|
||||||
|
with open(exe_path, "rb") as f:
|
||||||
|
f.seek(0x00437bfc)
|
||||||
|
build = f.read(4).decode("utf-8", errors="replace").rstrip("\x00")
|
||||||
|
f.seek(0x00437c04)
|
||||||
|
version = f.read(6).decode("utf-8", errors="replace").rstrip("\x00")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
# Unexpected build, or an exe half-written mid-update, reads junk there -
|
||||||
|
# show nothing rather than garbage. A real read looks like version "1.12.1",
|
||||||
|
# build "5875".
|
||||||
|
if not re.fullmatch(r"\d+(?:\.\d+)+", version) or not build.isdigit():
|
||||||
|
return ""
|
||||||
|
return f"{version} ({build})"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_size(num_bytes: float) -> str:
|
||||||
|
"""Human-readable size: KB, MB, GB. Uses binary (1024) steps but the
|
||||||
|
familiar KB/MB/GB labels. Truncated, not rounded, so 8.7998 GiB reads as
|
||||||
|
'8.79 GB' instead of rounding up to 8.80 and overstating the size."""
|
||||||
|
if num_bytes < 1024 ** 2:
|
||||||
|
return f"{int(num_bytes / 1024)} KB"
|
||||||
|
if num_bytes < 1024 ** 3:
|
||||||
|
return f"{int(num_bytes / 1024 ** 2 * 10) / 10:.1f} MB"
|
||||||
|
return f"{int(num_bytes / 1024 ** 3 * 100) / 100:.2f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_speed(bytes_per_sec: float) -> str:
|
||||||
|
if bytes_per_sec < 1024 * 1024:
|
||||||
|
return f"{bytes_per_sec / 1024:.0f} KB/s"
|
||||||
|
return f"{bytes_per_sec / 1024 / 1024:.1f} MB/s"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# The pristine (unpatched) WoW.exe carries 0xa1 at this offset; a patched one
|
||||||
|
# carries 0xb8 (see the locale patch). Used to cache a clean base for re-patch.
|
||||||
|
_LOCALE_ASSERT_OFFSET = 0x1b2115
|
||||||
|
|
||||||
|
# Game-language (WoW.exe locale) patch
|
||||||
|
# The language is switched by patching three spots in WoW.exe.
|
||||||
|
# LOCALE_NAMES is the exe's built-in 8-slot locale table (at
|
||||||
|
# 0x45591c - index*8). Selectable languages map to a slot index; ruRU and ptBR
|
||||||
|
# reuse the unused zhTW / xxYY slots and rename them. Offsets/bytes verified
|
||||||
|
# against the pristine base-WoW.exe.
|
||||||
|
_LOCALE_TAG_OFFSET = 0x1b2115 # the 0xa1/0xb8 assert instruction
|
||||||
|
_LOCALE_INDEX_OFFSET = 0x253c
|
||||||
|
_LOCALE_NAME_OFFSET = lambda i: 0x45591c - i * 8
|
||||||
|
LOCALE_NAMES = ["enUS", "koKR", "frFR", "deDE",
|
||||||
|
"zhCN", "zhTW", "esES", "xxYY"]
|
||||||
|
# selectable code -> (slot index, display label), in menu order
|
||||||
|
LOCALES = {
|
||||||
|
"enUS": (0, "English"),
|
||||||
|
"deDE": (3, "Deutsch"),
|
||||||
|
"ruRU": (5, "Русский"),
|
||||||
|
"zhCN": (4, "中文 (简体)"),
|
||||||
|
"esES": (6, "Español"),
|
||||||
|
"ptBR": (7, "Português (BR)"),
|
||||||
|
}
|
||||||
|
DEFAULT_LOCALE = "enUS"
|
||||||
|
|
||||||
|
|
||||||
|
def locale_patches(locale: str):
|
||||||
|
"""The three WoW.exe byte edits that force the given game language, as
|
||||||
|
(offset, bytes) tuples applied on top of the pristine base."""
|
||||||
|
if locale not in LOCALES:
|
||||||
|
locale = DEFAULT_LOCALE
|
||||||
|
idx, _ = LOCALES[locale]
|
||||||
|
carrier = LOCALE_NAMES[idx] # exe slot name at this index
|
||||||
|
return [
|
||||||
|
# assert instruction: mov eax, <carrier as reversed dword> (was mov eax,[imm])
|
||||||
|
(_LOCALE_TAG_OFFSET,
|
||||||
|
bytes([0xb8]) + bytes(carrier, "latin1")[::-1]),
|
||||||
|
# locale index: mov esi, <index>; jmp +0x1f
|
||||||
|
(_LOCALE_INDEX_OFFSET,
|
||||||
|
bytes([0xbe, idx, 0x00, 0x00, 0x00, 0xeb, 0x1f])),
|
||||||
|
# rename the slot's locale-name string to the selected tag
|
||||||
|
(_LOCALE_NAME_OFFSET(idx), bytes(locale, "latin1")),
|
||||||
|
]
|
||||||
|
|
||||||
|
def write_config_wtf(client_dir: str, tweaks: dict | None = None):
|
||||||
|
"""Write a fresh Config.wtf from scratch, overwriting any existing one.
|
||||||
|
Never raises — logs the error if the file can't be written (read-only,
|
||||||
|
locked by a running game, or an unwritable folder)."""
|
||||||
|
if tweaks is None:
|
||||||
|
tweaks = load_tweaks_config()
|
||||||
|
far_clip = tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"])
|
||||||
|
cam_dist = tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"])
|
||||||
|
nameplate = tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"])
|
||||||
|
fov_deg = tweaks.get("fieldOfView", TWEAKS_DEFAULTS["fieldOfView"])
|
||||||
|
fov_rad = round(fov_deg * math.pi / 180.0, 6)
|
||||||
|
bg_sound = 1 if tweaks.get("soundInBackground",
|
||||||
|
TWEAKS_DEFAULTS["soundInBackground"]) else 0
|
||||||
|
|
||||||
|
di = _get_display_info_safe()
|
||||||
|
srv = "octowow.st"
|
||||||
|
vars_ = {
|
||||||
|
"realmList": srv, "patchList": srv,
|
||||||
|
"readTOS": 1, "readEULA": 1,
|
||||||
|
"profanityFilter": 0,
|
||||||
|
"gxResolution": f"{di['width']}x{di['height']}",
|
||||||
|
"gxWindow": 1, "gxMaximize": 1,
|
||||||
|
"gxVSync": 0,
|
||||||
|
"gxColorBits": 24, "gxDepthBits": 24,
|
||||||
|
"gxRefresh": di["refresh_rate"],
|
||||||
|
"gxMultisampleQuality": 0, "gxMultisample": 2,
|
||||||
|
"hwDetect": 0,
|
||||||
|
"pixelShaders": 1, "M2UsePixelShaders": 1,
|
||||||
|
"specular": 1,
|
||||||
|
"anisotropic": 16, "trilinear": 1,
|
||||||
|
"lod": 0, "lodDist": 100,
|
||||||
|
"texLodBias": 0,
|
||||||
|
"shadowLevel": 0,
|
||||||
|
"particleDensity": 1,
|
||||||
|
"fullAlpha": 1,
|
||||||
|
"SmallCull": 0.01,
|
||||||
|
"farClip": far_clip,
|
||||||
|
"DistCull": 888.8,
|
||||||
|
"frillDensity": 128,
|
||||||
|
"unitDrawDist": 300,
|
||||||
|
"weatherDensity": 3,
|
||||||
|
"FoV": fov_rad,
|
||||||
|
"NameplateRange": nameplate,
|
||||||
|
"CameraDistanceMax": cam_dist,
|
||||||
|
"cameraDistanceMaxFactor": 1,
|
||||||
|
"scriptMemory": 512000,
|
||||||
|
"uiScale": 1,
|
||||||
|
"mouseSpeed": 1,
|
||||||
|
"autoSelfCast": 1,
|
||||||
|
"movie": 0,
|
||||||
|
"movieSubtitle": 1,
|
||||||
|
"checkAddonVersion": 0,
|
||||||
|
"minimapZoom": 0, "minimapInsideZoom": 0,
|
||||||
|
"EnableErrorSpeech": 0,
|
||||||
|
"SoundZoneMusicNoDelay": 1,
|
||||||
|
"SoundMaxHardwareChannels": 64,
|
||||||
|
"SoundSoftwareChannels": 64,
|
||||||
|
"UncapSounds": 1,
|
||||||
|
"BackgroundSound": bg_sound,
|
||||||
|
"NP_NameplateDistance": nameplate,
|
||||||
|
"NP_SpellQueueWindowMs": 150,
|
||||||
|
"NP_EnableAuraCastEvents": 1,
|
||||||
|
"NP_EnableAutoAttackEvents": 1,
|
||||||
|
"NP_EnableSpellStartEvents": 1,
|
||||||
|
"NP_EnableSpellGoEvents": 1,
|
||||||
|
"NP_EnableSpellHealEvents": 1,
|
||||||
|
"NP_QueueCastTimeSpells": 0,
|
||||||
|
"NP_QueueInstantSpells": 0,
|
||||||
|
"NP_QueueChannelingSpells": 0,
|
||||||
|
"NP_QueueTargetingSpells": 0,
|
||||||
|
"NP_QueueSpellsOnCooldown": 0,
|
||||||
|
"NP_ChatBubbleDistance": 60,
|
||||||
|
"NP_ChatBubblesWhisper": 1,
|
||||||
|
"NP_ChatBubblesRaid": 1,
|
||||||
|
"NP_ChatBubblesBattleground": 1,
|
||||||
|
"ChatBubblesParty": 1,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
cfg_dir = os.path.join(client_dir, "WTF")
|
||||||
|
ensure_dir(cfg_dir)
|
||||||
|
with open(os.path.join(cfg_dir, "Config.wtf"), "w",
|
||||||
|
encoding="utf-8") as f:
|
||||||
|
for k, v in vars_.items():
|
||||||
|
f.write(f'SET {k} "{v}"\n')
|
||||||
|
log("Config.wtf written.", "ok")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Could not write Config.wtf: {e}", "err")
|
||||||
|
|
||||||
|
|
||||||
|
def update_config_wtf(client_dir: str, tweaks: dict):
|
||||||
|
cfg_path = os.path.join(client_dir, "WTF", "Config.wtf")
|
||||||
|
if not os.path.exists(cfg_path):
|
||||||
|
write_config_wtf(client_dir, tweaks)
|
||||||
|
return
|
||||||
|
|
||||||
|
far_clip = tweaks.get("farClip", TWEAKS_DEFAULTS["farClip"])
|
||||||
|
cam_dist = tweaks.get("cameraDistance", TWEAKS_DEFAULTS["cameraDistance"])
|
||||||
|
nameplate = tweaks.get("nameplateRange", TWEAKS_DEFAULTS["nameplateRange"])
|
||||||
|
fov_deg = tweaks.get("fieldOfView", TWEAKS_DEFAULTS["fieldOfView"])
|
||||||
|
fov_rad = round(fov_deg * math.pi / 180.0, 6)
|
||||||
|
bg_sound = 1 if tweaks.get("soundInBackground",
|
||||||
|
TWEAKS_DEFAULTS["soundInBackground"]) else 0
|
||||||
|
updates = {
|
||||||
|
"farClip": str(far_clip),
|
||||||
|
"CameraDistanceMax": str(cam_dist),
|
||||||
|
"NP_NameplateDistance": str(nameplate),
|
||||||
|
"FoV": str(fov_rad),
|
||||||
|
"NameplateRange": str(nameplate),
|
||||||
|
"BackgroundSound": str(bg_sound),
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
updated_keys = set()
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
matched = False
|
||||||
|
for key, val in updates.items():
|
||||||
|
if line.strip().lower().startswith(f"set {key.lower()} "):
|
||||||
|
new_lines.append("SET " + key + ' "' + val + '"\n')
|
||||||
|
updated_keys.add(key)
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
for key, val in updates.items():
|
||||||
|
if key not in updated_keys:
|
||||||
|
new_lines.append("SET " + key + ' "' + val + '"\n')
|
||||||
|
|
||||||
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
|
||||||
|
log(f" Config.wtf updated: farClip={far_clip}, CameraDistanceMax={cam_dist}, "
|
||||||
|
f"NameplateRange={nameplate}, NP_NameplateDistance={nameplate}, "
|
||||||
|
f"FoV={fov_rad}", "dim")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Mods definition & engine
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Registry order == install order: VanillaFixes first (it provides the
|
||||||
|
# loader the other mods rely on). The UI sorts alphabetically for display.
|
||||||
|
TWEAKS_DEFAULTS = {
|
||||||
|
"locale": DEFAULT_LOCALE,
|
||||||
|
"alwaysAutoLoot": True,
|
||||||
|
"nameplateRange": 41,
|
||||||
|
"fieldOfView": 110,
|
||||||
|
"farClip": 777,
|
||||||
|
"frillDistance": 120,
|
||||||
|
"cameraDistance": 50,
|
||||||
|
"soundInBackground": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
TWEAKS_ITEMS = [
|
||||||
|
(None, "GENERAL", "section", False, None, None, None, None, None),
|
||||||
|
|
||||||
|
("locale", "Game Language", "dropdown", False, None,
|
||||||
|
None,
|
||||||
|
None, None, None),
|
||||||
|
|
||||||
|
("alwaysAutoLoot", "Auto Loot", "checkbox", True, None,
|
||||||
|
"Reverses the auto-loot behavior to always auto-loot.",
|
||||||
|
None, None, None),
|
||||||
|
|
||||||
|
("nameplateRange", "Nameplate Range", "number", False, None,
|
||||||
|
"Distance at which nameplates are visible. [0 - 41]",
|
||||||
|
0, 41, 1),
|
||||||
|
|
||||||
|
(None, "CAMERA", "section", False, None, None, None, None, None),
|
||||||
|
|
||||||
|
("fieldOfView", "Field of View", "number", False, None,
|
||||||
|
"Recommended values by aspect ratio: [4:3 = 90] [16:9 = 110] [21:9 = 150] [32:9 = 180]",
|
||||||
|
90, 180, 5),
|
||||||
|
|
||||||
|
("cameraDistance", "Camera Distance", "number", False, None,
|
||||||
|
"Maximum camera zoom-out distance. [50 - 100]",
|
||||||
|
50, 100, 1),
|
||||||
|
|
||||||
|
(None, "GRAPHICS", "section", False, None, None, None, None, None),
|
||||||
|
|
||||||
|
("farClip", "World Distance", "number", False, None,
|
||||||
|
"Terrain and world objects rendering distance. [100 - 10,000]",
|
||||||
|
100, 10000, 1),
|
||||||
|
|
||||||
|
("frillDistance", "Ground Clutter Distance", "number", False, None,
|
||||||
|
"Grass and small rocks rendering distance. [0 - 300]",
|
||||||
|
0, 300, 1),
|
||||||
|
|
||||||
|
(None, "SOUND", "section", False, None, None, None, None, None),
|
||||||
|
|
||||||
|
("soundInBackground", "Sound in Background", "checkbox", True, None,
|
||||||
|
"Allows game sounds to play in the background.",
|
||||||
|
None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# {tweak_id: (min, max)} for every numeric tweak — the single source of
|
||||||
|
# truth for clamping, wherever the value is read from the UI.
|
||||||
|
TWEAKS_LIMITS = {t[0]: (t[6], t[7]) for t in TWEAKS_ITEMS
|
||||||
|
if t[0] is not None and t[2] == "number"}
|
||||||
|
|
||||||
|
|
||||||
|
_FOV_REFS = [
|
||||||
|
(4 / 3, 90),
|
||||||
|
(16 / 9, 110),
|
||||||
|
(21 / 9, 150),
|
||||||
|
(32 / 9, 180),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fov_default_for_display() -> int:
|
||||||
|
try:
|
||||||
|
info = _get_display_info_safe()
|
||||||
|
ratio = info["width"] / info["height"] if info["height"] else 16 / 9
|
||||||
|
except Exception:
|
||||||
|
ratio = 16 / 9
|
||||||
|
|
||||||
|
if ratio <= _FOV_REFS[0][0]:
|
||||||
|
return _FOV_REFS[0][1]
|
||||||
|
if ratio >= _FOV_REFS[-1][0]:
|
||||||
|
return _FOV_REFS[-1][1]
|
||||||
|
for i in range(len(_FOV_REFS) - 1):
|
||||||
|
r0, f0 = _FOV_REFS[i]
|
||||||
|
r1, f1 = _FOV_REFS[i + 1]
|
||||||
|
if r0 <= ratio <= r1:
|
||||||
|
t = (ratio - r0) / (r1 - r0)
|
||||||
|
raw = f0 + t * (f1 - f0)
|
||||||
|
return round(round(raw / 5) * 5)
|
||||||
|
return 110
|
||||||
|
|
||||||
|
|
||||||
|
def _get_display_info_safe() -> dict:
|
||||||
|
return display_info()
|
||||||
|
|
||||||
|
|
||||||
|
def load_tweaks_config() -> dict:
|
||||||
|
cfg = load_config()
|
||||||
|
stored = cfg.get("tweaks", {})
|
||||||
|
defaults = dict(TWEAKS_DEFAULTS)
|
||||||
|
defaults["fieldOfView"] = fov_default_for_display()
|
||||||
|
return {k: stored.get(k, v) for k, v in defaults.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def save_tweaks_config(values: dict):
|
||||||
|
update_config(lambda c: c.__setitem__("tweaks", values))
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# News feed
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -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__", ""))
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
"""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
|
||||||
|
import json
|
||||||
|
from functools import cache
|
||||||
|
from pathlib import Path
|
||||||
|
from .platform_ops import user_data_dir, user_config_dir
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
UPDATER_VERSION = "1.3.1"
|
||||||
|
SERVER = "https://octowow.st"
|
||||||
|
UA = f"OctoUpdater/{UPDATER_VERSION}"
|
||||||
|
DOWNLOAD_RETRY = 5
|
||||||
|
DOWNLOAD_TIMEOUT = 10 # seconds without any data before a transfer aborts
|
||||||
|
|
||||||
|
# Where the app lives: next to the .exe when frozen (PyInstaller), otherwise
|
||||||
|
# next to this script — never the current working directory, which varies with
|
||||||
|
# how the app was launched. This anchors the default game folder.
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
APP_DIR = os.path.dirname(os.path.abspath(sys.executable)) # Directory where frozen .exe is executed from
|
||||||
|
#TODO : Compile into binary ? Make into AppImage ?
|
||||||
|
else:
|
||||||
|
APP_DIR = os.path.dirname(os.path.abspath(__file__)) # Directory where python3 script is executed from
|
||||||
|
|
||||||
|
|
||||||
|
# Set env vars
|
||||||
|
APP_DATA_DIR = user_data_dir()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _relocate_legacy_data():
|
||||||
|
# Old config (<1.3: octo_updater_config.json beside the app) becomes
|
||||||
|
# config.json in APP_DATA_DIR. Rename + relocate, only when the old name
|
||||||
|
# exists and the new path doesn't (idempotent). The legacy game-hash cache
|
||||||
|
# is no longer used and is deleted by _migrate_1_3.
|
||||||
|
old_name, new = "octo_updater_config.json", CONFIG_FILE
|
||||||
|
for old in (os.path.join(APP_DIR, old_name),
|
||||||
|
os.path.join(APP_DATA_DIR, old_name)):
|
||||||
|
if old == new or not os.path.exists(old) or os.path.exists(new):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shutil.move(old, new)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_CONFIG_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: writes the config , using tempfile to prevent data loss
|
||||||
|
def _atomic_write(path: str, text: str):
|
||||||
|
"""Write via a temp file + atomic rename so a crash mid-write can never
|
||||||
|
leave a truncated/corrupt file at `path`."""
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
# NOTE: executes write to config using thread safe lock ?
|
||||||
|
def save_config(data: dict):
|
||||||
|
with _CONFIG_LOCK:
|
||||||
|
try:
|
||||||
|
_atomic_write(CONFIG_FILE, json.dumps(data, indent=2))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def update_config(mutator):
|
||||||
|
"""Load the current on-disk config under the lock, apply `mutator(cfg)`,
|
||||||
|
save atomically, and return the result. Every config change — main thread
|
||||||
|
or worker — should go through this so no stale in-memory snapshot can
|
||||||
|
overwrite keys another thread just persisted."""
|
||||||
|
with _CONFIG_LOCK:
|
||||||
|
cfg = load_config()
|
||||||
|
mutator(cfg)
|
||||||
|
save_config(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── logging ─────────────────────────────────────────────────────────────────
|
||||||
|
# One thread-safe log sink for the whole app. Any function — worker thread or
|
||||||
|
# main — calls log(); the GUI drains _LOG_Q on the main thread (see
|
||||||
|
# OctoUpdaterApp._poll) and renders each line. This keeps all Tk access on the
|
||||||
+1212
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
|
|||||||
|
"""Optional MPQ content downloads using HTTPS and SHA-256 sidecars."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from .config import UA, DOWNLOAD_TIMEOUT
|
||||||
|
from .platform_ops import 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"]))
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Secure networking
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Hardened TLS: verify the server certificate against the system trust store,
|
||||||
|
# require the hostname to match, and refuse anything below TLS 1.2. This is
|
||||||
|
# the primary defence against a man-in-the-middle tampering with downloads.
|
||||||
|
SSL_CTX = ssl.create_default_context()
|
||||||
|
SSL_CTX.check_hostname = True
|
||||||
|
SSL_CTX.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
# Trust certifi's curated roots *in addition to* the system store, so a stale
|
||||||
|
# or incomplete Windows root store (Python's ssl uses a static snapshot and
|
||||||
|
# never triggers Windows' on-demand root update) can't break verification.
|
||||||
|
# If certifi isn't bundled, fall back to the system store alone.
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
SSL_CTX.load_verify_locations(certifi.where())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
SSL_CTX.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Binaries may only be fetched from these hosts. TLS already stops a MITM from
|
||||||
|
# impersonating them; this additionally stops a tampered API response from
|
||||||
|
# redirecting a download (e.g. a mod DLL) to an unexpected host.
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS = {
|
||||||
|
"octowow.st",
|
||||||
|
"dl.octowow.st",
|
||||||
|
"github.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
"objects.githubusercontent.com",
|
||||||
|
"release-assets.githubusercontent.com",
|
||||||
|
"codeberg.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_url(url: str, allowed_hosts):
|
||||||
|
"""Enforce HTTPS and (optionally) an allowlist on a URL."""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
if parts.scheme != "https":
|
||||||
|
raise RuntimeError(f"Refusing non-HTTPS URL: {url}")
|
||||||
|
if allowed_hosts is not None:
|
||||||
|
host = (parts.hostname or "").lower()
|
||||||
|
if host not in allowed_hosts:
|
||||||
|
raise RuntimeError(f"Refusing download from unexpected host: {host}")
|
||||||
|
|
||||||
|
|
||||||
|
class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Require every redirect target to stay HTTPS (blocks an https→http
|
||||||
|
downgrade). The host allowlist is deliberately *not* re-applied on
|
||||||
|
redirects: an allowlisted host controls its own redirects — legitimately
|
||||||
|
to its CDN (e.g. octowow.st→dl.octowow.st, github.com→codeload) — and TLS
|
||||||
|
protects wherever it lands. The allowlist's job is to vet the *initial*
|
||||||
|
URL (against a tampered API response), which secure_urlopen still does."""
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
_check_url(newurl, None) # HTTPS-only, no host check
|
||||||
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared opener with the hardened TLS context and the HTTPS-only redirect
|
||||||
|
# guard, built once.
|
||||||
|
_SECURE_OPENER = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPSHandler(context=SSL_CTX),
|
||||||
|
_HttpsOnlyRedirectHandler())
|
||||||
|
|
||||||
|
|
||||||
|
def secure_urlopen(req, timeout, allowed_hosts=None):
|
||||||
|
"""urlopen wrapper that enforces HTTPS + an optional host allowlist on the
|
||||||
|
initial URL, keeps redirects on HTTPS, and uses the hardened TLS context.
|
||||||
|
`req` may be a URL string or a urllib Request."""
|
||||||
|
url = req.full_url if isinstance(req, urllib.request.Request) else req
|
||||||
|
_check_url(url, allowed_hosts)
|
||||||
|
return _SECURE_OPENER.open(req, timeout=timeout)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Extracted from octo_updater.py. Keep this module focused on its named responsibility."""
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
from .net import secure_urlopen
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: Creates dir and verifies it was made
|
||||||
|
def ensure_dir(path):
|
||||||
|
Path(path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Windows: %LOCALAPPDATA%\octo-updater\
|
||||||
|
# Linux: $XDG_DATA_HOME/octo-updater or $HOME/.local/share/octo-updater
|
||||||
|
def user_data_dir() -> Path:
|
||||||
|
"""Alters which directory is used for application data. Returns Path object to directory"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||||
|
if not local_app_data:
|
||||||
|
raise RuntimeError("LOCALAPPDATA is not set")
|
||||||
|
|
||||||
|
return Path(local_app_data)
|
||||||
|
|
||||||
|
# For linux , will first check for XDG_DATA_HOME"
|
||||||
|
local_app_data = os.environ.get("XDG_DATA_HOME")
|
||||||
|
if local_app_data:
|
||||||
|
return Path(local_app_data) / "OctoUpdater"
|
||||||
|
|
||||||
|
return Path.home() / ".local" / "share" / "OctoUpdater"
|
||||||
|
|
||||||
|
|
||||||
|
# Windows: %APPDATA%\octo-updater
|
||||||
|
# Linux: $XDG_CONFIG_HOME/octo-updater or $HOME/.config/octo-updater
|
||||||
|
def user_config_dir() -> Path:
|
||||||
|
"""Alters which directory is used for application data. Returns Path object to directory"""
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
local_config_data = os.environ.get("APPDATA")
|
||||||
|
if not local_config_data:
|
||||||
|
raise RuntimeError("APPDATA is not set")
|
||||||
|
|
||||||
|
return Path(local_config_data)
|
||||||
|
|
||||||
|
# For linux , will first check for XDG_CONFIG_HOME" , else set to ~/.config/octo-updater
|
||||||
|
local_config_data = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if local_config_data:
|
||||||
|
return Path(local_config_data) / "OctoUpdater"
|
||||||
|
|
||||||
|
return Path.home() / ".config" / "OctoUpdater"
|
||||||
|
|
||||||
|
# NOTE: I think this is for opening links in the launcher ?
|
||||||
|
def open_directory(path: str) -> None:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.Popen(["explorer.exe", path])
|
||||||
|
else:
|
||||||
|
opener = shutil.which("xdg-open") # Point this to default linux browser ?
|
||||||
|
if not opener:
|
||||||
|
raise RuntimeError("xdg-open is required to open directories")
|
||||||
|
subprocess.Popen([opener, path])
|
||||||
|
|
||||||
|
# NOTE: If windows, creates ntfs junction , else , creates symlink
|
||||||
|
def create_directory_link(link: str, target: str) -> None:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
result = subprocess.run(["cmd", "/c", "mklink", "/J", link, target], capture_output=True, text=True, creationflags=flags)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "mklink failed")
|
||||||
|
return
|
||||||
|
os.symlink(target, link, target_is_directory=True)
|
||||||
|
|
||||||
|
# NOTE: allows dynamic scaling of UI window ?
|
||||||
|
def enable_dpi_awareness() -> None:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return
|
||||||
|
import ctypes
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
fn = getattr(user32, "SetProcessDpiAwarenessContext", None)
|
||||||
|
if fn:
|
||||||
|
fn(ctypes.c_void_p(-4))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ctypes.windll.user32.SetProcessDPIAware()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def request_defender_exclusion(path: str) -> bool:
|
||||||
|
"""Request a Windows Defender exclusion. Returns False off Windows."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return False
|
||||||
|
import ctypes
|
||||||
|
escaped = path.replace("'", "''")
|
||||||
|
command = f"Add-MpPreference -ExclusionPath '{escaped}'"
|
||||||
|
result = ctypes.windll.shell32.ShellExecuteW(
|
||||||
|
None, "runas", "powershell.exe",
|
||||||
|
f'-NoProfile -WindowStyle Hidden -Command "{command}"', None, 0)
|
||||||
|
return result > 32
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Find solution to launch client natively on linux , either using wine or bottles ?
|
||||||
|
def launch_executable(executable: str, cwd: str) -> None:
|
||||||
|
"""Launch the managed executable using native process semantics."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
# TODO: implement solution to run game on linux ....
|
||||||
|
raise RuntimeError("native Linux game launching is not configured")
|
||||||
|
flags = (getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
| getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0))
|
||||||
|
try:
|
||||||
|
subprocess.Popen([executable], cwd=cwd, creationflags=flags, close_fds=True)
|
||||||
|
except OSError:
|
||||||
|
flags &= ~getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0)
|
||||||
|
subprocess.Popen([executable], cwd=cwd, creationflags=flags, close_fds=True)
|
||||||
|
|
||||||
|
|
||||||
|
def display_info() -> dict:
|
||||||
|
"""Return primary display dimensions and refresh rate on Windows."""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
raise RuntimeError("display information is not implemented for this platform")
|
||||||
|
import ctypes
|
||||||
|
class DEVMODE(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("dmDeviceName", ctypes.c_wchar * 32), ("dmSpecVersion", ctypes.c_ushort),
|
||||||
|
("dmDriverVersion", ctypes.c_ushort), ("dmSize", ctypes.c_ushort),
|
||||||
|
("dmDriverExtra", ctypes.c_ushort), ("dmFields", ctypes.c_ulong),
|
||||||
|
("dmPositionX", ctypes.c_long), ("dmPositionY", ctypes.c_long),
|
||||||
|
("dmDisplayOrientation", ctypes.c_ulong), ("dmDisplayFixedOutput", ctypes.c_ulong),
|
||||||
|
("dmColor", ctypes.c_short), ("dmDuplex", ctypes.c_short),
|
||||||
|
("dmYResolution", ctypes.c_short), ("dmTTOption", ctypes.c_short),
|
||||||
|
("dmCollate", ctypes.c_short), ("dmFormName", ctypes.c_wchar * 32),
|
||||||
|
("dmLogPixels", ctypes.c_ushort), ("dmBitsPerPel", ctypes.c_ulong),
|
||||||
|
("dmPelsWidth", ctypes.c_ulong), ("dmPelsHeight", ctypes.c_ulong),
|
||||||
|
("dmDisplayFlags", ctypes.c_ulong), ("dmDisplayFrequency", ctypes.c_ulong),
|
||||||
|
]
|
||||||
|
dm = DEVMODE()
|
||||||
|
dm.dmSize = ctypes.sizeof(DEVMODE)
|
||||||
|
ctypes.windll.user32.EnumDisplaySettingsW(None, -1, ctypes.byref(dm))
|
||||||
|
return {"width": dm.dmPelsWidth, "height": dm.dmPelsHeight,
|
||||||
|
"refresh_rate": dm.dmDisplayFrequency}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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,26 @@
|
|||||||
|
import queue
|
||||||
|
from src import app_log
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_enqueues_message_and_tag(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
q = queue.Queue()
|
||||||
|
monkeypatch.setattr(app_log, "LOG_QUEUE", q)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
app_log.log("ready", "ok")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert q.get_nowait() == ("ready", "ok")
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_uses_empty_tag_by_default(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
q = queue.Queue()
|
||||||
|
monkeypatch.setattr(app_log, "LOG_QUEUE", q)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
app_log.log("ready")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert q.get_nowait() == ("ready", "")
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import pytest
|
||||||
|
from src import client
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_wdb_does_nothing_when_directory_is_absent(tmp_path, monkeypatch):
|
||||||
|
log = Mock()
|
||||||
|
monkeypatch.setattr(client, "log", log)
|
||||||
|
client.remove_wdb(str(tmp_path))
|
||||||
|
log.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_wdb_removes_existing_cache(tmp_path, monkeypatch):
|
||||||
|
wdb = tmp_path / "WDB"
|
||||||
|
wdb.mkdir()
|
||||||
|
(wdb / "cache.bin").write_bytes(b"x")
|
||||||
|
log = Mock()
|
||||||
|
monkeypatch.setattr(client, "log", log)
|
||||||
|
|
||||||
|
client.remove_wdb(str(tmp_path))
|
||||||
|
|
||||||
|
assert not wdb.exists()
|
||||||
|
log.assert_called_once_with("WDB cache cleared.", "dim")
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_wdb_logs_failure_without_raising(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "WDB").mkdir()
|
||||||
|
monkeypatch.setattr(client.shutil, "rmtree", Mock(side_effect=PermissionError("locked")))
|
||||||
|
log = Mock()
|
||||||
|
monkeypatch.setattr(client, "log", log)
|
||||||
|
|
||||||
|
client.remove_wdb(str(tmp_path))
|
||||||
|
|
||||||
|
assert "locked" in log.call_args.args[0]
|
||||||
|
assert log.call_args.args[1] == "err"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_version_returns_empty_when_executable_is_missing(tmp_path):
|
||||||
|
assert client.get_client_version(str(tmp_path)) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_version_reads_valid_version_and_build(tmp_path):
|
||||||
|
exe = tmp_path / "WoW.exe"
|
||||||
|
data = bytearray(0x00437c0a)
|
||||||
|
data[0x00437bfc:0x00437c00] = b"5875"
|
||||||
|
data[0x00437c04:0x00437c0a] = b"1.12.1"
|
||||||
|
exe.write_bytes(data)
|
||||||
|
|
||||||
|
assert client.get_client_version(str(tmp_path)) == "1.12.1 (5875)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_client_version_rejects_invalid_version_bytes(tmp_path):
|
||||||
|
exe = tmp_path / "WoW.exe"
|
||||||
|
data = bytearray(0x00437c0a)
|
||||||
|
data[0x00437bfc:0x00437c00] = b"5875"
|
||||||
|
data[0x00437c04:0x00437c0a] = b"broken"
|
||||||
|
exe.write_bytes(data)
|
||||||
|
|
||||||
|
assert client.get_client_version(str(tmp_path)) == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("value", "expected"), [
|
||||||
|
(0, "0 KB"), (1023, "0 KB"), (1024, "1 KB"),
|
||||||
|
(1024**2, "1.0 MB"), (1024**3, "1.00 GB"),
|
||||||
|
])
|
||||||
|
def test_fmt_size_formats_boundaries(value, expected):
|
||||||
|
assert client.fmt_size(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmt_size_truncates_gigabytes_instead_of_rounding_up():
|
||||||
|
assert client.fmt_size(8.7998 * 1024**3) == "8.79 GB"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("value", "expected"), [
|
||||||
|
(0, "0 KB/s"), (1024, "1 KB/s"),
|
||||||
|
(1024**2 - 1, "1024 KB/s"), (1024**2, "1.0 MB/s"),
|
||||||
|
])
|
||||||
|
def test_fmt_speed_formats_boundaries(value, expected):
|
||||||
|
assert client.fmt_speed(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_patches_defaults_unknown_locale_to_english():
|
||||||
|
assert client.locale_patches("not-a-locale") == client.locale_patches("enUS")
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_patches_renames_reused_slot_for_russian():
|
||||||
|
patches = client.locale_patches("ruRU")
|
||||||
|
assert patches[-1] == (client._LOCALE_NAME_OFFSET(5), b"ruRU")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_config_wtf_creates_config_when_missing(tmp_path, monkeypatch):
|
||||||
|
writer = Mock()
|
||||||
|
monkeypatch.setattr(client, "write_config_wtf", writer)
|
||||||
|
tweaks = {"farClip": 500}
|
||||||
|
|
||||||
|
client.update_config_wtf(str(tmp_path), tweaks)
|
||||||
|
|
||||||
|
writer.assert_called_once_with(str(tmp_path), tweaks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_config_wtf_updates_existing_values_and_preserves_unrelated_lines(tmp_path, monkeypatch):
|
||||||
|
cfg_dir = tmp_path / "WTF"
|
||||||
|
cfg_dir.mkdir()
|
||||||
|
cfg = cfg_dir / "Config.wtf"
|
||||||
|
cfg.write_text('SET farClip "100"\nSET untouched "yes"\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(client, "log", Mock())
|
||||||
|
|
||||||
|
client.update_config_wtf(str(tmp_path), {"farClip": 777, "cameraDistance": 50,
|
||||||
|
"nameplateRange": 41, "fieldOfView": 110, "soundInBackground": True})
|
||||||
|
|
||||||
|
text = cfg.read_text(encoding="utf-8")
|
||||||
|
assert 'SET farClip "777"' in text
|
||||||
|
assert 'SET untouched "yes"' in text
|
||||||
|
assert 'SET CameraDistanceMax "50"' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_fov_default_for_display_uses_lowest_reference_for_narrow_display(monkeypatch):
|
||||||
|
monkeypatch.setattr(client, "_get_display_info_safe", lambda: {"width": 1024, "height": 1000})
|
||||||
|
assert client.fov_default_for_display() == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_fov_default_for_display_uses_highest_reference_for_ultrawide(monkeypatch):
|
||||||
|
monkeypatch.setattr(client, "_get_display_info_safe", lambda: {"width": 5120, "height": 1080})
|
||||||
|
assert client.fov_default_for_display() == 180
|
||||||
|
|
||||||
|
|
||||||
|
def test_fov_default_for_display_falls_back_to_16_9_when_display_lookup_fails(monkeypatch):
|
||||||
|
monkeypatch.setattr(client, "_get_display_info_safe", Mock(side_effect=RuntimeError("display unavailable")))
|
||||||
|
assert client.fov_default_for_display() == 110
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_tweaks_config_merges_stored_values_with_defaults(monkeypatch):
|
||||||
|
monkeypatch.setattr(client, "load_config", lambda: {"tweaks": {"farClip": 999}})
|
||||||
|
monkeypatch.setattr(client, "fov_default_for_display", lambda: 150)
|
||||||
|
|
||||||
|
result = client.load_tweaks_config()
|
||||||
|
|
||||||
|
assert result["farClip"] == 999
|
||||||
|
assert result["fieldOfView"] == 150
|
||||||
|
assert result["cameraDistance"] == client.TWEAKS_DEFAULTS["cameraDistance"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_tweaks_config_stores_values(monkeypatch):
|
||||||
|
cfg = {}
|
||||||
|
monkeypatch.setattr(client, "update_config", lambda fn: fn(cfg))
|
||||||
|
values = {"farClip": 900}
|
||||||
|
|
||||||
|
client.save_tweaks_config(values)
|
||||||
|
|
||||||
|
assert cfg["tweaks"] == values
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
|||||||
|
import errno
|
||||||
|
import urllib.error
|
||||||
|
import zipfile
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import pytest
|
||||||
|
from src import mods
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("value", "expected"), [
|
||||||
|
("v1.2.0", (1, 2, 0)), ("V2.5-beta3", (2, 53)),
|
||||||
|
("", (0,)), ("release", (0,)),
|
||||||
|
])
|
||||||
|
def test_parse_version_extracts_numeric_components(value, expected):
|
||||||
|
assert mods._parse_version(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_updater_update_available_returns_false_without_latest_tag():
|
||||||
|
assert mods.updater_update_available("") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_updater_update_available_zero_pads_equivalent_versions(monkeypatch):
|
||||||
|
monkeypatch.setattr(mods, "UPDATER_VERSION", "1.2.0")
|
||||||
|
assert mods.updater_update_available("v1.2") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_updater_update_available_detects_newer_version(monkeypatch):
|
||||||
|
monkeypatch.setattr(mods, "UPDATER_VERSION", "1.2.0")
|
||||||
|
assert mods.updater_update_available("v1.2.1") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_asset_returns_first_matching_asset():
|
||||||
|
assets = [{"name": "mod.txt"}, {"name": "mod.zip"}]
|
||||||
|
assert mods._pick_asset(assets, "*.zip", None) == assets[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_asset_prefers_name_without_excluded_text():
|
||||||
|
assets = [{"name": "mod-dxvk.zip"}, {"name": "mod.zip"}]
|
||||||
|
assert mods._pick_asset(assets, "*.zip", "-dxvk") == assets[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_asset_returns_none_when_nothing_matches():
|
||||||
|
assert mods._pick_asset([{"name": "readme.txt"}], "*.zip", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_version_uses_tag_by_default():
|
||||||
|
mod = {"source": {"kind": "github_release"}}
|
||||||
|
assert mods._release_version(mod, {"tag_name": "v2.0"}) == "v2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_version_can_extract_version_from_asset_name():
|
||||||
|
mod = {"source": {"version_from": "asset", "asset_pattern": "*.zip", "prefer_no": None}}
|
||||||
|
rel = {"assets": [{"name": "SuperWoW-1.4.2.zip"}], "tag_name": "static"}
|
||||||
|
assert mods._release_version(mod, rel) == "1.4.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_slim_release_discards_unused_release_fields():
|
||||||
|
rel = {"tag_name": "v1", "body": "large", "assets": [{"name": "x.zip", "size": 12,
|
||||||
|
"browser_download_url": "https://example/x", "extra": "drop"}]}
|
||||||
|
assert mods._slim_release(rel) == {"tag_name": "v1", "assets": [{"name": "x.zip", "size": 12,
|
||||||
|
"browser_download_url": "https://example/x"}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_dll_creates_dlls_file(tmp_path):
|
||||||
|
mods.add_dll(str(tmp_path), "Test.dll")
|
||||||
|
assert (tmp_path / "dlls.txt").read_text() == "Test.dll\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_dll_does_not_add_case_insensitive_duplicate(tmp_path):
|
||||||
|
path = tmp_path / "dlls.txt"
|
||||||
|
path.write_text("Test.dll\n")
|
||||||
|
mods.add_dll(str(tmp_path), "test.DLL")
|
||||||
|
assert path.read_text() == "Test.dll\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_dll_removes_named_entry_case_insensitively(tmp_path):
|
||||||
|
path = tmp_path / "dlls.txt"
|
||||||
|
path.write_text("One.dll\nTwo.dll\n")
|
||||||
|
mods.remove_dll(str(tmp_path), "ONE.DLL")
|
||||||
|
assert path.read_text() == "Two.dll\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_dll_deletes_empty_dlls_file(tmp_path):
|
||||||
|
path = tmp_path / "dlls.txt"
|
||||||
|
path.write_text("One.dll\n")
|
||||||
|
mods.remove_dll(str(tmp_path), "one.dll")
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_dll_does_nothing_when_file_is_missing(tmp_path):
|
||||||
|
mods.remove_dll(str(tmp_path), "missing.dll")
|
||||||
|
assert not (tmp_path / "dlls.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_installed_files_present_requires_nonempty_installed_file_list(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(mods, "load_config", lambda: {"mods": {"m": {"installed_files": []}}})
|
||||||
|
assert mods.mod_installed_files_present({"id": "m"}, str(tmp_path)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_installed_files_present_returns_true_when_every_file_exists(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "a.dll").write_text("")
|
||||||
|
(tmp_path / "b.dll").write_text("")
|
||||||
|
monkeypatch.setattr(mods, "load_config", lambda: {"mods": {"m": {"installed_files": ["a.dll", "b.dll"]}}})
|
||||||
|
assert mods.mod_installed_files_present({"id": "m"}, str(tmp_path)) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["direct_file", "direct_tar"])
|
||||||
|
def test_mod_supports_update_check_rejects_pinned_direct_sources(kind):
|
||||||
|
assert mods.mod_supports_update_check({"source": {"kind": kind}}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_update_available_requires_enabled_mod():
|
||||||
|
mod = {"source": {"kind": "github_release"}}
|
||||||
|
state = {"enabled": False, "installed_version": "1"}
|
||||||
|
assert mods.mod_update_available(mod, state, {"latest_version": "2"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_update_available_respects_ignore_updates():
|
||||||
|
mod = {"source": {"kind": "github_release"}}
|
||||||
|
state = {"enabled": True, "ignore_updates": True, "installed_version": "1"}
|
||||||
|
assert mods.mod_update_available(mod, state, {"latest_version": "2"}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mod_update_available_detects_different_live_version():
|
||||||
|
mod = {"source": {"kind": "github_release"}}
|
||||||
|
state = {"enabled": True, "installed_version": "1"}
|
||||||
|
assert mods.mod_update_available(mod, state, {"latest_version": "2"}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_git_repo_ignores_case_trailing_slash_and_dot_git():
|
||||||
|
assert mods._same_git_repo("https://github.com/Owner/Repo.git/", "https://github.com/owner/repo") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"https://github.com/owner/repo", "https://sub.gitlab.com/owner/repo",
|
||||||
|
"https://codeberg.org/owner/repo",
|
||||||
|
])
|
||||||
|
def test_is_allowed_git_url_accepts_supported_https_hosts(url):
|
||||||
|
assert mods.is_allowed_git_url(url) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", ["http://github.com/a/b", "https://example.com/a/b", "not a url"])
|
||||||
|
def test_is_allowed_git_url_rejects_unsupported_urls(url):
|
||||||
|
assert mods.is_allowed_git_url(url) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_slim_addon_catalog_keeps_only_supported_fields():
|
||||||
|
catalog = [{"name": "A", "git": "g", "branch": "main", "ref": None, "description": "d",
|
||||||
|
"extra": 1, "toc": {"Title": "T", "Notes": "N", "Interface": "11200", "Other": "drop"}}]
|
||||||
|
result = mods._slim_addon_catalog(catalog)
|
||||||
|
assert result == [{"name": "A", "git": "g", "branch": "main", "ref": None, "description": "d",
|
||||||
|
"toc": {"Title": "T", "Notes": "N", "Interface": "11200"}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_toc_file_parses_metadata_and_ignores_other_lines(tmp_path):
|
||||||
|
path = tmp_path / "Addon.toc"
|
||||||
|
path.write_text("\ufeff## Title: My Addon\nfile.lua\n## Interface: 11200\n", encoding="utf-8")
|
||||||
|
assert mods.read_toc_file(str(path)) == {"Title": "My Addon", "Interface": "11200"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_toc_file_returns_empty_dict_when_missing(tmp_path):
|
||||||
|
assert mods.read_toc_file(str(tmp_path / "missing.toc")) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_wow_colored_splits_colored_and_plain_segments():
|
||||||
|
text = "plain |cffff0000red|r normal"
|
||||||
|
assert mods.parse_wow_colored(text) == [("plain ", None), ("red", "#ff0000"), (" normal", None)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_wow_colors_removes_color_escape_sequences():
|
||||||
|
assert mods.strip_wow_colors("A |cff00ff00green|r B") == "A green B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_parts_parses_github_dot_git_url():
|
||||||
|
kind, repo_url, owner, repo, api = mods._git_parts("https://github.com/Owner/Repo.git")
|
||||||
|
assert (kind, repo_url, owner, repo) == ("github", "https://github.com/Owner/Repo", "Owner", "Repo")
|
||||||
|
assert api == mods.GITHUB_API
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_parts_parses_prefixed_gitea_url():
|
||||||
|
kind, repo_url, owner, repo, api = mods._git_parts("https://octowow.st/git/Microchip/octo-updater")
|
||||||
|
assert kind == "gitea"
|
||||||
|
assert repo_url == "https://octowow.st/git/Microchip/octo-updater"
|
||||||
|
assert api == "https://octowow.st/git/api/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_parts_rejects_url_without_owner_and_repo():
|
||||||
|
with pytest.raises(ValueError, match="Unsupported git URL"):
|
||||||
|
mods._git_parts("https://github.com/onlyone")
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_net_error_maps_http_403_to_rate_limit_message():
|
||||||
|
e = urllib.error.HTTPError("https://api.github.com/x", 403, "Forbidden", {}, None)
|
||||||
|
assert "rate limit" in mods._describe_net_error(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_net_error_maps_http_404_to_not_found_message():
|
||||||
|
e = urllib.error.HTTPError("https://api.github.com/x", 404, "Not Found", {}, None)
|
||||||
|
assert mods._describe_net_error(e) == "repository or branch not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_install_error_maps_antivirus_like_os_errors():
|
||||||
|
e = OSError(errno.EACCES, "denied")
|
||||||
|
assert "antivirus" in mods.describe_install_error(e).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_install_error_maps_bad_zip_file():
|
||||||
|
assert "corrupted" in mods.describe_install_error(zipfile.BadZipFile()).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_cached_sha_returns_cached_value_without_network(monkeypatch):
|
||||||
|
key = "https://github.com/a/b#main"
|
||||||
|
monkeypatch.setattr(mods, "load_config", lambda: {"addon_sha_cache": {key: {"sha": "abc"}}})
|
||||||
|
assert mods.addon_cached_sha("https://github.com/a/b", branch="main") == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_zip_url_builds_github_archive_url():
|
||||||
|
assert mods.addon_zip_url("https://github.com/a/b", "abc") == "https://github.com/a/b/archive/abc.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_zip_url_builds_gitlab_archive_url():
|
||||||
|
assert mods.addon_zip_url("https://gitlab.com/a/b", "abc") == "https://gitlab.com/a/b/-/archive/abc/b-abc.zip"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import urllib.request
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import pytest
|
||||||
|
from src import net
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_url_accepts_https_when_no_allowlist():
|
||||||
|
net._check_url("https://example.com/file", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_url_rejects_http():
|
||||||
|
with pytest.raises(RuntimeError, match="non-HTTPS"):
|
||||||
|
net._check_url("http://example.com/file", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_url_accepts_allowlisted_host_case_insensitively():
|
||||||
|
net._check_url("https://EXAMPLE.COM/file", {"example.com"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_url_rejects_host_not_in_allowlist():
|
||||||
|
with pytest.raises(RuntimeError, match="unexpected host"):
|
||||||
|
net._check_url("https://evil.example/file", {"example.com"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_url_does_not_treat_subdomain_as_exact_allowlist_match():
|
||||||
|
with pytest.raises(RuntimeError, match="unexpected host"):
|
||||||
|
net._check_url("https://cdn.example.com/file", {"example.com"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_secure_urlopen_opens_valid_url_with_timeout(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
response = object()
|
||||||
|
opener = Mock()
|
||||||
|
opener.open.return_value = response
|
||||||
|
monkeypatch.setattr(net, "_SECURE_OPENER", opener)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = net.secure_urlopen("https://example.com/file", 7, {"example.com"})
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result is response
|
||||||
|
opener.open.assert_called_once_with("https://example.com/file", timeout=7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_secure_urlopen_validates_request_full_url_before_opening(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
opener = Mock()
|
||||||
|
monkeypatch.setattr(net, "_SECURE_OPENER", opener)
|
||||||
|
req = urllib.request.Request("http://example.com/file")
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(RuntimeError, match="non-HTTPS"):
|
||||||
|
net.secure_urlopen(req, 5)
|
||||||
|
opener.open.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_handler_rejects_https_to_http_downgrade():
|
||||||
|
handler = net._HttpsOnlyRedirectHandler()
|
||||||
|
req = urllib.request.Request("https://example.com/start")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="non-HTTPS"):
|
||||||
|
handler.redirect_request(req, None, 302, "Found", {}, "http://example.com/end")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from unittest.mock import Mock
|
||||||
|
import octo_updater
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_prepares_platform_and_data_before_starting_ui(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(octo_updater, "enable_dpi_awareness", lambda: calls.append("dpi"))
|
||||||
|
monkeypatch.setattr(octo_updater, "_relocate_legacy_data", lambda: calls.append("relocate"))
|
||||||
|
app = Mock()
|
||||||
|
monkeypatch.setattr(octo_updater, "OctoUpdaterApp", lambda: calls.append("construct") or app)
|
||||||
|
|
||||||
|
octo_updater.main()
|
||||||
|
|
||||||
|
assert calls == ["dpi", "relocate", "construct"]
|
||||||
|
app.mainloop.assert_called_once_with()
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, call
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import platform_ops
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_ARIA2_SHA256 = (
|
||||||
|
"35f6514cc5dd7e98a87b3c4c2d25a075"
|
||||||
|
"4b9b063dbe59bc0f22d483464f61e5b6"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def make_aria2_zip(
|
||||||
|
exe_data=b"fake aria2c executable",
|
||||||
|
exe_name="aria2-1.37.0-win-32bit-build1/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):
|
||||||
|
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):
|
||||||
|
digest = Mock()
|
||||||
|
digest.hexdigest.return_value = EXPECTED_ARIA2_SHA256
|
||||||
|
|
||||||
|
sha256 = Mock(return_value=digest)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.hashlib,
|
||||||
|
"sha256",
|
||||||
|
sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
return sha256
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ensure_dir
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ensure_dir_creates_directory(tmp_path):
|
||||||
|
target = tmp_path / "test-directory"
|
||||||
|
|
||||||
|
platform_ops.ensure_dir(target)
|
||||||
|
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_creates_parent_directories(tmp_path):
|
||||||
|
target = tmp_path / "one" / "two" / "three"
|
||||||
|
|
||||||
|
platform_ops.ensure_dir(target)
|
||||||
|
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_existing_directory_does_not_fail(tmp_path):
|
||||||
|
target = tmp_path / "existing"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
|
platform_ops.ensure_dir(target)
|
||||||
|
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_accepts_string_path(tmp_path):
|
||||||
|
target = tmp_path / "string-path"
|
||||||
|
|
||||||
|
platform_ops.ensure_dir(str(target))
|
||||||
|
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# find_aria2c
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_find_aria2c_returns_discovered_path(monkeypatch):
|
||||||
|
expected = "/usr/bin/aria2c"
|
||||||
|
|
||||||
|
which = Mock(return_value=expected)
|
||||||
|
monkeypatch.setattr(platform_ops.shutil, "which", which)
|
||||||
|
|
||||||
|
result = platform_ops.find_aria2c()
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
which.assert_called_once_with("aria2c")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_aria2c_returns_none_when_missing(monkeypatch):
|
||||||
|
which = Mock(return_value=None)
|
||||||
|
monkeypatch.setattr(platform_ops.shutil, "which", which)
|
||||||
|
|
||||||
|
result = platform_ops.find_aria2c()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
which.assert_called_once_with("aria2c")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_aria2c_returns_windows_path_unchanged(monkeypatch):
|
||||||
|
expected = r"C:\Program Files\aria2\aria2c.exe"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.shutil,
|
||||||
|
"which",
|
||||||
|
Mock(return_value=expected),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert platform_ops.find_aria2c() == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# install_aria2c_windows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_installs_executable(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
exe_data = b"fake aria2 executable"
|
||||||
|
archive = make_aria2_zip(exe_data)
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = tmp_path / "aria2c.exe"
|
||||||
|
|
||||||
|
assert Path(result) == expected
|
||||||
|
assert expected.read_bytes() == exe_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_returns_aria2_path(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Path(result) == tmp_path / "aria2c.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_uses_expected_url(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, **kwargs):
|
||||||
|
captured["url"] = req.full_url
|
||||||
|
return make_response(archive)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
fake_urlopen,
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["url"] == (
|
||||||
|
"https://github.com/aria2/aria2/releases/download/"
|
||||||
|
"release-1.37.0/aria2-1.37.0-win-32bit-build1.zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_uses_60_second_timeout(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
urlopen = Mock(return_value=make_response(archive))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
urlopen,
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = urlopen.call_args
|
||||||
|
|
||||||
|
assert kwargs["timeout"] == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_passes_allowed_hosts(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
urlopen = Mock(return_value=make_response(archive))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
urlopen,
|
||||||
|
)
|
||||||
|
|
||||||
|
hosts = {
|
||||||
|
"github.com",
|
||||||
|
"objects.githubusercontent.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS=hosts,
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = urlopen.call_args
|
||||||
|
|
||||||
|
assert kwargs["allowed_hosts"] is hosts
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_reads_response(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
response = make_response(archive)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=response),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.read.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_hashes_downloaded_archive(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
sha256 = allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sha256.assert_called_once_with(archive)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_rejects_bad_checksum(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
digest = Mock()
|
||||||
|
digest.hexdigest.return_value = "0" * 64
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.hashlib,
|
||||||
|
"sha256",
|
||||||
|
Mock(return_value=digest),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="aria2 checksum mismatch",
|
||||||
|
):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (tmp_path / "aria2c.exe").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_checksum_error_contains_digest_prefix(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
bad_digest = "123456789abc" + ("0" * 52)
|
||||||
|
|
||||||
|
digest = Mock()
|
||||||
|
digest.hexdigest.return_value = bad_digest
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.hashlib,
|
||||||
|
"sha256",
|
||||||
|
Mock(return_value=digest),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="123456789abc",
|
||||||
|
):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_rejects_archive_without_executable(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(buffer, "w") as zf:
|
||||||
|
zf.writestr("README.txt", b"no executable")
|
||||||
|
|
||||||
|
archive = buffer.getvalue()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="aria2c.exe not found",
|
||||||
|
):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_finds_nested_executable(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
exe_data = b"nested aria2 executable"
|
||||||
|
|
||||||
|
archive = make_aria2_zip(
|
||||||
|
exe_data=exe_data,
|
||||||
|
exe_name="release/bin/aria2c.exe",
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
tmp_path / "aria2c.exe"
|
||||||
|
).read_bytes() == exe_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_executable_search_is_case_insensitive(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
exe_data = b"uppercase executable"
|
||||||
|
|
||||||
|
archive = make_aria2_zip(
|
||||||
|
exe_data=exe_data,
|
||||||
|
exe_name="release/ARIA2C.EXE",
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
tmp_path / "aria2c.exe"
|
||||||
|
).read_bytes() == exe_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_calls_ensure_dir(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
real_ensure_dir = platform_ops.ensure_dir
|
||||||
|
ensure_dir = Mock(side_effect=real_ensure_dir)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"ensure_dir",
|
||||||
|
ensure_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_dir.assert_called_once_with(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_ensure_dir_creates_destination(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
destination = tmp_path / "nested" / "app-data"
|
||||||
|
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=destination,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert destination.is_dir()
|
||||||
|
assert (destination / "aria2c.exe").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_writes_part_file_before_replace(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
exe_data = b"aria2 executable"
|
||||||
|
archive = make_aria2_zip(exe_data)
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
real_replace = os.replace
|
||||||
|
observed = {}
|
||||||
|
|
||||||
|
def inspect_replace(src, dst):
|
||||||
|
observed["src"] = src
|
||||||
|
observed["dst"] = dst
|
||||||
|
observed["part_data"] = Path(src).read_bytes()
|
||||||
|
return real_replace(src, dst)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.os,
|
||||||
|
"replace",
|
||||||
|
inspect_replace,
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Path(observed["src"]) == tmp_path / "aria2c.exe.part"
|
||||||
|
assert Path(observed["dst"]) == tmp_path / "aria2c.exe"
|
||||||
|
assert observed["part_data"] == exe_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_removes_part_file_after_success(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (tmp_path / "aria2c.exe.part").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_replaces_existing_executable(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
executable = tmp_path / "aria2c.exe"
|
||||||
|
executable.write_bytes(b"old")
|
||||||
|
|
||||||
|
archive = make_aria2_zip(
|
||||||
|
exe_data=b"new",
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert executable.read_bytes() == b"new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_logs_download_and_success(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = Mock()
|
||||||
|
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert logger.call_args_list == [
|
||||||
|
call(
|
||||||
|
"Fetching aria2c (one-time, ~2.5 MB)…",
|
||||||
|
"acct",
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
"aria2c ready.",
|
||||||
|
"ok",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_does_not_log_ready_after_checksum_failure(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
digest = Mock()
|
||||||
|
digest.hexdigest.return_value = "0" * 64
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.hashlib,
|
||||||
|
"sha256",
|
||||||
|
Mock(return_value=digest),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = Mock()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert call("aria2c ready.", "ok") not in logger.call_args_list
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_propagates_network_failure(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(
|
||||||
|
side_effect=OSError(
|
||||||
|
"network unavailable"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
OSError,
|
||||||
|
match="network unavailable",
|
||||||
|
):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_rejects_invalid_zip(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
invalid_zip = b"this is not a zip archive"
|
||||||
|
|
||||||
|
allow_fake_zip_checksum(monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(invalid_zip)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(zipfile.BadZipFile):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_aria2c_windows_does_not_call_ensure_dir_on_bad_checksum(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
archive = make_aria2_zip()
|
||||||
|
|
||||||
|
digest = Mock()
|
||||||
|
digest.hexdigest.return_value = "0" * 64
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops.hashlib,
|
||||||
|
"sha256",
|
||||||
|
Mock(return_value=digest),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"secure_urlopen",
|
||||||
|
Mock(return_value=make_response(archive)),
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_dir = Mock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_ops,
|
||||||
|
"ensure_dir",
|
||||||
|
ensure_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
platform_ops.install_aria2c_windows(
|
||||||
|
APP_DATA_DIR=tmp_path,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS={"github.com"},
|
||||||
|
log_fn=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
ensure_dir.assert_not_called()
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import torrent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ensure_aria2c
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ensure_aria2c_returns_existing_executable(monkeypatch):
|
||||||
|
expected = "/usr/bin/aria2c"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=expected),
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = Mock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
installer,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = torrent.ensure_aria2c()
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
installer.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_aria2c_windows_returns_installed_binary(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
expected = r"C:\aria2\aria2c.exe"
|
||||||
|
monkeypatch.setattr(torrent.sys, "platform", "win32")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=expected),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert torrent.ensure_aria2c() == expected
|
||||||
|
|
||||||
|
def test_ensure_aria2c_windows_installs_when_binary_is_missing(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent.sys,
|
||||||
|
"platform",
|
||||||
|
"win32",
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = Mock(
|
||||||
|
return_value="/fake/aria2c.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
installer,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = torrent.ensure_aria2c()
|
||||||
|
|
||||||
|
assert result == "/fake/aria2c.exe"
|
||||||
|
|
||||||
|
installer.assert_called_once_with(
|
||||||
|
APP_DATA_DIR=torrent.APP_DATA_DIR,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS=torrent.ALLOWED_DOWNLOAD_HOSTS,
|
||||||
|
log_fn=torrent.log,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ensure_aria2c_linux_returns_installed_binary(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent.sys,
|
||||||
|
"platform",
|
||||||
|
"linux",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value="/usr/bin/aria2c"),
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = Mock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
installer,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = torrent.ensure_aria2c()
|
||||||
|
|
||||||
|
assert result == "/usr/bin/aria2c"
|
||||||
|
installer.assert_not_called()
|
||||||
|
|
||||||
|
def test_ensure_aria2c_linux_raises_when_not_installed(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent.sys,
|
||||||
|
"platform",
|
||||||
|
"linux",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = Mock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
installer,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="aria2c required but not installed",
|
||||||
|
):
|
||||||
|
torrent.ensure_aria2c()
|
||||||
|
|
||||||
|
installer.assert_not_called()
|
||||||
|
|
||||||
|
def test_ensure_aria2c_passes_custom_logger(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr(torrent.sys, "platform", "win32")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = Mock(
|
||||||
|
return_value="/fake/aria2c.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
installer,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = Mock()
|
||||||
|
|
||||||
|
torrent.ensure_aria2c(log_fn=logger)
|
||||||
|
|
||||||
|
installer.assert_called_once_with(
|
||||||
|
APP_DATA_DIR=torrent.APP_DATA_DIR,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS=torrent.ALLOWED_DOWNLOAD_HOSTS,
|
||||||
|
log_fn=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_aria2c_empty_path_triggers_install(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr(torrent.sys, "platform", "win32")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
installer = Mock(
|
||||||
|
return_value="/fake/aria2c.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
installer,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = torrent.ensure_aria2c()
|
||||||
|
|
||||||
|
assert result == "/fake/aria2c.exe"
|
||||||
|
installer.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_aria2c_propagates_installer_failure(monkeypatch):
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr(torrent.sys, "platform", "win32")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"find_aria2c",
|
||||||
|
Mock(return_value=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"install_aria2c_windows",
|
||||||
|
Mock(
|
||||||
|
side_effect=RuntimeError(
|
||||||
|
"installation failed"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
RuntimeError,
|
||||||
|
match="installation failed",
|
||||||
|
):
|
||||||
|
torrent.ensure_aria2c()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# torrent_version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_torrent_version_returns_sha1():
|
||||||
|
raw = b"torrent data"
|
||||||
|
|
||||||
|
assert torrent.torrent_version(raw) == hashlib.sha1(raw).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_version_changes_when_data_changes():
|
||||||
|
assert (
|
||||||
|
torrent.torrent_version(b"one")
|
||||||
|
!= torrent.torrent_version(b"two")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# torrent_selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_torrent_selection_empty_when_files_match(tmp_path):
|
||||||
|
file = tmp_path / "file.bin"
|
||||||
|
file.write_bytes(b"1234")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["file.bin"], 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
need, missing = torrent.torrent_selection(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert need == []
|
||||||
|
assert missing is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_selection_marks_missing_file(tmp_path):
|
||||||
|
files = [
|
||||||
|
(["missing.bin"], 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
need, missing = torrent.torrent_selection(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert need == [1]
|
||||||
|
assert missing is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_selection_marks_short_file(tmp_path):
|
||||||
|
file = tmp_path / "file.bin"
|
||||||
|
file.write_bytes(b"12")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["file.bin"], 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
need, missing = torrent.torrent_selection(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert need == [1]
|
||||||
|
assert missing is False
|
||||||
|
assert file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_selection_removes_oversized_file(tmp_path):
|
||||||
|
file = tmp_path / "file.bin"
|
||||||
|
file.write_bytes(b"123456")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["file.bin"], 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
need, missing = torrent.torrent_selection(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert need == [1]
|
||||||
|
assert missing is False
|
||||||
|
assert not file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_selection_drop_mismatched_removes_short_file(tmp_path):
|
||||||
|
file = tmp_path / "file.bin"
|
||||||
|
file.write_bytes(b"12")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["file.bin"], 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
need, missing = torrent.torrent_selection(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
drop_mismatched=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert need == [1]
|
||||||
|
assert missing is False
|
||||||
|
assert not file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# torrent_tree_intact
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_torrent_tree_intact_returns_true_for_matching_files(tmp_path):
|
||||||
|
(tmp_path / "one.bin").write_bytes(b"123")
|
||||||
|
(tmp_path / "two.bin").write_bytes(b"12345")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["one.bin"], 3),
|
||||||
|
(["two.bin"], 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert torrent.torrent_tree_intact(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_tree_intact_returns_false_for_missing_file(tmp_path):
|
||||||
|
files = [
|
||||||
|
(["missing.bin"], 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert torrent.torrent_tree_intact(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_torrent_tree_intact_returns_false_for_wrong_size(tmp_path):
|
||||||
|
(tmp_path / "file.bin").write_bytes(b"12")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["file.bin"], 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert torrent.torrent_tree_intact(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shield / unshield
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_unshield_protected_files_restores_backup(tmp_path):
|
||||||
|
original = tmp_path / "protected.dll"
|
||||||
|
backup = tmp_path / "protected.dll.octobak"
|
||||||
|
|
||||||
|
backup.write_bytes(b"original")
|
||||||
|
|
||||||
|
restored = torrent.unshield_protected_files([
|
||||||
|
(str(original), str(backup)),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert original.read_bytes() == b"original"
|
||||||
|
assert not backup.exists()
|
||||||
|
assert restored == ["protected.dll"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unshield_removes_stub_for_previously_absent_file(tmp_path):
|
||||||
|
stub = tmp_path / "protected.dll"
|
||||||
|
stub.write_bytes(b"aria partial data")
|
||||||
|
|
||||||
|
restored = torrent.unshield_protected_files([
|
||||||
|
(str(stub), None),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert not stub.exists()
|
||||||
|
assert restored == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_protected_files_restores_octobak(tmp_path):
|
||||||
|
backup = tmp_path / "file.bin.octobak"
|
||||||
|
backup.write_bytes(b"protected")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
(["file.bin"], 9),
|
||||||
|
]
|
||||||
|
|
||||||
|
torrent.recover_protected_files(
|
||||||
|
str(tmp_path),
|
||||||
|
files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
tmp_path / "file.bin"
|
||||||
|
).read_bytes() == b"protected"
|
||||||
|
|
||||||
|
assert not backup.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# clear_torrent_resume_state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_clear_torrent_resume_state_removes_aria2_files(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"TORRENT_STAGING_DIR",
|
||||||
|
str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
aria = tmp_path / "client.aria2"
|
||||||
|
metadata = tmp_path / "client.torrent"
|
||||||
|
unrelated = tmp_path / "keep.txt"
|
||||||
|
|
||||||
|
aria.write_bytes(b"a")
|
||||||
|
metadata.write_bytes(b"b")
|
||||||
|
unrelated.write_bytes(b"c")
|
||||||
|
|
||||||
|
torrent.clear_torrent_resume_state()
|
||||||
|
|
||||||
|
assert not aria.exists()
|
||||||
|
assert not metadata.exists()
|
||||||
|
assert unrelated.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_torrent_resume_state_handles_missing_directory(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
missing = tmp_path / "does-not-exist"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"TORRENT_STAGING_DIR",
|
||||||
|
str(missing),
|
||||||
|
)
|
||||||
|
|
||||||
|
torrent.clear_torrent_resume_state()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_aria_progress
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_aria_progress_download():
|
||||||
|
result = torrent.parse_aria_progress(
|
||||||
|
"1.2GiB/8.8GiB(13%) DL:5.0MiB"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["progress"] == 0.13
|
||||||
|
assert result["bps"] == 5 * 1024 ** 2
|
||||||
|
assert result["checking"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aria_progress_checksum():
|
||||||
|
result = torrent.parse_aria_progress(
|
||||||
|
"0B/1.5GiB(0%) "
|
||||||
|
"[Checksum:#f66a3e 236MiB/1.5GiB(15%)]"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["progress"] == 0.15
|
||||||
|
assert result["done"] == 236 * 1024 ** 2
|
||||||
|
assert result["checking"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aria_progress_without_download_speed():
|
||||||
|
result = torrent.parse_aria_progress(
|
||||||
|
"512MiB/1GiB(50%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["progress"] == 0.5
|
||||||
|
assert result["bps"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_aria_progress_returns_none_for_unrelated_line():
|
||||||
|
assert (
|
||||||
|
torrent.parse_aria_progress(
|
||||||
|
"Download complete"
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# stop_aria2c
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stop_aria2c_terminates_running_process(monkeypatch):
|
||||||
|
proc = Mock()
|
||||||
|
proc.poll.return_value = None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"_active_aria2",
|
||||||
|
proc,
|
||||||
|
)
|
||||||
|
|
||||||
|
torrent.stop_aria2c()
|
||||||
|
|
||||||
|
proc.terminate.assert_called_once_with()
|
||||||
|
assert torrent._active_aria2 is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_aria2c_does_not_terminate_finished_process(monkeypatch):
|
||||||
|
proc = Mock()
|
||||||
|
proc.poll.return_value = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"_active_aria2",
|
||||||
|
proc,
|
||||||
|
)
|
||||||
|
|
||||||
|
torrent.stop_aria2c()
|
||||||
|
|
||||||
|
proc.terminate.assert_not_called()
|
||||||
|
assert torrent._active_aria2 is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_aria2c_handles_no_process(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
torrent,
|
||||||
|
"_active_aria2",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
torrent.stop_aria2c()
|
||||||
|
|
||||||
|
assert torrent._active_aria2 is None
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
from unittest.mock import Mock
|
||||||
|
from src import ui
|
||||||
|
|
||||||
|
|
||||||
|
class JsonResponse(io.BytesIO):
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *args): return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_html_removes_tags_scripts_and_decodes_entities():
|
||||||
|
raw = '<p>Hello & goodbye</p><script>bad()</script><br><b>World</b>'
|
||||||
|
assert ui._strip_html(raw) == "Hello & goodbye\n\nWorld"
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_html_formats_list_items_as_bullets():
|
||||||
|
assert ui._strip_html("<ul><li>One</li><li>Two</li></ul>") == "• One\n\n• Two"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_news_date_formats_valid_iso_date():
|
||||||
|
assert ui._format_news_date("2026-09-27T12:30:00+00:00") == "27 Sep 2026"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_news_date_returns_original_invalid_value():
|
||||||
|
assert ui._format_news_date("not-a-date") == "not-a-date"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_patch_notes_returns_items_newest_first(monkeypatch):
|
||||||
|
payload = {"items": [{"id": 1, "date": "2026-01-01"}, {"id": 2, "date": "2026-09-01"}]}
|
||||||
|
opener = Mock(return_value=JsonResponse(json.dumps(payload).encode()))
|
||||||
|
monkeypatch.setattr(ui, "secure_urlopen", opener)
|
||||||
|
|
||||||
|
result = ui.fetch_patch_notes()
|
||||||
|
|
||||||
|
assert [x["id"] for x in result] == [2, 1]
|
||||||
|
assert opener.call_args.kwargs["timeout"] == ui.NEWS_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_patch_notes_returns_empty_list_when_items_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(ui, "secure_urlopen", Mock(return_value=JsonResponse(b"{}")))
|
||||||
|
assert ui.fetch_patch_notes() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_featured_post_returns_valid_post(monkeypatch):
|
||||||
|
payload = {"id": 7, "title": "News"}
|
||||||
|
monkeypatch.setattr(ui, "secure_urlopen", Mock(return_value=JsonResponse(json.dumps(payload).encode())))
|
||||||
|
assert ui.fetch_featured_post() == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_featured_post_returns_none_when_id_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(ui, "secure_urlopen", Mock(return_value=JsonResponse(b'{"title":"News"}')))
|
||||||
|
assert ui.fetch_featured_post() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_featured_post_returns_none_for_non_object_json(monkeypatch):
|
||||||
|
monkeypatch.setattr(ui, "secure_urlopen", Mock(return_value=JsonResponse(b'[]')))
|
||||||
|
assert ui.fetch_featured_post() is None
|
||||||
+419
@@ -0,0 +1,419 @@
|
|||||||
|
"""Torrent metadata, file selection, protected-file handling, and aria2c execution."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
import sys
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
from .app_log import log
|
||||||
|
from .config import APP_DATA_DIR, UA, DOWNLOAD_TIMEOUT
|
||||||
|
from .mods import MODS_REGISTRY
|
||||||
|
from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
|
||||||
|
from .platform_ops import create_directory_link, ensure_dir, find_aria2c, install_aria2c_windows
|
||||||
|
|
||||||
|
CLIENT_TORRENT_URL = "https://dl.octowow.st/download/client.torrent"
|
||||||
|
|
||||||
|
# NOTE: Windows specific ZIP file
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
_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."""
|
||||||
|
ARIA2C_PATH = find_aria2c() # NOTE: Returns a path to a aria2c executable , otherwise this var is None
|
||||||
|
|
||||||
|
if ARIA2C_PATH: # NOTE: If result from find_aria2c() isn't None , returns path
|
||||||
|
return ARIA2C_PATH
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform.startswith("win"): # NOTE: Checks if platform is windows
|
||||||
|
return install_aria2c_windows( APP_DATA_DIR=APP_DATA_DIR,
|
||||||
|
ALLOWED_DOWNLOAD_HOSTS=ALLOWED_DOWNLOAD_HOSTS,
|
||||||
|
log_fn=log_fn)
|
||||||
|
|
||||||
|
raise RuntimeError("aria2c required but not installed") # NOTE: If neither windows or linux path is returned , throws runtime error
|
||||||
|
|
||||||
|
|
||||||
|
def _bdecode(buf: bytes, pos: int = 0):
|
||||||
|
"""Minimal bencode decoder → (value, next_pos). Strings stay bytes."""
|
||||||
|
ch = buf[pos]
|
||||||
|
if ch == 0x69: # i<int>e
|
||||||
|
end = buf.index(b"e", pos)
|
||||||
|
return int(buf[pos + 1:end]), end + 1
|
||||||
|
if ch == 0x6c: # l<items>e
|
||||||
|
lst, p = [], pos + 1
|
||||||
|
while buf[p] != 0x65:
|
||||||
|
v, p = _bdecode(buf, p)
|
||||||
|
lst.append(v)
|
||||||
|
return lst, p + 1
|
||||||
|
if ch == 0x64: # d<pairs>e
|
||||||
|
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) # <len>:<bytes>
|
||||||
|
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
|
||||||
|
|
||||||
|
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 _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
|
||||||
|
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}")
|
||||||
Reference in New Issue
Block a user