mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 03:13:44 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""Problem mods service.
|
||||
|
||||
Identifies and disables mods with known Proton compatibility issues by
|
||||
rewriting modlist.txt files atomically.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
|
||||
from jackify.backend.models.game_types import normalize_game_name
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROBLEM_MODS_MANIFEST_URL = (
|
||||
"https://raw.githubusercontent.com/Omni-guides/Jackify/main/manifests/problem_mods.json"
|
||||
)
|
||||
_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "problem_mods_manifest.json"
|
||||
|
||||
_CANONICAL_TO_MANIFEST_KEY = {
|
||||
"skyrim": "SkyrimSE",
|
||||
"skyrimse": "SkyrimSE",
|
||||
"fallout4": "Fallout4",
|
||||
"skyrimvr": "SkyrimVR",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"enderal": "Enderal",
|
||||
}
|
||||
|
||||
|
||||
def _load_bundled_manifest() -> dict:
|
||||
try:
|
||||
with open(_BUNDLED_MANIFEST_PATH, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("Bundled problem mods manifest load failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
_manifest_cache: Optional[dict] = None
|
||||
|
||||
|
||||
def _disk_cache_path() -> Path:
|
||||
return get_jackify_data_dir() / "manifests" / "problem_mods.json"
|
||||
|
||||
|
||||
def _load_disk_cache() -> Optional[dict]:
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_disk_cache(data: dict) -> None:
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".problem_mods_", suffix=".tmp")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2)
|
||||
os.replace(tmp, path)
|
||||
except Exception as e:
|
||||
logger.debug("Problem mods manifest disk save failed: %s", e)
|
||||
|
||||
|
||||
def fetch_remote_manifest() -> Optional[dict]:
|
||||
"""Fetch the remote problem mods manifest. Returns parsed dict or None on failure."""
|
||||
try:
|
||||
resp = requests.get(PROBLEM_MODS_MANIFEST_URL, timeout=8, verify=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("Problem mods manifest fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def apply_remote_manifest(data: dict) -> None:
|
||||
"""Store fetched manifest in memory and persist to disk."""
|
||||
global _manifest_cache
|
||||
_manifest_cache = data
|
||||
_save_disk_cache(data)
|
||||
|
||||
|
||||
def _effective_manifest() -> dict:
|
||||
if _manifest_cache is not None:
|
||||
return _manifest_cache
|
||||
disk = _load_disk_cache()
|
||||
if disk is not None:
|
||||
return disk
|
||||
return _load_bundled_manifest()
|
||||
|
||||
|
||||
def _get_mod_fixes(game_type: str) -> List[dict]:
|
||||
"""Return mod_fixes list for the given game type.
|
||||
|
||||
Handles old format (plain list of mod names = disable-only entries) so
|
||||
cached manifests from before the format change continue to work.
|
||||
"""
|
||||
canonical = normalize_game_name(game_type) or game_type.lower().replace(" ", "")
|
||||
manifest_key = _CANONICAL_TO_MANIFEST_KEY.get(canonical)
|
||||
if not manifest_key:
|
||||
return []
|
||||
raw = _effective_manifest().get(manifest_key, {})
|
||||
if isinstance(raw, list):
|
||||
return [{"mod": name, "disable": True} for name in raw]
|
||||
if isinstance(raw, dict):
|
||||
return raw.get("mod_fixes", [])
|
||||
return []
|
||||
|
||||
|
||||
def get_problem_mods(game_type: str) -> List[str]:
|
||||
"""Return names of mods flagged for disabling for the given game type."""
|
||||
return [fix["mod"] for fix in _get_mod_fixes(game_type) if fix.get("disable")]
|
||||
|
||||
|
||||
def get_enabled_mods(modlist_txt_path: Path) -> set:
|
||||
"""Return lowercase set of enabled mod names from a modlist.txt."""
|
||||
try:
|
||||
lines = modlist_txt_path.read_text(encoding="utf-8").splitlines()
|
||||
except Exception:
|
||||
return set()
|
||||
mods = set()
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("+"):
|
||||
name = stripped[1:]
|
||||
mods.add(name.lower())
|
||||
bare = re.sub(r'^\[.*?\]\s*', '', name)
|
||||
if bare != name:
|
||||
mods.add(bare.lower())
|
||||
return mods
|
||||
|
||||
|
||||
def create_prefix_dirs(wineprefix: Path, game_type: str, enabled_mods: set) -> List[str]:
|
||||
"""Create directories inside a Wine prefix for present problem mods.
|
||||
|
||||
Only acts when the associated mod is actually enabled in the modlist.
|
||||
Paths are relative to drive_c inside the prefix.
|
||||
Returns list of paths created.
|
||||
"""
|
||||
created: List[str] = []
|
||||
for fix in _get_mod_fixes(game_type):
|
||||
if not fix.get("create_dirs"):
|
||||
continue
|
||||
if fix.get("mod", "").lower() not in enabled_mods:
|
||||
continue
|
||||
for rel_path in fix["create_dirs"]:
|
||||
target = wineprefix / "drive_c" / rel_path
|
||||
if target.exists():
|
||||
continue
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Created prefix directory for '%s': %s", fix["mod"], target)
|
||||
created.append(rel_path)
|
||||
except Exception as e:
|
||||
logger.warning("Could not create prefix directory %s: %s", target, e)
|
||||
return created
|
||||
|
||||
|
||||
def disable_problem_mods(modlist_txt_path: Path, game_type: str) -> List[str]:
|
||||
"""Disable problem mods in modlist.txt by prepending '-' to enabled entries.
|
||||
|
||||
Reads modlist.txt, disables any enabled ('+name') line whose name is in the
|
||||
problem list, writes the file back atomically. Idempotent: already-disabled
|
||||
entries are not counted.
|
||||
|
||||
Returns list of mod names actually disabled (empty if none).
|
||||
"""
|
||||
problem_names = get_problem_mods(game_type)
|
||||
if not problem_names:
|
||||
return []
|
||||
|
||||
problem_set = {n.lower() for n in problem_names}
|
||||
|
||||
try:
|
||||
content = modlist_txt_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Could not read modlist.txt at %s: %s", modlist_txt_path, e)
|
||||
return []
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
disabled: List[str] = []
|
||||
new_lines: List[str] = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\r\n")
|
||||
if stripped.startswith("+"):
|
||||
name = stripped[1:]
|
||||
# Strip leading [tag] prefixes (e.g. "[PC] MCM Booster" -> "MCM Booster")
|
||||
bare_name = re.sub(r'^\[.*?\]\s*', '', name)
|
||||
if name.lower() in problem_set or bare_name.lower() in problem_set:
|
||||
eol = line[len(stripped):]
|
||||
new_lines.append(f"-{name}{eol}")
|
||||
disabled.append(name)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
if not disabled:
|
||||
return []
|
||||
|
||||
new_content = "".join(new_lines)
|
||||
try:
|
||||
dir_ = modlist_txt_path.parent
|
||||
fd, tmp_path = tempfile.mkstemp(dir=dir_, prefix=".modlist_", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(new_content)
|
||||
os.replace(tmp_path, modlist_txt_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Could not write modlist.txt at %s: %s", modlist_txt_path, e)
|
||||
return []
|
||||
|
||||
return disabled
|
||||
Reference in New Issue
Block a user