5 Commits

13 changed files with 303 additions and 55 deletions
+1
View File
@@ -17,3 +17,4 @@ dist/
.idea/
Thumbs.db
.DS_Store
.venv/
+5 -3
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""Octo Updater entry point."""
from config import _relocate_legacy_data
from platform_ops import enable_dpi_awareness
from ui import OctoUpdaterApp
import sys
from src.config import _relocate_legacy_data
from src.platform_ops import enable_dpi_awareness
from src.ui import OctoUpdaterApp
def main() -> None:
enable_dpi_awareness()
View File
View File
+3 -3
View File
@@ -18,9 +18,9 @@ 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
from .app_log import log
from .config import load_config, update_config
from .platform_ops import display_info
def remove_wdb(client_dir: str):
"""Delete the client's WDB folder (server-data cache, safe to drop)."""
+16 -18
View File
@@ -15,8 +15,13 @@ 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"
@@ -28,29 +33,21 @@ DOWNLOAD_TIMEOUT = 10 # seconds without any data before a transfer aborts
# 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))
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__))
APP_DIR = os.path.dirname(os.path.abspath(__file__)) # Directory where python3 script is executed from
def _default_app_data_dir() -> str:
base = os.environ.get("LOCALAPPDATA")
if base:
path = os.path.join(base, "OctoUpdater")
try:
os.makedirs(path, exist_ok=True)
return path
except OSError:
pass
return APP_DIR
# Set env vars
APP_DATA_DIR = user_data_dir()
APP_CONFIG_DIR = user_config_dir()
APP_DATA_DIR = _default_app_data_dir()
CONFIG_FILE = os.path.join(APP_DATA_DIR, "config.json")
CONFIG_FILE = os.path.join(APP_CONFIG_DIR, "config.json") # TODO: Change this to point to ~/.config/OctoUpdater
# First-run default game folder, anchored to the app dir (not the CWD).
DEFAULT_GAME_DIR = os.path.join(APP_DIR, "OctoWoW")
DEFAULT_GAME_DIR = os.path.join(APP_DIR, "OctoWoW") # NOTE: May need to change later to point to other dir
def _relocate_legacy_data():
@@ -81,17 +78,18 @@ def load_config() -> dict:
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"
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:
+3 -3
View File
@@ -21,9 +21,9 @@ from pathlib import Path
import urllib.error
import zipfile
import io
from app_log import log
from config import SERVER, UA, UPDATER_VERSION, load_config, update_config, ensure_dir
from net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
from .app_log import log
from .config import SERVER, UA, UPDATER_VERSION, load_config, update_config, ensure_dir
from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
MODS_REGISTRY = [
{
+1 -1
View File
@@ -18,7 +18,7 @@ import queue
from functools import cache
from pathlib import Path
from config import UA
from .config import UA
+50 -1
View File
@@ -5,15 +5,57 @@ import subprocess
import sys
from pathlib import Path
# 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")
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: Creates link to windows junction for torrent ?
def create_directory_link(link: str, target: str) -> None:
if sys.platform == "win32":
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
@@ -23,6 +65,7 @@ def create_directory_link(link: str, target: str) -> None:
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
@@ -58,9 +101,11 @@ def request_defender_exclusion(path: str) -> bool:
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))
@@ -95,3 +140,7 @@ def display_info() -> dict:
ctypes.windll.user32.EnumDisplaySettingsW(None, -1, ctypes.byref(dm))
return {"width": dm.dmPelsWidth, "height": dm.dmPelsHeight,
"refresh_rate": dm.dmDisplayFrequency}
+146
View File
@@ -0,0 +1,146 @@
import pytest
import json
from pathlib import Path
from src import config
def test_load_config(monkeypatch, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"game_dir": "/games/OctoWoW"})
)
monkeypatch.setattr(config, "CONFIG_FILE", str(config_file))
result = config.load_config()
assert result == {"game_dir": "/games/OctoWoW"}
def test_load_config_missing_file(monkeypatch, tmp_path):
config_file = tmp_path / "does_not_exist.json"
monkeypatch.setattr(config, "CONFIG_FILE", str(config_file))
result = config.load_config()
assert result == {}
def test_load_config_invalid_json(monkeypatch, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text("this is not json")
monkeypatch.setattr(config, "CONFIG_FILE", str(config_file))
result = config.load_config()
assert result == {}
def test_atomic_write(tmp_path):
config_file = tmp_path / "config.json"
config._atomic_write(
str(config_file),
'{"test": true}'
)
assert config_file.exists()
assert config_file.read_text() == '{"test": true}'
assert not Path(str(config_file) + ".tmp").exists()
def test_atomic_write_replaces_existing_file(tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text("old data")
config._atomic_write(
str(config_file),
"new data"
)
assert config_file.read_text() == "new data"
def test_save_config(monkeypatch, tmp_path):
config_file = tmp_path / "config.json"
monkeypatch.setattr(config, "CONFIG_FILE", str(config_file))
config.save_config({
"game_dir": "/games/OctoWoW",
"enabled": True,
})
result = json.loads(config_file.read_text())
assert result == {
"game_dir": "/games/OctoWoW",
"enabled": True,
}
def test_update_config(monkeypatch, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({
"game_dir": "/old/path",
"existing_setting": True,
})
)
monkeypatch.setattr(config, "CONFIG_FILE", str(config_file))
def change_game_dir(cfg):
cfg["game_dir"] = "/new/path"
result = config.update_config(change_game_dir)
assert result["game_dir"] == "/new/path"
assert result["existing_setting"] is True
saved = json.loads(config_file.read_text())
assert saved["game_dir"] == "/new/path"
assert saved["existing_setting"] is True
def test_ensure_dir(tmp_path):
new_directory = tmp_path / "one" / "two" / "three"
config.ensure_dir(new_directory)
assert new_directory.exists()
assert new_directory.is_dir()
def test_relocate_legacy_data(monkeypatch, tmp_path):
app_dir = tmp_path / "app"
data_dir = tmp_path / "data"
config_dir = tmp_path / "config"
app_dir.mkdir()
data_dir.mkdir()
config_dir.mkdir()
old_config = app_dir / "octo_updater_config.json"
new_config = config_dir / "config.json"
old_config.write_text('{"legacy": true}')
monkeypatch.setattr(config, "APP_DIR", str(app_dir))
monkeypatch.setattr(config, "APP_DATA_DIR", str(data_dir))
monkeypatch.setattr(config, "CONFIG_FILE", str(new_config))
config._relocate_legacy_data()
assert not old_config.exists()
assert new_config.exists()
assert json.loads(new_config.read_text()) == {
"legacy": True
}
+45
View File
@@ -0,0 +1,45 @@
import pytest
from pathlib import Path
from src import platform_ops
def test_user_config_dir_windows(monkeypatch):
monkeypatch.setattr(platform_ops.sys, "platform", "win32")
monkeypatch.setenv("APPDATA", r"C:\Users\test\AppData\Roaming")
result = platform_ops.user_config_dir()
assert result == Path(r"C:\Users\test\AppData\Roaming")
def test_user_config_dir_windows_without_appdata(monkeypatch):
monkeypatch.setattr(platform_ops.sys, "platform", "win32")
monkeypatch.delenv("APPDATA", raising=False)
with pytest.raises(RuntimeError, match="APPDATA is not set"):
platform_ops.user_config_dir()
def test_user_config_dir_linux_with_xdg_config_home(monkeypatch):
monkeypatch.setattr(platform_ops.sys, "platform", "linux")
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/test-config")
result = platform_ops.user_config_dir()
assert result == Path("/tmp/test-config") / "octo-updater"
def test_user_config_dir_linux_without_xdg_config_home(monkeypatch):
monkeypatch.setattr(platform_ops.sys, "platform", "linux")
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
monkeypatch.setattr(
platform_ops.Path,
"home",
lambda: Path("/home/testuser"),
)
result = platform_ops.user_config_dir()
assert result == Path("/home/testuser/.config/octo-updater")
+5 -5
View File
@@ -18,11 +18,11 @@ import queue
from functools import cache
from pathlib import Path
from app_log import log
from config import APP_DATA_DIR, UA, DOWNLOAD_RETRY, DOWNLOAD_TIMEOUT, ensure_dir, load_config, update_config
from net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
from client import locale_patches, write_config_wtf, update_config_wtf
from platform_ops import create_directory_link
from .app_log import log
from .config import APP_DATA_DIR, UA, DOWNLOAD_RETRY, DOWNLOAD_TIMEOUT, ensure_dir, load_config, update_config
from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen
from .client import locale_patches, write_config_wtf, update_config_wtf
from .platform_ops import create_directory_link
# Torrent-based client sync (aria2c)
# ──────────────────────────────────────────────────────────────────────────────
+28 -21
View File
@@ -21,18 +21,21 @@ from pathlib import Path
import tkinter as tk
from tkinter import filedialog
import urllib.error
from app_log import LOG_QUEUE as _LOG_Q, log
from config import *
from net import *
from client import *
from torrent import *
from mods import *
from platform_ops import open_directory, launch_executable, request_defender_exclusion
from .app_log import LOG_QUEUE as _LOG_Q, log
#from .config import _relocate_legacy_data
from .net import *
from .client import *
from .torrent import *
from .mods import *
from .platform_ops import open_directory, launch_executable, request_defender_exclusion
from . import config
# News tab: the latest announcement (forum 2, full post) fills the left panel,
# the patch-notes list (forum 4) fills the right.
NEWS_FEATURED_URL = f"{SERVER}/forum/octonews.php?forum=2&mode=full"
PATCHNOTES_URL = f"{SERVER}/forum/octonews.php?mode=list&forum=4&limit=8"
NEWS_FEATURED_URL = f"{config.SERVER}/forum/octonews.php?forum=2&mode=full"
PATCHNOTES_URL = f"{config.SERVER}/forum/octonews.php?mode=list&forum=4&limit=8"
NEWS_TIMEOUT = 8
NEWS_CACHE_TTL = 300
@@ -71,9 +74,11 @@ C_PARCH_LINK = "#a3561c"
C_PARCH_EDGE = "#b7a678"
FONT_BODY = ("Segoe UI", 9)
FONT_MONO = ("TkDefaultFont", 10)
FONT_VER = ("TkDefaultFont", 8)
NEWS_FEATURED_URL = f"{SERVER}/forum/octonews.php?forum=2&mode=full"
PATCHNOTES_URL = f"{SERVER}/forum/octonews.php?mode=list&forum=4&limit=8"
NEWS_FEATURED_URL = f"{config.SERVER}/forum/octonews.php?forum=2&mode=full"
PATCHNOTES_URL = f"{config.SERVER}/forum/octonews.php?mode=list&forum=4&limit=8"
NEWS_TIMEOUT = 8
NEWS_CACHE_TTL = 300
@@ -103,7 +108,8 @@ def _format_news_date(iso: str) -> str:
def fetch_patch_notes() -> list:
"""Patch-notes list → [{id, title, date, body, url?, author?}, …]"""
req = urllib.request.Request(PATCHNOTES_URL, headers={"User-Agent": UA})
#req = urllib.request.Request(PATCHNOTES_URL, headers={"User-Agent": config.UA})
req = urllib.request.Request(PATCHNOTES_URL)
with secure_urlopen(req, timeout=NEWS_TIMEOUT) as r:
data = json.load(r)
items = data.get("items", [])
@@ -115,7 +121,8 @@ def fetch_patch_notes() -> list:
def fetch_featured_post() -> dict | None:
"""Latest announcements-forum post → {id, title, author?, date, url, html}"""
req = urllib.request.Request(NEWS_FEATURED_URL, headers={"User-Agent": UA})
#req = urllib.request.Request(NEWS_FEATURED_URL, headers={"User-Agent": config.UA})
req = urllib.request.Request(NEWS_FEATURED_URL)
with secure_urlopen(req, timeout=NEWS_TIMEOUT) as r:
data = json.load(r)
return data if isinstance(data, dict) and data.get("id") else None
@@ -210,10 +217,10 @@ class OctoUpdaterApp(tk.Tk):
# Move a pre-1.3 config/cache from beside the .exe into the per-user
# data dir before anything reads them, so first-run detection and
# load_config() below see the relocated files (see _relocate_legacy_data).
_relocate_legacy_data()
#config._relocate_legacy_data()
# Detect first run before anything writes the config.
self._first_run = not os.path.exists(CONFIG_FILE)
self._first_run = not os.path.exists(config.CONFIG_FILE)
# Set when the user adds a Defender exclusion via Settings; checked at
# the next reconcile to skip the auto-prompt (so a manual add isn't
# double-prompted), then reset — so each folder change offers one unless
@@ -247,7 +254,7 @@ class OctoUpdaterApp(tk.Tk):
# effect only once Settings is closed (see _close_settings), so no
# live trace fires mid-edit.
self._game_path = tk.StringVar(
value=os.path.normpath(self._cfg.get("out_dir", DEFAULT_GAME_DIR)))
value=os.path.normpath(self._cfg.get("out_dir", config.DEFAULT_GAME_DIR)))
# Count of mods with an update available — shown as a badge on the
# MODS nav tab.
@@ -293,7 +300,7 @@ class OctoUpdaterApp(tk.Tk):
self._build()
out_dir = self._cfg.get("out_dir", DEFAULT_GAME_DIR)
out_dir = self._cfg.get("out_dir", config.DEFAULT_GAME_DIR)
if not os.path.exists(out_dir):
def _wipe(c):
c.pop("mods", None)
@@ -596,13 +603,13 @@ class OctoUpdaterApp(tk.Tk):
hdr.bind("<Leave>", lambda e: self._on_hdr_motion(None))
self._clear_wdb_var = tk.BooleanVar(
value=bool(self._cfg.get("clear_wdb_on_launch", False)))
value=bool(self._cfg.get("clear_wdb_on_launch", True)))
self._close_on_launch_var = tk.BooleanVar(
value=bool(self._cfg.get("close_on_launch", False)))
value=bool(self._cfg.get("close_on_launch", True)))
self._auto_mods_var = tk.BooleanVar(
value=bool(self._cfg.get("auto_install_mods", True)))
value=bool(self._cfg.get("auto_install_mods", False)))
self._auto_addons_var = tk.BooleanVar(
value=bool(self._cfg.get("auto_install_addons", True)))
value=bool(self._cfg.get("auto_install_addons", False)))
self._ignore_speech_var = tk.BooleanVar(
value=bool(self._cfg.get("ignore_speech", False)))
# Deferred "install missing" pending from turning an auto-install