mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:07:45 +02:00
504 lines
19 KiB
Python
504 lines
19 KiB
Python
"""
|
|
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/<tool_id>/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}"
|