Files
Jackify/jackify/backend/services/tool_registry.py
T

741 lines
29 KiB
Python

"""
Third-party tool registry: install, update, downgrade, and uninstall.
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
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
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
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:
return (
self.installed
and self.definition.can_downgrade
and self.definition.pinned_version is None
)
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",
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=True,
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. 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}
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))
def _ttw_status_from_config() -> Tuple[bool, Optional[str], Optional[Path]]:
try:
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 status check failed: %s", e)
return False, None, None
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()
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:
for asset in assets:
if re.search(pattern, asset.get("name", ""), re.IGNORECASE):
return asset
return None
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 _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}"
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:
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)
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]:
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
for found in search_dir.rglob(f"{exe_name}*.AppImage"):
if found.is_file():
return found
return None
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 get_effective_definitions()]
def check_latest_version(self, tool_id: str) -> Optional[str]:
defn = _TOOL_MAP.get(tool_id)
if defn is None:
return None
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, 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)
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)
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]:
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]:
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())
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,
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, installing into the tools directory."""
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,
)
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}"