mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
This commit is contained in:
@@ -1,16 +1,9 @@
|
||||
"""
|
||||
Third-party tool registry.
|
||||
Third-party tool registry: install, update, downgrade, and uninstall.
|
||||
|
||||
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.
|
||||
Tool state is stored at $jackify_data_dir/tools/<tool_id>/manifest.json.
|
||||
TTW_Linux_Installer installs into $jackify_data_dir/tools/ttw_installer/ and
|
||||
delegates the download/extract to TTWInstallerHandler.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -32,7 +25,6 @@ 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
|
||||
@@ -44,6 +36,14 @@ class ToolDefinition:
|
||||
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
|
||||
can_downgrade: bool = True # False for pinned tools where version must not change
|
||||
is_engine: bool = False # Engine cards show Set Active instead of Launch
|
||||
can_launch: bool = False # Tool has a launchable binary the user runs directly
|
||||
nexus_mod_id: Optional[int] = None # Nexus mod ID; premium users download from Nexus first
|
||||
nexus_game_domain: str = "site" # Nexus game domain for site-wide tools
|
||||
nexus_file_filter: Optional[str] = None # Substring filter to pick the right Nexus file
|
||||
hidden: bool = False # Set true in manifest to suppress display and installs
|
||||
include_prereleases: bool = False # If True, newest release by date (inc. pre-releases) is used
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,15 +58,37 @@ class ToolStatus:
|
||||
|
||||
@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()
|
||||
return (
|
||||
self.installed
|
||||
and self.definition.can_downgrade
|
||||
and self.definition.pinned_version is None
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
ToolDefinition(
|
||||
tool_id="jackify-engine",
|
||||
display_name="jackify-engine",
|
||||
description="Native Wabbajack-matched file handler. The proven, stable engine for modlist installs.",
|
||||
github_repo="Omni-guides/dev-jackify-engine",
|
||||
asset_patterns=[r"jackify-engine.*linux.*x64.*\.tar\.gz", r"jackify-engine.*\.tar\.gz", r"jackify-engine.*\.zip"],
|
||||
executable_names=["jackify-engine"],
|
||||
tier=1,
|
||||
can_uninstall=False,
|
||||
is_engine=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="clf3",
|
||||
display_name="CLF3",
|
||||
description="Rust-based Wabbajack file handler. Faster installs, slightly slower modlist updates than jackify-engine.",
|
||||
github_repo="SulfurNitride/CLF3",
|
||||
asset_patterns=[r"clf3.*linux.*x86_64.*\.tar\.gz", r"clf3.*\.tar\.gz", r"clf3.*\.zip"],
|
||||
executable_names=["clf3"],
|
||||
tier=1,
|
||||
can_uninstall=True,
|
||||
is_engine=True,
|
||||
include_prereleases=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="ttw_installer",
|
||||
display_name="TTW Linux Installer",
|
||||
@@ -75,58 +97,130 @@ TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
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,
|
||||
can_launch=True,
|
||||
pinned_version="0.0.7", # must match TTW_INSTALLER_PINNED_VERSION in ttw_installer_handler.py
|
||||
nexus_mod_id=1657,
|
||||
nexus_file_filter="mpi",
|
||||
),
|
||||
ToolDefinition(
|
||||
tool_id="radium",
|
||||
display_name="Radium Textures",
|
||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation.",
|
||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.",
|
||||
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,
|
||||
can_launch=True,
|
||||
nexus_mod_id=1660,
|
||||
nexus_file_filter="linux",
|
||||
),
|
||||
]
|
||||
|
||||
_TOOL_MAP: Dict[str, ToolDefinition] = {t.tool_id: t for t in TOOL_DEFINITIONS}
|
||||
|
||||
ENGINE_TOOL_IDS: List[str] = [t.tool_id for t in TOOL_DEFINITIONS if t.is_engine]
|
||||
_DEFAULT_ENGINE = "jackify-engine"
|
||||
_ACTIVE_ENGINE_CONFIG_KEY = "active_engine"
|
||||
|
||||
|
||||
def get_active_engine_id() -> str:
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
val = ConfigHandler().get(_ACTIVE_ENGINE_CONFIG_KEY, _DEFAULT_ENGINE)
|
||||
return val if val in ENGINE_TOOL_IDS else _DEFAULT_ENGINE
|
||||
except Exception:
|
||||
return _DEFAULT_ENGINE
|
||||
|
||||
def set_active_engine_id(tool_id: str) -> None:
|
||||
if tool_id not in ENGINE_TOOL_IDS:
|
||||
raise ValueError(f"Not an engine: {tool_id}")
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
cfg = ConfigHandler()
|
||||
cfg.set(_ACTIVE_ENGINE_CONFIG_KEY, tool_id)
|
||||
cfg.save_config()
|
||||
except Exception as e:
|
||||
logger.warning("Could not persist active engine selection: %s", e)
|
||||
|
||||
|
||||
# -- remote manifest ---------------------------------------------------------
|
||||
TOOL_MANIFEST_URL = "https://raw.githubusercontent.com/Omni-guides/Jackify/main/tools_manifest.json"
|
||||
_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "tools_manifest.json"
|
||||
|
||||
|
||||
def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]:
|
||||
definitions = []
|
||||
for entry in entries:
|
||||
try:
|
||||
definitions.append(ToolDefinition(
|
||||
tool_id=entry["tool_id"],
|
||||
display_name=entry["display_name"],
|
||||
description=entry["description"],
|
||||
github_repo=entry["github_repo"],
|
||||
asset_patterns=entry["asset_patterns"],
|
||||
tier=entry.get("tier", 2),
|
||||
executable_names=entry.get("executable_names", []),
|
||||
pinned_version=entry.get("pinned_version"),
|
||||
can_uninstall=entry.get("can_uninstall", True),
|
||||
can_downgrade=entry.get("can_downgrade", True),
|
||||
is_engine=entry.get("is_engine", False),
|
||||
can_launch=entry.get("can_launch", False),
|
||||
nexus_mod_id=entry.get("nexus_mod_id"),
|
||||
nexus_game_domain=entry.get("nexus_game_domain", "site"),
|
||||
nexus_file_filter=entry.get("nexus_file_filter"),
|
||||
hidden=entry.get("hidden", False),
|
||||
))
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.warning("Skipping malformed manifest entry: %s", e)
|
||||
return definitions if definitions else None
|
||||
|
||||
|
||||
def _load_bundled_manifest() -> Optional[List[ToolDefinition]]:
|
||||
try:
|
||||
with open(_BUNDLED_MANIFEST_PATH, "r", encoding="utf-8") as fh:
|
||||
entries = json.load(fh)
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
return _parse_manifest_entries(entries)
|
||||
except Exception as e:
|
||||
logger.debug("Bundled manifest load failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
_manifest_cache: Optional[List[ToolDefinition]] = _load_bundled_manifest()
|
||||
|
||||
|
||||
def fetch_remote_manifest() -> Optional[List[ToolDefinition]]:
|
||||
"""Fetch the remote tool manifest. Returns parsed definitions or None on failure."""
|
||||
try:
|
||||
resp = requests.get(TOOL_MANIFEST_URL, timeout=8, verify=True)
|
||||
resp.raise_for_status()
|
||||
entries = resp.json()
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
return _parse_manifest_entries(entries)
|
||||
except Exception as e:
|
||||
logger.debug("Tool manifest fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
def get_effective_definitions() -> List[ToolDefinition]:
|
||||
"""Remote manifest definitions if fetched this session, else baked-in TOOL_DEFINITIONS."""
|
||||
source = _manifest_cache if _manifest_cache is not None else TOOL_DEFINITIONS
|
||||
return [d for d in source if not d.hidden]
|
||||
|
||||
def apply_remote_manifest(definitions: List[ToolDefinition]) -> None:
|
||||
"""Store fetched manifest as session cache and rebuild the tool map."""
|
||||
global _manifest_cache, _TOOL_MAP
|
||||
_manifest_cache = definitions
|
||||
_TOOL_MAP = {t.tool_id: t for t in 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():
|
||||
@@ -143,34 +237,25 @@ def _write_manifest(tool_id: str, data: dict) -> None:
|
||||
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
|
||||
search_dirs = [
|
||||
TOOLS_BASE_DIR / "ttw_installer",
|
||||
get_jackify_data_dir() / "TTW_Linux_Installer", # legacy location
|
||||
]
|
||||
for tool_dir in search_dirs:
|
||||
for exe_name in ["ttw_linux_gui", "mpi_installer"]:
|
||||
exe = tool_dir / exe_name
|
||||
if exe.is_file():
|
||||
manifest = _read_manifest("ttw_installer")
|
||||
version = manifest.get("installed_version")
|
||||
return True, version, exe
|
||||
return False, None, None
|
||||
except Exception as e:
|
||||
logger.debug("TTW config read failed: %s", e)
|
||||
logger.debug("TTW status check 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:
|
||||
@@ -188,12 +273,27 @@ def fetch_latest_release_info(github_repo: str, pinned_version: Optional[str] =
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=True)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
data = resp.json()
|
||||
tag = data.get("tag_name") or data.get("name", "unknown")
|
||||
logger.info("Latest release for %s: %s", github_repo, tag)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("GitHub fetch error for %s: %s", github_repo, e)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_release_list(github_repo: str, max_count: int = 10) -> List[dict]:
|
||||
"""Return a list of release dicts (tag_name, name, published_at) from GitHub, newest first."""
|
||||
url = f"https://api.github.com/repos/{github_repo}/releases?per_page={max_count}"
|
||||
try:
|
||||
resp = requests.get(url, timeout=10, verify=True)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("Release list fetch failed for %s: %s", github_repo, e)
|
||||
return []
|
||||
|
||||
|
||||
def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]:
|
||||
assets = release_data.get("assets", [])
|
||||
for pattern in asset_patterns:
|
||||
@@ -203,48 +303,149 @@ def _find_asset(release_data: dict, asset_patterns: List[str]) -> Optional[dict]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core install logic (shared across all non-TTW tools)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _find_sums_asset(release_data: dict, asset_name: str) -> Optional[dict]:
|
||||
"""Find a .SHA256SUMS release asset that covers the given filename."""
|
||||
assets = release_data.get("assets", [])
|
||||
stem = asset_name.rsplit(".", 2)[0] if asset_name.endswith(".tar.gz") else Path(asset_name).stem
|
||||
for asset in assets:
|
||||
name = asset.get("name", "")
|
||||
if name.endswith(".SHA256SUMS") and stem in name:
|
||||
return asset
|
||||
for asset in assets:
|
||||
if asset.get("name", "").endswith(".SHA256SUMS"):
|
||||
return asset
|
||||
return None
|
||||
|
||||
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."""
|
||||
|
||||
def _verify_sha256_sums(sums_path: Path, target_path: Path) -> Tuple[bool, str]:
|
||||
"""Parse a SHA256SUMS file and verify target_path. Format: 'hash filename'."""
|
||||
import hashlib
|
||||
try:
|
||||
expected_hash = None
|
||||
for line in sums_path.read_text().strip().splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == target_path.name:
|
||||
expected_hash = parts[0].lower()
|
||||
break
|
||||
if not expected_hash:
|
||||
return False, f"No entry for {target_path.name} in SHA256SUMS file"
|
||||
sha256 = hashlib.sha256()
|
||||
with open(target_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
sha256.update(chunk)
|
||||
actual = sha256.hexdigest().lower()
|
||||
if actual != expected_hash:
|
||||
return False, f"SHA256 mismatch for {target_path.name}"
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, f"SHA256 verification error: {e}"
|
||||
|
||||
|
||||
def _extract_archive(file_path: Path, target_dir: Path) -> Tuple[bool, str]:
|
||||
"""Extract an archive or chmod an AppImage in place. Removes the archive on success."""
|
||||
name_lower = file_path.name.lower()
|
||||
is_archive = False
|
||||
try:
|
||||
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
||||
is_archive = True
|
||||
with tarfile.open(file_path, "r:gz") as tf:
|
||||
tf.extractall(path=target_dir)
|
||||
elif name_lower.endswith(".zip"):
|
||||
is_archive = True
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
zf.extractall(path=target_dir)
|
||||
elif name_lower.endswith(".appimage"):
|
||||
file_path.chmod(0o755)
|
||||
else:
|
||||
return False, f"Unsupported format: {file_path.name}"
|
||||
finally:
|
||||
if is_archive:
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if is_archive:
|
||||
_chmod_elf_binaries(target_dir)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _chmod_elf_binaries(directory: Path) -> None:
|
||||
"""Set executable bit on any ELF binaries found directly in directory."""
|
||||
ELF_MAGIC = b'\x7fELF'
|
||||
for f in directory.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
with open(f, 'rb') as fh:
|
||||
magic = fh.read(4)
|
||||
if magic == ELF_MAGIC:
|
||||
f.chmod(f.stat().st_mode | 0o111)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _download_and_extract(
|
||||
tool_id: str,
|
||||
asset: dict,
|
||||
target_dir: Path,
|
||||
sums_asset: Optional[dict] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Download a GitHub release asset, optionally verify SHA256, then extract."""
|
||||
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:
|
||||
if sums_asset:
|
||||
sums_url = sums_asset.get("browser_download_url", "")
|
||||
sums_path = target_dir / sums_asset.get("name", "SHA256SUMS")
|
||||
if sums_url and fs.download_file(sums_url, sums_path, overwrite=True, quiet=True):
|
||||
ok, err = _verify_sha256_sums(sums_path, temp_path)
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
sums_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return False, err
|
||||
logger.info("SHA256 verified for %s", asset_name)
|
||||
else:
|
||||
logger.warning("SHA256SUMS download failed for %s, skipping verification", asset_name)
|
||||
return _extract_archive(temp_path, target_dir)
|
||||
|
||||
return True, ""
|
||||
|
||||
def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, Optional[Path], str]:
|
||||
"""Attempt Nexus CDN download for premium users. Returns (success, file_path, message)."""
|
||||
if not defn.nexus_mod_id:
|
||||
return False, None, "No Nexus mod configured"
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
||||
from jackify.backend.services.nexus_download_service import NexusDownloadService
|
||||
auth = NexusAuthService()
|
||||
token = auth.get_auth_token()
|
||||
if not token:
|
||||
return False, None, "No Nexus auth token"
|
||||
is_oauth = auth.get_auth_method() == "oauth"
|
||||
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
|
||||
if not is_premium:
|
||||
return False, None, "Not Nexus Premium"
|
||||
ok, path, msg = NexusDownloadService(token).download_latest_file(
|
||||
defn.nexus_game_domain, defn.nexus_mod_id, target_dir,
|
||||
file_name_filter=defn.nexus_file_filter,
|
||||
)
|
||||
return ok, path, msg
|
||||
except Exception as e:
|
||||
logger.debug("Nexus download attempt failed for %s: %s", defn.tool_id, e)
|
||||
return False, None, str(e)
|
||||
|
||||
|
||||
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
|
||||
@@ -255,17 +456,12 @@ def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Pat
|
||||
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."""
|
||||
|
||||
@@ -276,40 +472,58 @@ class ToolRegistry:
|
||||
return self._build_status(defn)
|
||||
|
||||
def get_all_statuses(self) -> List[ToolStatus]:
|
||||
return [self._build_status(d) for d in TOOL_DEFINITIONS]
|
||||
return [self._build_status(d) for d in get_effective_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 defn.pinned_version:
|
||||
return defn.pinned_version
|
||||
if defn.include_prereleases:
|
||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||
if releases:
|
||||
data = releases[0]
|
||||
return data.get("tag_name") or data.get("name")
|
||||
return None
|
||||
data = fetch_latest_release_info(defn.github_repo)
|
||||
if data:
|
||||
return data.get("tag_name") or data.get("name")
|
||||
return None
|
||||
|
||||
def install(self, tool_id: str) -> Tuple[bool, str]:
|
||||
def install(self, tool_id: str, version: Optional[str] = None) -> Tuple[bool, str]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
|
||||
if defn.hidden:
|
||||
return False, f"{defn.display_name} is not available for install"
|
||||
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}"
|
||||
pin = version or defn.pinned_version
|
||||
nexus_ok, nexus_path, _ = _try_nexus_download(defn, install_dir) if not version else (False, None, "")
|
||||
if nexus_ok and nexus_path:
|
||||
ok, err = _extract_archive(nexus_path, install_dir)
|
||||
tag = pin or "nexus"
|
||||
else:
|
||||
if defn.include_prereleases and not pin:
|
||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||
data = releases[0] if releases else None
|
||||
else:
|
||||
data = fetch_latest_release_info(defn.github_repo, pin)
|
||||
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")
|
||||
sums_asset = _find_sums_asset(data, asset.get("name", ""))
|
||||
ok, err = _download_and_extract(tool_id, asset, install_dir, sums_asset=sums_asset)
|
||||
|
||||
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
|
||||
|
||||
@@ -332,7 +546,6 @@ class ToolRegistry:
|
||||
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}"
|
||||
@@ -447,7 +660,6 @@ class ToolRegistry:
|
||||
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
|
||||
@@ -478,6 +690,21 @@ class ToolRegistry:
|
||||
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())
|
||||
|
||||
if not installed and defn.tool_id == "jackify-engine":
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
bundled = Path(get_jackify_engine_path())
|
||||
if bundled.is_file():
|
||||
installed = True
|
||||
binary_path = bundled
|
||||
if not installed_version:
|
||||
version_file = bundled.parent / "version.txt"
|
||||
if version_file.is_file():
|
||||
installed_version = version_file.read_text().strip() or None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ToolStatus(
|
||||
definition=defn,
|
||||
installed=installed,
|
||||
@@ -487,7 +714,7 @@ class ToolRegistry:
|
||||
)
|
||||
|
||||
def _install_ttw(self) -> Tuple[bool, str]:
|
||||
"""Delegate TTW install to the existing handler."""
|
||||
"""Delegate TTW install to the existing handler, installing into the tools directory."""
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
@@ -498,6 +725,16 @@ class ToolRegistry:
|
||||
steamdeck=False, verbose=False,
|
||||
filesystem_handler=fs, config_handler=cfg,
|
||||
)
|
||||
return handler.install_ttw_installer()
|
||||
install_dir = TOOLS_BASE_DIR / "ttw_installer"
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
ok, msg = handler.install_ttw_installer(install_dir=install_dir)
|
||||
if ok:
|
||||
version = cfg.get("ttw_installer_version") or "unknown"
|
||||
exe_path = _find_executable(_TOOL_MAP["ttw_installer"], install_dir)
|
||||
_write_manifest("ttw_installer", {
|
||||
"installed_version": version,
|
||||
"binary_path": str(exe_path) if exe_path else None,
|
||||
})
|
||||
return ok, msg
|
||||
except Exception as e:
|
||||
return False, f"TTW install failed: {e}"
|
||||
|
||||
Reference in New Issue
Block a user