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
@@ -19,69 +19,6 @@ logger = logging.getLogger(__name__)
class GameUtilsMixin:
"""Mixin for game-related utility operations"""
# TODO post-0.6: remove this method - dead code, never called.
# Superseded by registry injection (game paths written directly into the modlist prefix).
# def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
# """
# Generate launch options for FNV/Enderal games that require vanilla compatdata.
#
# Args:
# special_game_type: "fnv" or "enderal"
# modlist_install_dir: Directory where the modlist is installed
#
# Returns:
# Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
# """
# if not special_game_type or special_game_type not in ["fnv", "enderal"]:
# return None
#
# logger.info(f"Generating {special_game_type.upper()} launch options")
#
# # Map game types to AppIDs
# appid_map = {"fnv": "22380", "enderal": "976620"}
# appid = appid_map[special_game_type]
#
# # Find vanilla game compatdata
# from ..handlers.path_handler import PathHandler
# compatdata_path = PathHandler.find_compat_data(appid)
# if not compatdata_path:
# logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
# return None
#
# # Create STEAM_COMPAT_DATA_PATH string
# compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
#
# # Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
# compat_mounts_str = ""
# try:
# all_libs = PathHandler.get_all_steam_library_paths()
# main_steam_lib_path_obj = PathHandler.find_steam_library()
# if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
# main_steam_lib_path = main_steam_lib_path_obj.parent.parent
# else:
# main_steam_lib_path = main_steam_lib_path_obj
#
# mount_paths = []
# if main_steam_lib_path:
# main_resolved = main_steam_lib_path.resolve()
# for lib_path in all_libs:
# if lib_path.resolve() != main_resolved:
# mount_paths.append(str(lib_path.resolve()))
#
# if mount_paths:
# mount_paths_str = ':'.join(mount_paths)
# compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
# logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
# except Exception as e:
# logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
#
# # Combine all launch options
# launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
# launch_options = ' '.join(launch_options.split()) # Clean up spacing
#
# logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
# return launch_options
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names"""
import os
@@ -110,21 +110,14 @@ class ProtonOperationsMixin:
with open(config_path, 'r') as f:
config_data = vdf.load(f)
# Navigate to the correct location in the VDF structure
if 'Software' not in config_data:
config_data['Software'] = {}
if 'Valve' not in config_data['Software']:
config_data['Software']['Valve'] = {}
if 'Steam' not in config_data['Software']['Valve']:
config_data['Software']['Valve']['Steam'] = {}
# Get or create CompatToolMapping
if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']:
config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {}
# config.vdf root key is "InstallConfigStore"
ics = config_data.setdefault('InstallConfigStore', {})
sw = ics.setdefault('Software', {})
valve = sw.setdefault('Valve', {})
steam = valve.setdefault('Steam', {})
ctm = steam.setdefault('CompatToolMapping', {})
# Set the Proton version for this AppID using Steam's expected format
# Steam requires a dict with 'name', 'config', and 'priority' keys
config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = {
ctm[str(appid)] = {
'name': proton_version,
'config': '',
'priority': '250'
@@ -148,7 +141,7 @@ class ProtonOperationsMixin:
# Verify it was set correctly
with open(config_path, 'r') as f:
verify_data = vdf.load(f)
compat_mapping = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid))
compat_mapping = verify_data.get('InstallConfigStore', {}).get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid))
logger.debug(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}")
return True
@@ -133,6 +133,17 @@ class WorkflowMixin:
"""
logger.info("Starting proven working automated prefix creation workflow")
if download_dir is None:
try:
from jackify.backend.handlers.path_handler import PathHandler
ini_path = Path(modlist_install_dir) / 'ModOrganizer.ini'
dl_str = PathHandler().get_download_directory_linux_path(ini_path)
if dl_str:
download_dir = Path(dl_str)
logger.debug(f"Resolved download_dir from ini: {download_dir}")
except Exception as e:
logger.debug(f"Could not resolve download_dir from ini: {e}")
try:
conflict_result = self.handle_existing_shortcut_conflict(
shortcut_name,
@@ -0,0 +1,326 @@
"""
Diagnostic bundle service - collects logs, system info, and prefix records
into a tar.gz for support reporting.
"""
import json
import logging
import os
import platform
import shutil
import subprocess
import tarfile
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def build_bundle(output_dir: Optional[Path] = None) -> Path:
"""
Collect logs, system info, and per-prefix component records into a tar.gz.
Returns the path to the created bundle file.
"""
from jackify.shared.paths import get_jackify_logs_dir, get_jackify_data_dir
from jackify import __version__
if output_dir is None:
output_dir = get_jackify_data_dir() / "DiagnosticBundles"
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
bundle_name = f"jackify_diagnostic_{timestamp}.tar.gz"
bundle_path = output_dir / bundle_name
with tempfile.TemporaryDirectory() as staging_dir:
staging = Path(staging_dir)
# System info
_write_text(staging / "system_info.txt", _collect_system_info(__version__))
# Logs
logs_dir = get_jackify_logs_dir()
log_staging = staging / "logs"
log_staging.mkdir()
cutoff = datetime.now().timestamp() - timedelta(days=7).total_seconds()
if logs_dir.is_dir():
for log_file in sorted(logs_dir.glob("*.log*")):
if log_file.is_file() and log_file.stat().st_mtime >= cutoff:
try:
shutil.copy2(log_file, log_staging / log_file.name)
except Exception as exc:
logger.debug("Could not copy log %s: %s", log_file.name, exc)
# Config files (credentials scrubbed)
_collect_config_files(staging)
# Per-prefix component records
_collect_component_records(staging)
# Modlist shortcut info
_collect_modlist_info(staging)
with tarfile.open(bundle_path, "w:gz") as tar:
tar.add(staging_dir, arcname="jackify_diagnostic")
logger.info("Diagnostic bundle written: %s", bundle_path)
return bundle_path
def _collect_system_info(version: str) -> str:
lines = [
f"Jackify version: {version}",
f"Date: {datetime.now().isoformat()}",
f"Kernel: {platform.release()}",
f"Machine: {platform.machine()}",
"",
]
_append_engine_info(lines)
# Distro
try:
import distro
lines.append(f"Distro: {distro.name(pretty=True)}")
except ImportError:
try:
lines.append(f"Distro: {platform.freedesktop_os_release().get('PRETTY_NAME', 'unknown')}")
except Exception:
lines.append("Distro: unknown")
# glibc
try:
glibc = platform.libc_ver()
lines.append(f"glibc: {glibc[0]} {glibc[1]}")
except Exception:
lines.append("glibc: unknown")
# GPU
try:
gpu_out = subprocess.check_output(
["lspci", "-mm"],
stderr=subprocess.DEVNULL,
timeout=5,
text=True,
)
gpu_lines = [l for l in gpu_out.splitlines() if "VGA" in l or "3D" in l or "Display" in l]
for gl in gpu_lines[:2]:
lines.append(f"GPU: {gl.strip()}")
except Exception:
lines.append("GPU: unavailable (lspci not found)")
lines.append("")
# Steam type
_append_steam_info(lines)
return "\n".join(lines)
def _append_steam_info(lines: list) -> None:
flatpak_steam = Path.home() / ".var/app/com.valvesoftware.Steam"
native_steam = Path.home() / ".local/share/Steam"
if flatpak_steam.is_dir():
lines.append("Steam: Flatpak")
elif native_steam.is_dir():
lines.append("Steam: Native")
else:
lines.append("Steam: not detected")
# Proton versions — official builds in steamapps/common, community builds in compatibilitytools.d
proton_scan = [
(native_steam / "steamapps/common", "valve"),
(flatpak_steam / "data/Steam/steamapps/common", "valve"),
(native_steam / "compatibilitytools.d", "community"),
(flatpak_steam / "data/Steam/compatibilitytools.d", "community"),
(Path.home() / ".steam/root/compatibilitytools.d", "community"),
]
proton_versions = []
seen = set()
for root, source in proton_scan:
if not root.is_dir():
continue
for entry in sorted(root.iterdir()):
if entry.is_dir() and entry.name not in seen and "proton" in entry.name.lower():
seen.add(entry.name)
proton_versions.append((entry.name, source))
if proton_versions:
lines.append("Proton versions:")
for name, source in sorted(proton_versions):
lines.append(f" {name} ({source})")
else:
lines.append("Proton versions: none found")
def _append_engine_info(lines: list) -> None:
try:
from jackify.backend.services.tool_registry import get_active_engine_id, ENGINE_TOOL_IDS, _read_manifest
active = get_active_engine_id()
lines.append(f"Active engine: {active}")
for tool_id in ENGINE_TOOL_IDS:
try:
manifest = _read_manifest(tool_id)
installed_version = manifest.get("installed_version")
if installed_version:
lines.append(f" {tool_id}: {installed_version}")
else:
lines.append(f" {tool_id}: not installed")
except Exception:
lines.append(f" {tool_id}: unknown")
except Exception as e:
lines.append(f"Engine info: unavailable ({e})")
lines.append("")
_CREDENTIAL_KEYS = {"nexus_api_key", "api_key", "access_token", "refresh_token", "token"}
_EXCLUDED_CONFIG_FILES = {"nexus-oauth.json"}
def _collect_config_files(staging: Path) -> None:
"""Copy ~/.config/jackify files, excluding credential files and scrubbing credential fields."""
config_dir = Path.home() / ".config" / "jackify"
if not config_dir.is_dir():
return
cfg_staging = staging / "config"
cfg_staging.mkdir()
for cfg_file in sorted(config_dir.iterdir()):
if not cfg_file.is_file():
continue
if cfg_file.name in _EXCLUDED_CONFIG_FILES:
continue
if cfg_file.suffix == ".json":
try:
data = json.loads(cfg_file.read_text(encoding="utf-8"))
_scrub_credentials(data)
(cfg_staging / cfg_file.name).write_text(
json.dumps(data, indent=2), encoding="utf-8"
)
continue
except Exception as exc:
logger.debug("Could not parse %s for scrubbing: %s", cfg_file.name, exc)
try:
shutil.copy2(cfg_file, cfg_staging / cfg_file.name)
except Exception as exc:
logger.debug("Could not copy config file %s: %s", cfg_file.name, exc)
def _scrub_credentials(obj: object) -> None:
"""Recursively replace credential field values with '[REDACTED]' in-place."""
if isinstance(obj, dict):
for key in list(obj.keys()):
if key.startswith("nexus_premium_cache_"):
del obj[key]
elif any(cred in key.lower() for cred in _CREDENTIAL_KEYS):
if obj[key] is not None:
obj[key] = "[REDACTED]"
else:
_scrub_credentials(obj[key])
elif isinstance(obj, list):
for item in obj:
_scrub_credentials(item)
def _collect_component_records(staging: Path) -> None:
"""Find jackify_components.json files in known prefix locations and copy them."""
steam_compat = Path.home() / ".steam/root/steamapps/compatdata"
flatpak_compat = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata"
cutoff = datetime.now().timestamp() - timedelta(days=30).total_seconds()
found = []
for base in (steam_compat, flatpak_compat):
if not base.is_dir():
continue
try:
for pfx_dir in base.iterdir():
record = pfx_dir / "pfx" / "jackify_components.json"
if record.is_file() and record.stat().st_mtime >= cutoff:
found.append((pfx_dir.name, record))
except PermissionError:
pass
if not found:
return
comp_staging = staging / "component_records"
comp_staging.mkdir()
for appid, record_path in found:
dest = comp_staging / f"jackify_components_{appid}.json"
try:
shutil.copy2(record_path, dest)
except Exception as exc:
logger.debug("Could not copy component record for %s: %s", appid, exc)
def _collect_modlist_info(staging: Path) -> None:
"""Collect installed modlist details from Steam shortcuts.vdf and config.vdf."""
try:
from jackify.backend.services.install_verifier_service import _load_verifier
vmod = _load_verifier()
modlists = vmod.discover_installed_modlists()
except Exception as exc:
logger.debug("Could not discover modlists for bundle: %s", exc)
return
if not modlists:
return
# Build a lookup of launch options keyed by unsigned appid from shortcuts.vdf
launch_opts: dict = {}
try:
for vdf_path in vmod._find_shortcuts_vdf_paths():
for sc in vmod._parse_shortcuts_vdf(vdf_path):
raw = sc.get("appid", sc.get("AppID", sc.get("appId")))
if raw is None:
continue
try:
unsigned = str(vmod._signed_to_unsigned(int(raw)))
except Exception:
continue
lo = sc.get("LaunchOptions", sc.get("launchoptions", ""))
if lo:
launch_opts[unsigned] = lo
except Exception as exc:
logger.debug("Could not read launch options from shortcuts.vdf: %s", exc)
# Read config.vdf once for Proton mappings
proton_map: dict = {}
try:
for root in vmod._find_steam_roots():
cfg = root / "config" / "config.vdf"
if cfg.is_file():
content = cfg.read_text(encoding="utf-8", errors="replace")
for m in modlists:
appid = m.get("appid", "")
if appid and appid not in proton_map:
tool = vmod._vdf_extract_compat_tool(content, appid)
if tool:
proton_map[appid] = tool
break
except Exception as exc:
logger.debug("Could not read Proton versions from config.vdf: %s", exc)
records = []
for m in modlists:
appid = m.get("appid", "")
records.append({
"name": m.get("name", "Unknown"),
"appid": appid,
"install_dir": str(m.get("modlist_dir", "")),
"game_type": m.get("game_type", "unknown"),
"proton_version": proton_map.get(appid),
"launch_options": launch_opts.get(appid),
})
_write_text(staging / "modlists.json", json.dumps(records, indent=2))
def _write_text(path: Path, content: str) -> None:
try:
path.write_text(content, encoding="utf-8")
except Exception as exc:
logger.debug("Could not write %s: %s", path, exc)
@@ -3,13 +3,12 @@ Watches a directory for newly downloaded files and matches them against a
list of pending manual download items by lax filename comparison.
"""
import os
import re
import time
import logging
from dataclasses import dataclass, field
from pathlib import Path
from threading import Thread, Event
from threading import Thread, Event, Lock
from typing import Callable, Optional
logger = logging.getLogger(__name__)
@@ -29,6 +28,11 @@ class DownloadWatcherService:
Caller sets pending_items (list of dicts with at least 'file_name') and
registers an on_candidate callback that receives (Path, dict) when a
potential match is detected (after debounce, before hash validation).
Detection strategy: every scan checks every non-temp file against pending
items. Files currently being debounced are skipped to avoid duplicate
threads. When debounce completes (pass or fail), the path is cleared from
the in-flight set so the next scan can re-detect it if still pending.
"""
def __init__(self, config: WatcherConfig, on_candidate: Callable[[Path, dict], None]):
@@ -38,8 +42,8 @@ class DownloadWatcherService:
self._pending_exact: list[tuple[str, dict]] = []
self._stop_event = Event()
self._thread: Optional[Thread] = None
# Track known files so we only react to new/changed ones
self._known: dict[Path, float] = {}
self._debouncing: set[Path] = set()
self._debouncing_lock = Lock()
def set_pending_items(self, items: list[dict]) -> None:
"""Replace the pending items list. Thread-safe for simple list swap."""
@@ -77,17 +81,11 @@ class DownloadWatcherService:
for path in entries:
if not path.is_file():
continue
# Skip browser temp files
if path.suffix in ('.part', '.crdownload', '.tmp'):
continue
try:
mtime = path.stat().st_mtime
except OSError:
continue
prev_mtime = self._known.get(path)
if prev_mtime == mtime:
continue
self._known[path] = mtime
with self._debouncing_lock:
if path in self._debouncing:
continue
self._check_candidate(path)
except OSError as e:
logger.debug(f"Watcher scan error on {watch_dir}: {e}")
@@ -100,15 +98,14 @@ class DownloadWatcherService:
logger.debug(f"Candidate exact match: {path.name}")
self._debounce_and_emit(path, item)
return
# Some modlist metadata stores filenames with a leading dot that browsers
# strip when saving the download. Match against the stripped expected name.
# Leading-dot normalisation: browsers strip a leading dot from filenames.
for expected_name, item in self._pending_exact:
if expected_name.lstrip('.') == candidate_name:
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}")
self._debounce_and_emit(path, item)
return
# Some modlist metadata stores filenames with a leading numeric prefix
# (e.g. "1_filename.zip") that is absent from the browser-saved file.
# Numeric-prefix normalisation: engine metadata may include a leading
# numeric prefix (e.g. "1_filename.zip") absent from the downloaded file.
for expected_name, item in self._pending_exact:
stripped = re.sub(r'^\d+_', '', expected_name)
if stripped != expected_name and stripped == candidate_name:
@@ -117,30 +114,64 @@ class DownloadWatcherService:
return
def _debounce_and_emit(self, path: Path, item: dict) -> None:
with self._debouncing_lock:
self._debouncing.add(path)
expected_size = 0
try:
expected_size = int(item.get('expected_size', 0) or 0)
except (TypeError, ValueError):
expected_size = 0
def _wait_and_emit():
prev_size = -1
stable_count = 0
needed = max(1, int(self._config.debounce_seconds / 0.5))
for _ in range(needed * 4): # max ~2× debounce time
if self._stop_event.is_set():
return
time.sleep(0.5)
try:
size = path.stat().st_size
except OSError:
return
if size == prev_size:
stable_count += 1
if stable_count >= needed:
break
else:
stable_count = 0
prev_size = size
if path.exists():
self._on_candidate(path, item)
became_stable = False
try:
prev_size = -1
stable_count = 0
needed = max(1, int(self._config.debounce_seconds / 0.5))
for _ in range(needed * 4):
if self._stop_event.is_set():
return
time.sleep(0.5)
try:
size = path.stat().st_size
except OSError:
return
# A slow/in-progress download can hold a constant size for the
# debounce window (initial throttle, network stall) and look
# stable while still incomplete. Validating it prematurely fails
# the hash, reverts the item to pending, and triggers a duplicate
# browser tab. Hold off until the file reaches its known size.
if expected_size > 0 and size != expected_size:
stable_count = 0
prev_size = size
continue
if size == prev_size:
stable_count += 1
if stable_count >= needed:
became_stable = True
break
else:
stable_count = 0
prev_size = size
# Only validate if the file stopped growing. If still downloading,
# release the debounce lock so the next scan can retry once it finishes.
if became_stable and path.exists():
self._on_candidate(path, item)
# Path stays in _debouncing until release_path() is called by the
# manager after validation completes, preventing repeated re-fires.
finally:
if not became_stable:
with self._debouncing_lock:
self._debouncing.discard(path)
Thread(target=_wait_and_emit, daemon=True, name=f'Debounce-{path.name[:20]}').start()
def release_path(self, path: Path) -> None:
"""Allow the watcher to re-detect a path after validation completes."""
with self._debouncing_lock:
self._debouncing.discard(path)
def _watch_loop(self) -> None:
while not self._stop_event.is_set():
self._scan()
+188
View File
@@ -0,0 +1,188 @@
"""
Engine Invoker
Resolves the active install engine and builds the appropriate subprocess command.
Keeps engine-specific CLI differences isolated from install workflow code.
"""
import logging
import os
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
def get_active_engine_id() -> str:
from jackify.backend.services.tool_registry import get_active_engine_id as _get
return _get()
def is_clf3_active() -> bool:
return get_active_engine_id() == "clf3"
def ensure_engine_available(engine_id: str = "jackify-engine") -> Tuple[bool, str]:
"""
Check that the given engine binary is present and download it if not.
Returns (True, path) on success, (False, error_message) on failure.
Call this at startup before the first install attempt.
"""
from jackify.backend.services.tool_registry import ToolRegistry
path = get_engine_path(engine_id)
if path:
return True, path
logger.info("Engine %s not found, attempting download via Tools Hub", engine_id)
ok, msg = ToolRegistry().install(engine_id)
if not ok:
return False, msg
path = get_engine_path(engine_id)
if not path:
return False, f"{engine_id} downloaded but binary not found after install"
return True, path
def get_engine_path(engine_id: str) -> Optional[str]:
"""Return the filesystem path to the engine binary for the given engine_id."""
from jackify.backend.services.tool_registry import ToolRegistry
path = ToolRegistry().get_binary_path(engine_id)
if path and path.is_file():
return str(path)
if engine_id == "jackify-engine":
from jackify.backend.core.modlist_operations import get_jackify_engine_path
return get_jackify_engine_path()
logger.warning("Engine binary not found for engine_id=%s", engine_id)
return None
def get_active_engine_path() -> Optional[str]:
"""Return the binary path for the currently active engine."""
return get_engine_path(get_active_engine_id())
def resolve_game_dir(game_type: Optional[str], modlist_path: Optional[str] = None) -> Optional[str]:
"""
Resolve the vanilla game installation directory for CLF3's --game argument.
Searches Steam and Heroic-managed stores in order.
Returns None if the path cannot be determined.
Use resolve_game_location() when the store identity is also needed.
"""
result = resolve_game_location(game_type)
return result[0] if result else None
def resolve_game_location(game_type: Optional[str]) -> Optional[tuple]:
"""
Return (path_str, store) for the detected game installation, or None.
store is one of: 'steam', 'gog', 'epic', 'unknown'.
"""
if not game_type:
return None
try:
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
result = VanillaGameFinder().find(game_type)
if result:
path, store = result
return str(path), store
except Exception as e:
logger.debug("Game dir detection failed for %s: %s", game_type, e)
return None
def build_install_command(
engine_id: str,
engine_path: str,
wabbajack: str,
install_dir: str,
downloads_dir: str,
game_dir: Optional[str] = None,
install_mode: str = "online",
debug: bool = False,
) -> List[str]:
"""Build the subprocess install command for the given engine."""
if engine_id == "clf3":
return _build_clf3_command(engine_path, wabbajack, install_dir, downloads_dir, game_dir)
return _build_jackify_engine_command(engine_path, wabbajack, install_dir, downloads_dir, install_mode, debug, game_dir)
def _build_jackify_engine_command(
engine_path: str,
wabbajack: str,
install_dir: str,
downloads_dir: str,
install_mode: str,
debug: bool,
game_dir: Optional[str] = None,
) -> List[str]:
cmd = [engine_path, "install", "--show-file-progress"]
if wabbajack.endswith(".wabbajack") and os.path.isfile(wabbajack):
cmd += ["-w", wabbajack]
else:
cmd += ["-m", wabbajack]
cmd += ["-o", install_dir, "-d", downloads_dir]
if game_dir:
cmd += ["-g", game_dir]
if debug:
cmd.append("--debug")
return cmd
def _read_resource_settings() -> dict:
"""Read resource_settings.json from the Jackify config dir; return empty dict on any failure."""
try:
from jackify.shared.paths import get_jackify_config_dir
import json
path = get_jackify_config_dir() / "resource_settings.json"
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _clf3_default_workers() -> int:
"""Default worker count matching jackify-engine: full cpu_count."""
import multiprocessing
return max(1, multiprocessing.cpu_count() or 4)
def _build_clf3_command(
engine_path: str,
wabbajack: str,
install_dir: str,
downloads_dir: str,
game_dir: Optional[str] = None,
) -> List[str]:
# Positional order: <WABBAJACK_FILE> <DOWNLOADS> <OUTPUT>
# CLF3 performs its own game detection (Steam + Heroic) with file verification.
# game_dir is reserved for explicit edge-case overrides only.
cmd = [engine_path, "install", "--jackify"]
if os.environ.get("JACKIFY_CLF3_VERBOSE"):
cmd.append("--verbose")
logger.info("CLF3 verbose mode enabled (JACKIFY_CLF3_VERBOSE)")
if game_dir:
cmd += ["--game", game_dir]
res = _read_resource_settings()
default = _clf3_default_workers()
def _tasks(key: str) -> int:
val = res.get(key, {}).get("MaxTasks", 0)
return val if val > 0 else default
concurrent = _tasks("Downloads")
install_workers = _tasks("Installer")
sevenzip_workers = _tasks("File Extractor")
cmd += [
"--concurrent", str(concurrent),
"--install-workers", str(install_workers),
"--sevenzip-workers", str(sevenzip_workers),
]
logger.debug(
"CLF3 resource flags: concurrent=%d install_workers=%d sevenzip_workers=%d (from %s)",
concurrent, install_workers, sevenzip_workers,
"resource_settings.json" if res else "default (cpu_count)",
)
cmd += [wabbajack, downloads_dir, install_dir]
return cmd
@@ -0,0 +1,48 @@
"""Service for running verify_install.py from Jackify workflows."""
import importlib.util
import logging
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_BUNDLED_PATH = Path(__file__).parent.parent.parent / "tools" / "verify_install.py"
def _load_verifier():
spec = importlib.util.spec_from_file_location("_verify_install_bundled", _BUNDLED_PATH)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot locate bundled verify_install.py at {_BUNDLED_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules.setdefault("_verify_install_bundled", module)
spec.loader.exec_module(module)
return module
def resolve_pfx_for_appid(appid: str) -> Optional[Path]:
"""Resolve the Proton prefix path for a Steam AppID."""
if not appid:
return None
steam_roots = [
Path.home() / ".steam" / "steam",
Path.home() / ".local" / "share" / "Steam",
Path.home() / ".steam" / "root",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
]
for root in steam_roots:
pfx = root / "steamapps" / "compatdata" / str(appid) / "pfx"
if pfx.is_dir():
return pfx
return None
def run_install_verification(pfx: Path, modlist_dir: Path, game_type: str, appid: str = "", modlist_name: str = ""):
"""Run the install verifier and return a Results object, or None on failure."""
try:
verifier = _load_verifier()
return verifier.run_verification(pfx, modlist_dir, game_type, appid, modlist_name)
except Exception as e:
logger.warning("Install verifier failed: %s", e, exc_info=True)
raise
@@ -119,6 +119,7 @@ class ManualDownloadManager(ManualDownloadManagerApiMixin, ManualDownloadManager
self._startup_precheck_pending = 0
self._run_id = f"mdl-{int(time.time())}-{id(self) % 10000}"
self._last_progress_log_completed = -1
self._last_browser_open: dict[str, float] = {} # file_name -> monotonic timestamp
additional = [modlist_download_dir] if modlist_download_dir != watch_directory else []
config = WatcherConfig(watch_directory=watch_directory, additional_dirs=additional)
@@ -123,8 +123,12 @@ class ManualDownloadManagerApiMixin:
with self._lock:
for item in self._items:
if item.file_name == file_name and item.status not in ('complete',):
# Only free the browser slot if this item actually held one.
# 'pending' items have no slot; 'validating' items still hold
# the slot counted when they entered 'browser_opened'.
had_slot = item.status in ('browser_opened', 'validating')
item.status = 'deferred'
if self._active_tabs > 0:
if had_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
break
@@ -133,6 +137,11 @@ class ManualDownloadManagerApiMixin:
self._open_next_tabs()
self._check_all_done()
def force_rescan(self) -> None:
"""Re-ingest existing files immediately (Scan Now button)."""
self._diag("MDL-1026", "Force rescan requested by user")
self._ingest_existing_files()
def set_concurrent_limit(self, limit: int) -> None:
with self._lock:
self._limit = max(1, min(5, limit))
@@ -4,8 +4,10 @@ from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import time
from pathlib import Path
from typing import Optional
@@ -84,6 +86,8 @@ class ManualDownloadManagerRuntimeMixin:
return item
return None
_BROWSER_OPEN_COOLDOWN = 30.0 # seconds before the same file's URL may be re-opened
def _open_browser(self, item: DownloadItem) -> tuple[bool, Optional[str]]:
url = item.nexus_url
if not url:
@@ -91,6 +95,16 @@ class ManualDownloadManagerRuntimeMixin:
logger.warning(f"{msg}: {item.file_name}")
return False, msg
now = time.monotonic()
last = self._last_browser_open.get(item.file_name, 0.0)
if now - last < self._BROWSER_OPEN_COOLDOWN:
remaining = int(self._BROWSER_OPEN_COOLDOWN - (now - last))
logger.warning(
f"Suppressed duplicate browser open for {item.file_name} "
f"(cooldown: {remaining}s remaining)"
)
return True, None
# Linux desktop launch fallbacks. xdg-open should cover most environments,
# but keep alternates for distributions where handlers differ.
launch_cmds = (
@@ -99,6 +113,14 @@ class ManualDownloadManagerRuntimeMixin:
['sensible-browser', url],
)
# Strip AppImage library path overrides before launching external processes.
# Inheriting LD_LIBRARY_PATH causes xdg-open/kde-open to load bundled Qt
# libs, which produces symbol version mismatches on distributions that ship
# a different Qt version than the one bundled in the AppImage.
clean_env = os.environ.copy()
for var in ('LD_LIBRARY_PATH', 'LD_PRELOAD'):
clean_env.pop(var, None)
launch_errors: list[str] = []
for cmd in launch_cmds:
try:
@@ -107,6 +129,7 @@ class ManualDownloadManagerRuntimeMixin:
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
env=clean_env,
)
except OSError as e:
launch_errors.append(f"{cmd[0]} not available: {e}")
@@ -116,10 +139,12 @@ class ManualDownloadManagerRuntimeMixin:
rc = proc.wait(timeout=3)
except subprocess.TimeoutExpired:
# Launcher still running after handoff window; treat as success.
self._last_browser_open[item.file_name] = time.monotonic()
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
return True, None
if rc == 0:
self._last_browser_open[item.file_name] = time.monotonic()
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
return True, None
@@ -144,7 +169,7 @@ class ManualDownloadManagerRuntimeMixin:
item = self._item_by_name(file_name)
if item is None:
reject_reason = "unknown_item"
elif item.status in ('complete', 'skipped'):
elif item.status in ('complete', 'skipped', 'deferred'):
reject_reason = f"terminal_status:{item.status}"
elif item.status == 'validating':
reject_reason = "already_validating"
@@ -224,23 +249,30 @@ class ManualDownloadManagerRuntimeMixin:
item_to_notify = item
completed_now = True
else:
# Hash mismatch or validation error - revert to pending so the
# sliding window can re-open a browser tab and the watcher can
# re-validate if the user downloads the correct file.
item.status = 'pending'
msg = result.error or f"Hash mismatch (got {result.computed_hash})"
item.error_message = msg
logger.warning(f"Validation failed for {file_name}: {msg}")
if had_browser_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
validation_failed = True
# If the user deferred this item while validation was in-flight,
# skip_item already decremented _active_tabs and set status='deferred'.
# Preserve the defer; don't double-decrement or re-open a browser tab.
if item.status == 'deferred':
item_to_notify = item
validation_failed = True
else:
# Revert to pending so the sliding window can re-open a browser tab.
item.status = 'pending'
item.error_message = msg
logger.warning(f"Validation failed for {file_name}: {msg}")
if had_browser_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
validation_failed = True
if from_startup_precheck and self._startup_precheck_pending > 0:
self._startup_precheck_pending -= 1
precheck_ready = self._startup_precheck_pending == 0
if item_to_notify is not None:
self._notify(item_to_notify)
if result.file_path:
self._watcher.release_path(result.file_path)
if completed_now:
self._diag(
"MDL-1021",
+17 -18
View File
@@ -158,9 +158,8 @@ class ModlistService(ModlistServiceInstallationMixin):
logger.error(f"Failed to list modlists: {e}")
raise
def configure_modlist_post_steam(self, context: ModlistContext,
def configure_modlist_post_steam(self, context: ModlistContext,
progress_callback=None,
manual_steps_callback=None,
completion_callback=None) -> bool:
"""Configure a modlist after Steam setup is complete.
@@ -173,7 +172,6 @@ class ModlistService(ModlistServiceInstallationMixin):
Args:
context: Modlist context with updated app_id
progress_callback: Optional callback for progress updates
manual_steps_callback: Called when manual steps needed
completion_callback: Called when configuration is complete
Returns:
@@ -253,12 +251,12 @@ class ModlistService(ModlistServiceInstallationMixin):
'path': str(context.install_dir),
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': True, # Manual steps were done in GUI
'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam
'skip_confirmation': True,
'appid': getattr(context, 'app_id', None),
'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
'modlist_source': getattr(context, 'modlist_source', None),
'suppress_completion_banner': True,
}
debug_callback(f"Configuration context built: {config_context}")
@@ -317,7 +315,11 @@ class ModlistService(ModlistServiceInstallationMixin):
debug_callback("Calling run_modlist_configuration_phase")
success = modlist_menu.run_modlist_configuration_phase(config_context)
debug_callback(f"Configuration phase result: {success}")
context.steam_restart_needed = config_context.get('steam_restart_needed', False)
context.mounts_app_name = config_context.get('mounts_app_name', '')
context.mounts_exe_path = config_context.get('mounts_exe_path', '')
context.mounts_dl_path = config_context.get('mounts_dl_path', '')
# Restore stdout before ENB detection and completion callback
if original_stdout:
sys.stdout = original_stdout
@@ -405,20 +407,18 @@ class ModlistService(ModlistServiceInstallationMixin):
return False
def configure_modlist(self, context: ModlistContext,
progress_callback=None,
manual_steps_callback=None,
def configure_modlist(self, context: ModlistContext,
progress_callback=None,
completion_callback=None,
output_callback=None) -> bool:
"""Configure a modlist after installation.
Args:
context: Modlist context
progress_callback: Optional callback for progress updates
manual_steps_callback: Optional callback for manual steps
completion_callback: Optional callback for completion
output_callback: Optional callback for output/logging
Returns:
True if configuration successful, False otherwise
"""
@@ -438,15 +438,14 @@ class ModlistService(ModlistServiceInstallationMixin):
'path': str(context.install_dir),
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': False,
'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths
'skip_confirmation': True,
'appid': getattr(context, 'app_id', None),
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
# DEBUG: Log what resolution we're passing
logger.info(f"DEBUG: config_context resolution = {config_context['resolution']}")
logger.info(f"DEBUG: context.resolution = {getattr(context, 'resolution', 'NOT_SET')}")
logger.info(f"config_context resolution = {config_context['resolution']}")
logger.info(f"context.resolution = {getattr(context, 'resolution', 'NOT_SET')}")
# Run the complete configuration phase
success = modlist_menu.run_modlist_configuration_phase(config_context)
@@ -300,13 +300,21 @@ class ModlistServiceInstallationMixin:
output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.")
output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.")
if _ck_missing and output_callback:
_gt = context.get('game_type') or ''
if 'fallout4' in _gt.lower():
_ck_name = "Fallout 4 Creation Kit"
_ck_search = "Fallout 4: Creation Kit"
else:
_ck_name = "Skyrim Special Edition Creation Kit"
_ck_search = "Skyrim Special Edition: Creation Kit"
output_callback("")
output_callback("[WARN] Creation Kit Files Missing")
output_callback(" This modlist requires the Skyrim Special Edition Creation Kit.")
output_callback(" - In Steam, search for 'Skyrim Special Edition: Creation Kit' and install it.")
output_callback(f" This modlist requires the {_ck_name}.")
output_callback(f" - In Steam, search for '{_ck_search}' and install it.")
output_callback(" - Right-click it in Steam > Properties > Compatibility and set a Proton version.")
output_callback(" - Click Play to launch the Creation Kit.")
output_callback(" - When asked whether to unzip Scripts.zip, select NO.")
if 'fallout4' not in _gt.lower():
output_callback(" - When asked whether to unzip Scripts.zip, select NO.")
output_callback(" - Once the Creation Kit opens successfully, close it.")
output_callback(" - Re-run the modlist install in Jackify.")
return False
@@ -224,10 +224,19 @@ class NativeSteamService:
try:
# Create backup first
if shortcuts_path.exists():
backup_path = shortcuts_path.with_suffix(f".vdf.backup_{int(time.time())}")
import shutil
import glob
backup_dir = shortcuts_path.parent / "backups"
backup_dir.mkdir(exist_ok=True)
backup_path = backup_dir / f"shortcuts_{int(time.time())}.bak"
shutil.copy2(shortcuts_path, backup_path)
logger.info(f"Created backup: {backup_path}")
existing = sorted(glob.glob(str(backup_dir / "shortcuts_*.bak")))
for old in existing[:-5]:
try:
os.remove(old)
except Exception:
pass
# Ensure parent directory exists
shortcuts_path.parent.mkdir(parents=True, exist_ok=True)
@@ -386,10 +395,19 @@ class NativeSteamService:
return False
# Create backup first
backup_path = config_path.with_suffix(f".vdf.backup_{int(time.time())}")
import shutil
import glob
backup_dir = config_path.parent / "backups"
backup_dir.mkdir(exist_ok=True)
backup_path = backup_dir / f"config_{int(time.time())}.bak"
shutil.copy2(config_path, backup_path)
logger.info(f"Created backup: {backup_path}")
existing = sorted(glob.glob(str(backup_dir / "config_*.bak")))
for old in existing[:-5]:
try:
os.remove(old)
except Exception:
pass
# Read the file as text to avoid VDF library formatting issues
with open(config_path, 'r', encoding='utf-8', errors='ignore') as f:
@@ -212,7 +212,7 @@ class NexusAuthService:
Returns:
Tuple of (valid, username_or_error)
"""
return self.api_key_service.validate_api_key(api_key)
return self.api_key_service.validate_api_key_works(api_key)
def ensure_valid_auth(self) -> Optional[str]:
"""
@@ -1,4 +1,5 @@
"""Nexus Premium status detection service."""
import hashlib
import time
import logging
from typing import Tuple, Optional
@@ -70,7 +71,8 @@ class NexusPremiumService:
def _cache_key(self, token: str, is_oauth: bool = False) -> str:
suffix = "oauth" if is_oauth else "apikey"
return f"nexus_premium_cache_{token[:8]}_{suffix}"
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()[:12]
return f"nexus_premium_cache_{token_hash}_{suffix}"
def _read_cache(self, token: str, is_oauth: bool = False) -> Optional[Tuple[bool, Optional[str]]]:
try:
+118
View File
@@ -0,0 +1,118 @@
"""NXM download pipeline: resolve CDN URL and save to modlist download directory."""
import logging
from pathlib import Path
from typing import Optional, Callable, Tuple
import requests
from jackify.backend.services.nxm_url import NxmUrl
logger = logging.getLogger(__name__)
_NEXUS_API_BASE = "https://api.nexusmods.com/v1"
_CHUNK_SIZE = 65536
def get_nxm_download_url(nxm: NxmUrl, auth_token: str, auth_method: str = "api_key") -> Optional[str]:
"""Resolve an NXM URL to a CDN download URL using the Nexus API.
The key/expires from the NXM URL authorise the request for both Premium
and non-Premium accounts.
"""
url = (
f"{_NEXUS_API_BASE}/games/{nxm.game}/mods/{nxm.mod_id}"
f"/files/{nxm.file_id}/download_link.json"
)
if auth_method == "oauth":
headers = {"Authorization": f"Bearer {auth_token}", "User-Agent": "jackify"}
else:
headers = {"apikey": auth_token, "User-Agent": "jackify"}
params: dict = {}
if nxm.key:
params["key"] = nxm.key
if nxm.expires:
params["expires"] = nxm.expires
try:
resp = requests.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if isinstance(data, list) and data:
cdn_url = data[0].get("URI")
logger.debug("Resolved NXM CDN URL for file %s", nxm.file_id)
return cdn_url
logger.warning("Nexus API returned empty download link list for file %s", nxm.file_id)
return None
except requests.HTTPError as e:
logger.error(
"Nexus API error resolving NXM URL (method=%s, status=%s): %s",
auth_method, e.response.status_code if e.response else "?", e,
)
return None
except Exception as e:
logger.error("Unexpected error resolving NXM URL: %s", e)
return None
def resolve_mo2_download_dir(modlist_dir: Path) -> Optional[Path]:
"""Read download_directory from ModOrganizer.ini and resolve to a Linux path.
Returns None if the directory is not configured or cannot be resolved.
Delegates to PathHandler which handles all MO2 path formats correctly.
"""
from jackify.backend.handlers.path_handler import PathHandler
ini_path = modlist_dir / "ModOrganizer.ini"
if not ini_path.exists():
logger.warning("ModOrganizer.ini not found at %s", ini_path)
return None
dl_str = PathHandler().get_download_directory_linux_path(ini_path)
if dl_str:
return Path(dl_str)
default = modlist_dir / "downloads"
logger.debug("No download_directory in ini, using default: %s", default)
return default
def download_nxm_file(
cdn_url: str,
download_dir: Path,
filename: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
) -> Tuple[bool, str]:
"""Download a file to the modlist download directory.
Returns (success, message).
"""
try:
download_dir.mkdir(parents=True, exist_ok=True)
dest = download_dir / filename
resp = requests.get(cdn_url, stream=True, timeout=60)
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
with open(dest, "wb") as f:
for chunk in resp.iter_content(chunk_size=_CHUNK_SIZE):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if progress_callback and total > 0:
progress_callback(downloaded, total)
logger.info("NXM download complete: %s (%d bytes)", dest.name, downloaded)
return True, f"Saved to {dest}"
except Exception as e:
logger.error("NXM download failed: %s", e)
return False, str(e)
def filename_from_cdn_url(cdn_url: str, fallback: str) -> str:
"""Extract a filename from a CDN URL, falling back to provided name."""
path = cdn_url.split("?")[0].rstrip("/")
name = path.split("/")[-1]
return name if name else fallback
+73
View File
@@ -0,0 +1,73 @@
"""Unix socket IPC for single-instance NXM URL routing.
The running Jackify instance listens on a QLocalServer. A second instance
launched by the OS protocol handler connects, sends the nxm:// URL, and exits.
"""
import logging
from typing import Optional
from PySide6.QtCore import QObject, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
logger = logging.getLogger(__name__)
_SOCKET_NAME = "jackify-nxm-ipc"
_CONNECT_TIMEOUT_MS = 1000
class NxmIpcServer(QObject):
"""Listens for nxm:// URLs from secondary Jackify instances."""
url_received = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._server: Optional[QLocalServer] = None
def start(self) -> bool:
QLocalServer.removeServer(_SOCKET_NAME)
self._server = QLocalServer(self)
self._server.newConnection.connect(self._on_connection)
if not self._server.listen(_SOCKET_NAME):
logger.warning("NXM IPC server failed to start: %s", self._server.errorString())
return False
logger.debug("NXM IPC server listening on %s", _SOCKET_NAME)
return True
def stop(self) -> None:
if self._server:
self._server.close()
QLocalServer.removeServer(_SOCKET_NAME)
self._server = None
def _on_connection(self) -> None:
conn = self._server.nextPendingConnection()
if conn:
conn.readyRead.connect(lambda: self._read(conn))
def _read(self, conn: QLocalSocket) -> None:
data = bytes(conn.readAll()).decode(errors="replace").strip()
conn.disconnectFromServer()
if data.startswith("nxm://"):
logger.info("NXM IPC received URL: %s", data)
self.url_received.emit(data)
else:
logger.warning("NXM IPC received unexpected data: %r", data[:80])
def send_to_running_instance(url: str) -> bool:
"""Send an nxm:// URL to the running Jackify instance.
Returns True if a running instance was found and the URL was delivered.
"""
socket = QLocalSocket()
socket.connectToServer(_SOCKET_NAME)
if not socket.waitForConnected(_CONNECT_TIMEOUT_MS):
return False
socket.write(url.encode())
socket.flush()
socket.waitForBytesWritten(500)
socket.disconnectFromServer()
logger.debug("NXM URL handed off to running instance")
return True
+124
View File
@@ -0,0 +1,124 @@
"""NXM protocol handler registration.
Updates (or creates) the Jackify .desktop file to include
x-scheme-handler/nxm in its MimeType, then registers it with xdg.
"""
import logging
import os
import subprocess
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
_DESKTOP_FILE = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop"
_NXM_MIME = "x-scheme-handler/nxm"
def ensure_nxm_registered() -> bool:
"""Add nxm:// handler to the existing Jackify .desktop file if not present.
Safe to call on every launch - no-ops when already registered.
Returns True on success.
"""
try:
if not _DESKTOP_FILE.exists():
if not _create_desktop_file():
return False
content = _DESKTOP_FILE.read_text()
if _NXM_MIME in content:
logger.debug("nxm:// already registered in desktop file")
return True
# Add nxm to existing MimeType= line
updated = False
lines = content.splitlines()
for i, line in enumerate(lines):
if line.strip().startswith("MimeType="):
if not line.rstrip().endswith(";"):
lines[i] = line.rstrip() + ";"
lines[i] = lines[i] + f"{_NXM_MIME};"
updated = True
break
if not updated:
# No MimeType line - append one
lines.append(f"MimeType={_NXM_MIME};")
_DESKTOP_FILE.write_text("\n".join(lines) + "\n")
logger.info("Added nxm:// to desktop file MimeType")
_run_xdg_registration()
return True
except Exception as e:
logger.warning("Failed to register nxm:// protocol: %s", e)
return False
def _create_desktop_file() -> bool:
"""Create a minimal .desktop file with both jackify:// and nxm:// handlers."""
try:
env = os.environ
is_appimage = (
"APPIMAGE" in env or "APPDIR" in env or
(sys.argv[0] and sys.argv[0].endswith(".AppImage"))
)
if is_appimage:
exec_path = env.get("APPIMAGE") or str(Path(sys.argv[0]).resolve())
exec_line = f'Exec="{exec_path}" %u'
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
exec_line = f"Exec={exec_path} %u"
_DESKTOP_FILE.parent.mkdir(parents=True, exist_ok=True)
_DESKTOP_FILE.write_text(
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Jackify\n"
"Comment=Wabbajack modlist manager for Linux\n"
f"{exec_line}\n"
"Icon=com.jackify.app\n"
"Terminal=false\n"
"Categories=Game;Utility;\n"
f"MimeType=x-scheme-handler/jackify;{_NXM_MIME};\n"
)
logger.info("Created desktop file at %s", _DESKTOP_FILE)
return True
except Exception as e:
logger.warning("Failed to create desktop file: %s", e)
return False
def _run_xdg_registration() -> None:
apps_dir = _DESKTOP_FILE.parent
for cmd in [
["update-desktop-database", str(apps_dir)],
["xdg-mime", "default", _DESKTOP_FILE.name, _NXM_MIME],
["xdg-settings", "set", "default-url-scheme-handler", "nxm", _DESKTOP_FILE.name],
]:
try:
subprocess.run(cmd, capture_output=True, timeout=10)
except Exception as e:
logger.debug("xdg command %s failed (non-fatal): %s", cmd[0], e)
# mimeapps.list fallback for DEs that ignore xdg-settings
mimeapps = Path.home() / ".config" / "mimeapps.list"
try:
content = mimeapps.read_text() if mimeapps.exists() else "[Default Applications]\n"
if f"{_NXM_MIME}=" not in content:
if "[Default Applications]" not in content:
content = "[Default Applications]\n" + content
lines = content.split("\n")
for i, line in enumerate(lines):
if line.strip() == "[Default Applications]":
lines.insert(i + 1, f"{_NXM_MIME}={_DESKTOP_FILE.name}")
break
mimeapps.parent.mkdir(parents=True, exist_ok=True)
mimeapps.write_text("\n".join(lines))
logger.info("Added nxm handler to mimeapps.list")
except Exception as e:
logger.debug("mimeapps.list update failed (non-fatal): %s", e)
+97
View File
@@ -0,0 +1,97 @@
"""NXM session state: remembers which modlist to route downloads to.
Clears when the process exits. Not persisted to disk.
"""
import logging
import re
from pathlib import Path
from typing import Optional, List, Dict
logger = logging.getLogger(__name__)
_remembered_modlist: Optional[str] = None
def get_remembered_modlist() -> Optional[str]:
return _remembered_modlist
def set_remembered_modlist(name: str) -> None:
global _remembered_modlist
_remembered_modlist = name
def clear_remembered_modlist() -> None:
global _remembered_modlist
_remembered_modlist = None
def detect_active_mo2_modlist(modlists: List[Dict]) -> Optional[Dict]:
"""Return the modlist whose MO2 instance is currently running, or None.
Scans live processes for ModOrganizer.exe and matches the install path
against the provided modlist list.
"""
try:
import psutil
except ImportError:
logger.debug("psutil not available, skipping MO2 process detection")
return None
mo2_dirs: List[Path] = []
try:
for proc in psutil.process_iter(["cmdline"]):
try:
cmdline = proc.info.get("cmdline") or []
for arg in cmdline:
arg_str = str(arg)
if "ModOrganizer.exe" in arg_str:
resolved = _resolve_mo2_path(arg_str)
if resolved:
mo2_dirs.append(resolved)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
except Exception as e:
logger.debug("MO2 process scan failed: %s", e)
return None
if not mo2_dirs:
return None
matches = []
for modlist in modlists:
ml_dir = Path(modlist.get("modlist_dir", "")).resolve()
for mo2_dir in mo2_dirs:
try:
if mo2_dir.resolve() == ml_dir:
matches.append(modlist)
break
except Exception:
continue
if len(matches) == 1:
logger.debug("Active MO2 instance matched modlist: %s", matches[0].get("name"))
return matches[0]
if len(matches) > 1:
logger.debug("Multiple active MO2 instances found, falling back to picker")
else:
logger.debug("MO2 process found but no modlist match for dirs: %s", mo2_dirs)
return None
def _resolve_mo2_path(arg: str) -> Optional[Path]:
"""Extract and resolve the modlist directory from a ModOrganizer.exe cmdline arg."""
# Wine path: Z:\path\to\modlist\ModOrganizer.exe
m = re.match(r"(?i)z:([\\/].+?)[\\/]ModOrganizer\.exe", arg)
if m:
linux_path = m.group(1).replace("\\", "/")
return Path(linux_path)
# Raw Linux path: /path/to/modlist/ModOrganizer.exe
m = re.match(r"(/.+?)/ModOrganizer\.exe", arg)
if m:
return Path(m.group(1))
return None
+60
View File
@@ -0,0 +1,60 @@
"""NXM URL parser.
nxm://{game}/mods/{mod_id}/files/{file_id}?key=KEY&expires=TS&user_id=UID
"""
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse, parse_qs
@dataclass
class NxmUrl:
game: str
mod_id: int
file_id: int
key: str
expires: str
user_id: Optional[str] = None
raw: str = ""
@property
def display_name(self) -> str:
return f"{self.game} / mod {self.mod_id} / file {self.file_id}"
def parse_nxm_url(url: str) -> NxmUrl:
"""Parse an nxm:// URL into its components.
Raises ValueError if the URL is malformed.
"""
parsed = urlparse(url)
if parsed.scheme.lower() != "nxm":
raise ValueError(f"Not an NXM URL: {url}")
game = parsed.netloc.lower()
parts = [p for p in parsed.path.strip("/").split("/") if p]
# Expected: ['mods', '{mod_id}', 'files', '{file_id}']
if len(parts) < 4 or parts[0] != "mods" or parts[2] != "files":
raise ValueError(f"Unexpected NXM URL path: {parsed.path}")
try:
mod_id = int(parts[1])
file_id = int(parts[3])
except ValueError:
raise ValueError(f"Non-integer mod/file ID in NXM URL: {url}")
params = parse_qs(parsed.query)
key = params.get("key", [""])[0]
expires = params.get("expires", [""])[0]
user_id = params.get("user_id", [None])[0]
return NxmUrl(
game=game,
mod_id=mod_id,
file_id=file_id,
key=key,
expires=expires,
user_id=user_id,
raw=url,
)
@@ -153,12 +153,13 @@ def detect_game_type_from_modlist(modlist_dir: str) -> Optional[str]:
return None
def fetch_artwork(game_type: str, dest_dir: Path) -> int:
def fetch_artwork(game_type: str, dest_dir: Path, skip_existing: bool = False) -> int:
"""
Fetch top-voted artwork for game_type from SteamGridDB into dest_dir.
Returns the number of images successfully downloaded.
dest_dir must already exist.
dest_dir must already exist. When skip_existing is True, slots where the
file already exists in dest_dir are skipped.
"""
steam_appid = GAME_STEAM_APP_IDS.get(game_type)
if not steam_appid:
@@ -168,12 +169,14 @@ def fetch_artwork(game_type: str, dest_dir: Path) -> int:
api_key = _get_api_key()
downloaded = 0
for endpoint, query, filename in _ARTWORK_SLOTS:
dest_path = dest_dir / filename
if skip_existing and dest_path.exists():
continue
data = _api_get(f"{endpoint}/steam/{steam_appid}?{query}", api_key)
if not data or not data.get("success") or not data.get("data"):
logger.debug(f"No {endpoint} results for {game_type} ({steam_appid})")
continue
image_url = data["data"][0]["url"]
dest_path = dest_dir / filename
if _download(image_url, dest_path):
logger.info(f"Downloaded {filename} for {game_type} from SteamGridDB")
downloaded += 1
+39 -44
View File
@@ -8,11 +8,13 @@ standalone operation for existing prefixes.
Based on research into NaK's registry configuration (external reference only).
"""
import json
import logging
import os
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import Callable, Optional
@@ -91,11 +93,11 @@ def _build_reg_content() -> str:
return "\r\n".join(lines)
# .NET 9 SDK - direct installer, not available via winetricks.
# Synthesis runs on .NET 9; the SDK (not just runtime) is required for patcher compilation.
# .NET 9 SDK - ZIP distribution, extracted directly to avoid running an EXE under Wine.
# Synthesis requires the SDK (not just runtime) for patcher compilation.
# Versions match Fluorine's confirmed-working prefix configuration.
_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe"
_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.exe"
_DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.zip"
_DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.zip"
# .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2.
# Covers Synthesis patchers targeting .NET 10 runtime.
@@ -122,42 +124,29 @@ def _install_dotnet9_sdk(
log: Callable[[str], None],
) -> bool:
"""
Download and install the .NET 9 SDK into the Wine prefix.
Cached to avoid re-downloading on subsequent runs.
Download and extract the .NET 9 SDK ZIP into the Wine prefix.
Uses the standalone ZIP distribution to avoid running an EXE under Wine.
Synthesis requires the full SDK (Roslyn compiler) for patcher compilation.
"""
try:
from jackify.shared.paths import get_jackify_data_dir
cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
installer = cache_dir / _DOTNET9_SDK_FILENAME
sdk_zip = cache_dir / _DOTNET9_SDK_FILENAME
if not installer.exists():
if not sdk_zip.exists():
log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...")
urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer)
urllib.request.urlretrieve(_DOTNET9_SDK_URL, sdk_zip)
log(".NET 9 SDK downloaded")
else:
log(".NET 9 SDK installer already cached, skipping download")
log(".NET 9 SDK already cached, skipping download")
log("Installing .NET 9 SDK (this may take a few minutes)...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[wine_bin, str(installer), "/install", "/quiet", "/norestart"],
env=env,
capture_output=True,
text=True,
timeout=600,
)
if result.returncode not in (0, 3010): # 3010 = success, reboot required
log(f".NET 9 SDK installer exited with code {result.returncode}")
return False
log(".NET 9 SDK installed successfully")
dest = prefix_path / "drive_c" / "Program Files" / "dotnet"
dest.mkdir(parents=True, exist_ok=True)
log("Extracting .NET 9 SDK...")
with zipfile.ZipFile(sdk_zip) as zf:
zf.extractall(dest)
log(".NET 9 SDK extracted successfully")
return True
except Exception as e:
@@ -362,6 +351,7 @@ def apply_tool_config(
log: Optional[Callable[[str], None]] = None,
install_dotnet9_sdk: bool = False,
install_fxc2_d3dcompiler: bool = False,
preserve_global_mscoree: bool = False,
) -> bool:
"""
Apply tool compatibility settings to the Wine prefix.
@@ -399,20 +389,25 @@ def apply_tool_config(
# Remove legacy global *mscoree=native from DllOverrides if present.
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
try:
env_clean = os.environ.copy()
env_clean["WINEPREFIX"] = str(prefix_path)
env_clean["WINEDEBUG"] = "-all"
env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0")
subprocess.run(
[wine_bin, "reg", "delete",
"HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides",
"/v", "*mscoree", "/f"],
env=env_clean, capture_output=True, text=True, timeout=15,
)
_log("Removed legacy global *mscoree override (if present)")
except Exception as e:
_log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}")
# NSF/CSF modlists are the exception: NetScriptFramework's mixed-mode runtime needs the
# global override to host the CLR (the per-exe entry alone is insufficient), so it is kept.
if preserve_global_mscoree:
_log("Preserving global *mscoree=native (NSF/CSF modlist)")
else:
try:
env_clean = os.environ.copy()
env_clean["WINEPREFIX"] = str(prefix_path)
env_clean["WINEDEBUG"] = "-all"
env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0")
subprocess.run(
[wine_bin, "reg", "delete",
"HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides",
"/v", "*mscoree", "/f"],
env=env_clean, capture_output=True, text=True, timeout=15,
)
_log("Removed legacy global *mscoree override (if present)")
except Exception as e:
_log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}")
reg_content = _build_reg_content()
+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}"
@@ -0,0 +1,55 @@
[
{
"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/jackify-engine",
"asset_patterns": ["jackify-engine.*linux.*x64.*\\.tar\\.gz", "jackify-engine.*\\.tar\\.gz", "jackify-engine.*\\.zip"],
"executable_names": ["jackify-engine"],
"tier": 1,
"can_uninstall": false,
"is_engine": true,
"can_launch": false
},
{
"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": ["clf3.*linux.*x86_64", "clf3.*\\.tar\\.gz", "clf3.*\\.zip"],
"executable_names": ["clf3"],
"tier": 1,
"can_uninstall": true,
"is_engine": true,
"can_launch": false
},
{
"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": ["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",
"nexus_mod_id": 1657,
"nexus_game_domain": "site",
"nexus_file_filter": "mpi"
},
{
"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": ["radium.*linux.*x86_64", "radium.*\\.tar\\.gz", "radium.*\\.zip"],
"executable_names": ["radium", "radium-textures"],
"tier": 2,
"can_uninstall": true,
"can_launch": true,
"nexus_mod_id": 1660,
"nexus_game_domain": "site",
"nexus_file_filter": "linux"
}
]
@@ -24,11 +24,8 @@ def _build_handler() -> TTWInstallerHandler:
def get_ttw_installer_path() -> Optional[Path]:
"""Return the resolved TTW_Linux_Installer executable path, if available."""
handler = _build_handler()
path = handler.ttw_installer_executable_path
if path and path.exists():
return path
return None
from jackify.backend.services.tool_registry import ToolRegistry
return ToolRegistry().get_binary_path("ttw_installer")
def ensure_ttw_installer_available(
@@ -47,13 +44,17 @@ def ensure_ttw_installer_available(
if progress_callback:
progress_callback("TTW_Linux_Installer not found, installing...")
from jackify.backend.services.tool_registry import TOOLS_BASE_DIR
install_dir = TOOLS_BASE_DIR / "ttw_installer"
install_dir.mkdir(parents=True, exist_ok=True)
handler = _build_handler()
success, message = handler.install_ttw_installer()
success, message = handler.install_ttw_installer(install_dir=install_dir)
if not success:
logger.error("Failed to install TTW_Linux_Installer: %s", message)
return None, message
path = handler.ttw_installer_executable_path
path = get_ttw_installer_path()
if path and path.exists():
if progress_callback:
progress_callback("TTW_Linux_Installer installed successfully")