""" Third-party tool registry. Manages install, update, downgrade, and uninstall of independently-versioned tools that Jackify either invokes directly (Tier 1) or makes available for users to run from MO2 (Tier 2). Each tool stores a manifest at: $jackify_data_dir/tools//manifest.json TTW_Linux_Installer is a special case: it has a pre-existing handler with its own config keys. The registry reads those keys for status display and delegates install/update to the existing handler rather than managing storage itself. """ import json import logging import os import re import tarfile import zipfile from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional, Tuple import requests from jackify.shared.paths import get_jackify_data_dir logger = logging.getLogger(__name__) TOOLS_BASE_DIR = get_jackify_data_dir() / "tools" GITHUB_API = "https://api.github.com/repos/{repo}/releases/{ref}" @dataclass class ToolDefinition: tool_id: str display_name: str description: str github_repo: str # e.g. "SulfurNitride/CLF3" asset_patterns: List[str] # ordered list of regex patterns to match release asset filename tier: int # 1 = Jackify invokes it, 2 = user runs it themselves executable_names: List[str] = field(default_factory=list) pinned_version: Optional[str] = None # None = always use latest can_uninstall: bool = True # False for tools Jackify hard-depends on @dataclass class ToolStatus: definition: ToolDefinition installed: bool installed_version: Optional[str] previous_version: Optional[str] binary_path: Optional[Path] latest_version: Optional[str] = None update_available: bool = False @property def can_downgrade(self) -> bool: prev_dir = TOOLS_BASE_DIR / self.definition.tool_id / "_previous" return self.previous_version is not None and prev_dir.exists() # --------------------------------------------------------------------------- # Tool catalogue # --------------------------------------------------------------------------- TOOL_DEFINITIONS: List[ToolDefinition] = [ ToolDefinition( tool_id="ttw_installer", display_name="TTW Linux Installer", description="Automates Tale of Two Wastelands installation on Linux. Required for the TTW workflow.", github_repo="SulfurNitride/TTW_Linux_Installer", asset_patterns=[r"universal-mpi-installer.*\.(zip|tar\.gz)"], executable_names=["mpi_installer", "ttw_linux_gui"], tier=1, can_uninstall=False, ), ToolDefinition( tool_id="clf3", display_name="CLF3", description="Rust-based Wabbajack file handler. Planned as an experimental engine alternative.", github_repo="SulfurNitride/CLF3", asset_patterns=[r"clf3.*linux.*x86_64", r"clf3.*\.tar\.gz", r"clf3.*\.zip"], executable_names=["clf3"], tier=1, can_uninstall=True, ), ToolDefinition( tool_id="fluorine", display_name="Fluorine Manager", description="Linux-native MO2 port with FUSE-based VFS and built-in Rootbuilder support.", github_repo="SulfurNitride/Fluorine-Manager", asset_patterns=[r"fluorine.*\.appimage", r"fluorine.*\.tar\.gz", r"fluorine.*\.zip"], executable_names=["Fluorine", "fluorine"], tier=2, ), ToolDefinition( tool_id="bodyslide", display_name="BodySlide (Linux Port)", description="BodySlide and Outfit Studio ported to Linux. For body/outfit mesh conversion.", github_repo="SulfurNitride/BodySlide-and-Outfit-Studio-Linux-Port", asset_patterns=[r"bodyslide.*linux.*\.(appimage|tar\.gz|zip)", r".*bodyslide.*\.(tar\.gz|zip)"], executable_names=["BodySlide", "BodySlide_x64"], tier=2, ), ToolDefinition( tool_id="radium", display_name="Radium Textures", description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.", github_repo="SulfurNitride/Radium-Textures", asset_patterns=[r"radium.*linux.*x86_64", r"radium.*\.tar\.gz", r"radium.*\.zip"], executable_names=["radium", "radium-textures"], tier=2, ), ] _TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_DEFINITIONS} # --------------------------------------------------------------------------- # Manifest helpers # --------------------------------------------------------------------------- def _manifest_path(tool_id: str) -> Path: return TOOLS_BASE_DIR / tool_id / "manifest.json" def _read_manifest(tool_id: str) -> dict: mp = _manifest_path(tool_id) if mp.exists(): try: return json.loads(mp.read_text()) except Exception: pass return {} def _write_manifest(tool_id: str, data: dict) -> None: mp = _manifest_path(tool_id) mp.parent.mkdir(parents=True, exist_ok=True) mp.write_text(json.dumps(data, indent=2)) # --------------------------------------------------------------------------- # TTW bridge - reads existing config keys written by TTWInstallerHandler # --------------------------------------------------------------------------- def _ttw_status_from_config() -> Tuple[bool, Optional[str], Optional[Path]]: """Return (installed, version, binary_path) by reading TTWInstallerHandler config.""" try: from jackify.backend.handlers.config_handler import ConfigHandler cfg = ConfigHandler() version = cfg.get("ttw_installer_version") install_path_str = cfg.get("ttw_installer_install_path") if not install_path_str: return False, None, None install_dir = Path(install_path_str) for exe_name in ["mpi_installer", "ttw_linux_gui"]: exe = install_dir / exe_name if exe.is_file(): return True, str(version) if version else None, exe return False, None, None except Exception as e: logger.debug("TTW config read failed: %s", e) return False, None, None # --------------------------------------------------------------------------- # GitHub release fetching # --------------------------------------------------------------------------- def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] = None) -> Optional[dict]: """Fetch release metadata from GitHub API. Returns parsed JSON or None on failure.""" if pinned_version: tags = [pinned_version, f"v{pinned_version}"] if not pinned_version.startswith("v") else [pinned_version] for tag in tags: url = GITHUB_API.format(repo=github_repo, ref=f"tags/{tag}") try: resp = requests.get(url, timeout=10, verify=True) if resp.status_code == 200: return resp.json() except Exception as e: logger.debug("GitHub fetch error for %s@%s: %s", github_repo, tag, e) return None url = GITHUB_API.format(repo=github_repo, ref="latest") try: resp = requests.get(url, timeout=10, verify=True) resp.raise_for_status() return resp.json() except Exception as e: logger.debug("GitHub fetch error for %s: %s", github_repo, e) return None def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]: assets = release_data.get("assets", []) for pattern in asset_patterns: for asset in assets: if re.search(pattern, asset.get("name", ""), re.IGNORECASE): return asset return None # --------------------------------------------------------------------------- # Core install logic (shared across all non-TTW tools) # --------------------------------------------------------------------------- def _download_and_extract(tool_id: str, asset: dict, target_dir: Path) -> Tuple[bool, str]: """Download a release asset and extract it into target_dir.""" from jackify.backend.handlers.filesystem_handler import FileSystemHandler fs = FileSystemHandler() asset_name = asset.get("name", "") download_url = asset.get("browser_download_url", "") if not download_url: return False, "Asset has no download URL" temp_path = target_dir / asset_name logger.info("Downloading %s", asset_name) if not fs.download_file(download_url, temp_path, overwrite=True, quiet=True): return False, f"Download failed: {asset_name}" try: name_lower = asset_name.lower() is_archive = False if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): is_archive = True with tarfile.open(temp_path, "r:gz") as tf: tf.extractall(path=target_dir) elif name_lower.endswith(".zip"): is_archive = True with zipfile.ZipFile(temp_path, "r") as zf: zf.extractall(path=target_dir) elif name_lower.endswith(".appimage"): temp_path.chmod(0o755) else: return False, f"Unsupported archive format: {asset_name}" finally: if is_archive: try: temp_path.unlink(missing_ok=True) except Exception: pass return True, "" def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]: for exe_name in tool_def.executable_names: direct = search_dir / exe_name if direct.is_file(): return direct for found in search_dir.rglob(exe_name): if found.is_file(): return found # AppImage pattern for found in search_dir.rglob(f"{exe_name}*.AppImage"): if found.is_file(): return found return None # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- class ToolRegistry: """Read/write interface to the managed tool store.""" def get_status(self, tool_id: str) -> Optional[ToolStatus]: defn = _TOOL_MAP.get(tool_id) if defn is None: return None return self._build_status(defn) def get_all_statuses(self) -> List[ToolStatus]: return [self._build_status(d) for d in TOOL_DEFINITIONS] def check_latest_version(self, tool_id: str) -> Optional[str]: """Fetch latest tag from GitHub. Returns tag string or None.""" defn = _TOOL_MAP.get(tool_id) if defn is None: return None data = fetch_latest_release_info(defn.github_repo, defn.pinned_version) if data: return data.get("tag_name") or data.get("name") return None def install(self, tool_id: str) -> Tuple[bool, str]: defn = _TOOL_MAP.get(tool_id) if defn is None: return False, f"Unknown tool: {tool_id}" if tool_id == "ttw_installer": return self._install_ttw() install_dir = TOOLS_BASE_DIR / tool_id install_dir.mkdir(parents=True, exist_ok=True) data = fetch_latest_release_info(defn.github_repo, defn.pinned_version) if not data: return False, f"Could not fetch release info for {defn.display_name}" asset = _find_asset(data, defn.asset_patterns) if not asset: all_names = [a.get("name", "") for a in data.get("assets", [])] return False, f"No matching asset found. Available: {', '.join(all_names)}" tag = data.get("tag_name") or data.get("name", "unknown") ok, err = _download_and_extract(tool_id, asset, install_dir) if not ok: return False, err exe_path = _find_executable(defn, install_dir) if exe_path: try: os.chmod(exe_path, 0o755) except Exception: pass manifest = _read_manifest(tool_id) _write_manifest(tool_id, { "installed_version": tag, "previous_version": manifest.get("installed_version"), "binary_path": str(exe_path) if exe_path else None, "install_dir": str(install_dir), }) logger.info("Installed %s %s", defn.display_name, tag) return True, f"{defn.display_name} {tag} installed" def update(self, tool_id: str) -> Tuple[bool, str]: """Update to latest release. Saves current as previous for downgrade.""" defn = _TOOL_MAP.get(tool_id) if defn is None: return False, f"Unknown tool: {tool_id}" if tool_id == "ttw_installer": return self._install_ttw() manifest = _read_manifest(tool_id) current_dir = TOOLS_BASE_DIR / tool_id prev_dir = TOOLS_BASE_DIR / tool_id / "_previous" # Back up current install before overwriting if current_dir.exists() and manifest.get("installed_version"): import shutil try: if prev_dir.exists(): shutil.rmtree(prev_dir) # Copy current files (excluding _previous subdir) to _previous prev_dir.mkdir(parents=True, exist_ok=True) for item in current_dir.iterdir(): if item.name == "_previous": continue dest = prev_dir / item.name if item.is_file(): shutil.copy2(item, dest) elif item.is_dir(): shutil.copytree(item, dest) except Exception as e: logger.warning("Could not back up previous version of %s: %s", tool_id, e) ok, msg = self.install(tool_id) if ok and manifest.get("installed_version"): # Preserve previous_version in manifest (install() sets it from current manifest) updated_manifest = _read_manifest(tool_id) updated_manifest["previous_version"] = manifest.get("installed_version") _write_manifest(tool_id, updated_manifest) return ok, msg def downgrade(self, tool_id: str) -> Tuple[bool, str]: """Swap current install with the backed-up previous version.""" defn = _TOOL_MAP.get(tool_id) if defn is None: return False, f"Unknown tool: {tool_id}" if tool_id == "ttw_installer": return False, "Downgrade not supported for TTW Linux Installer via this interface" import shutil current_dir = TOOLS_BASE_DIR / tool_id prev_dir = TOOLS_BASE_DIR / tool_id / "_previous" if not prev_dir.exists(): return False, f"No previous version stored for {defn.display_name}" manifest = _read_manifest(tool_id) current_version = manifest.get("installed_version") previous_version = manifest.get("previous_version") # Swap: move current out, move previous in swap_dir = TOOLS_BASE_DIR / tool_id / "_swap" try: if swap_dir.exists(): shutil.rmtree(swap_dir) swap_dir.mkdir(parents=True) for item in current_dir.iterdir(): if item.name in ("_previous", "_swap"): continue shutil.move(str(item), str(swap_dir / item.name)) for item in prev_dir.iterdir(): shutil.move(str(item), str(current_dir / item.name)) # Put what was current into _previous if prev_dir.exists(): shutil.rmtree(prev_dir) prev_dir.mkdir() for item in swap_dir.iterdir(): shutil.move(str(item), str(prev_dir / item.name)) shutil.rmtree(swap_dir, ignore_errors=True) except Exception as e: return False, f"Downgrade failed: {e}" exe_path = _find_executable(defn, current_dir) if exe_path: try: os.chmod(exe_path, 0o755) except Exception: pass _write_manifest(tool_id, { "installed_version": previous_version, "previous_version": current_version, "binary_path": str(exe_path) if exe_path else None, "install_dir": str(current_dir), }) logger.info("Downgraded %s from %s to %s", defn.display_name, current_version, previous_version) return True, f"{defn.display_name} downgraded to {previous_version}" def uninstall(self, tool_id: str) -> Tuple[bool, str]: defn = _TOOL_MAP.get(tool_id) if defn is None: return False, f"Unknown tool: {tool_id}" if not defn.can_uninstall: return False, f"{defn.display_name} cannot be uninstalled - Jackify depends on it" import shutil tool_dir = TOOLS_BASE_DIR / tool_id if tool_dir.exists(): try: shutil.rmtree(tool_dir) except Exception as e: return False, f"Uninstall failed: {e}" logger.info("Uninstalled %s", defn.display_name) return True, f"{defn.display_name} uninstalled" def get_binary_path(self, tool_id: str) -> Optional[Path]: """Return the installed binary path for a Tier 1 tool, or None.""" if tool_id == "ttw_installer": _, _, binary = _ttw_status_from_config() return binary manifest = _read_manifest(tool_id) bp = manifest.get("binary_path") if bp: p = Path(bp) if p.is_file(): return p return None # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _build_status(self, defn: ToolDefinition) -> ToolStatus: if defn.tool_id == "ttw_installer": installed, version, binary = _ttw_status_from_config() return ToolStatus( definition=defn, installed=installed, installed_version=version, previous_version=None, binary_path=binary, ) manifest = _read_manifest(defn.tool_id) installed_version = manifest.get("installed_version") binary_path_str = manifest.get("binary_path") binary_path = Path(binary_path_str) if binary_path_str else None installed = installed_version is not None and (binary_path is None or binary_path.is_file()) return ToolStatus( definition=defn, installed=installed, installed_version=installed_version, previous_version=manifest.get("previous_version"), binary_path=binary_path, ) def _install_ttw(self) -> Tuple[bool, str]: """Delegate TTW install to the existing handler.""" try: from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler fs = FileSystemHandler() cfg = ConfigHandler() handler = TTWInstallerHandler( steamdeck=False, verbose=False, filesystem_handler=fs, config_handler=cfg, ) return handler.install_ttw_installer() except Exception as e: return False, f"TTW install failed: {e}"