mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 04:13:45 +02:00
Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user