Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
+360 -123
View File
@@ -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}"