"""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