mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 03:13:44 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -129,7 +129,7 @@ def _append_steam_info(lines: list) -> None:
|
||||
else:
|
||||
lines.append("Steam: not detected")
|
||||
|
||||
# Proton versions — official builds in steamapps/common, community builds in compatibilitytools.d
|
||||
# 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"),
|
||||
|
||||
@@ -42,6 +42,7 @@ class DownloadItem:
|
||||
status: STATUS = "pending"
|
||||
local_path: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
download_reason: Optional[str] = None
|
||||
needs_user_retry: bool = False
|
||||
|
||||
@classmethod
|
||||
@@ -55,6 +56,21 @@ class DownloadItem:
|
||||
or evt.get('url')
|
||||
or ''
|
||||
)
|
||||
# If engine provides no URL but has mod metadata, construct a Nexus Mods
|
||||
# page URL as a fallback so the user has somewhere to go.
|
||||
if not source_url:
|
||||
game = str(evt.get('game_name') or '').lower().replace(' ', '')
|
||||
mod_id = evt.get('mod_id')
|
||||
file_id = evt.get('file_id')
|
||||
if game and mod_id:
|
||||
if file_id:
|
||||
source_url = (
|
||||
f"https://www.nexusmods.com/{game}/mods/{mod_id}"
|
||||
f"?tab=files&file_id={file_id}"
|
||||
)
|
||||
else:
|
||||
source_url = f"https://www.nexusmods.com/{game}/mods/{mod_id}?tab=files"
|
||||
|
||||
item = cls(
|
||||
file_name=evt.get('file_name', ''),
|
||||
nexus_url=source_url,
|
||||
@@ -66,6 +82,7 @@ class DownloadItem:
|
||||
index=evt.get('index', 0),
|
||||
total=evt.get('total', 0),
|
||||
loop_iteration=loop_iteration,
|
||||
download_reason=evt.get('reason') or evt.get('download_reason') or None,
|
||||
)
|
||||
if not item.nexus_url:
|
||||
# Engine contract says nexus_url should be present and non-empty.
|
||||
|
||||
@@ -8,7 +8,6 @@ Downloads and configures a standalone Mod Organizer 2 instance:
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -18,6 +17,8 @@ from typing import Callable, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from jackify.backend.services.tool_registry import _find_7z_binary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -41,10 +42,14 @@ class MO2SetupService:
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Extract the MO2 archive without interactive prompts and honor cancellation."""
|
||||
|
||||
sevenzip = _find_7z_binary()
|
||||
if not sevenzip:
|
||||
return False, "7z binary not found (bundled copy missing and no system 7z/7zz on PATH)"
|
||||
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
['7z', 'x', '-y', '-aoa', str(archive_path), f'-o{install_dir}'],
|
||||
[sevenzip, 'x', '-y', '-aoa', str(archive_path), f'-o{install_dir}'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -102,8 +107,8 @@ class MO2SetupService:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not shutil.which('7z'):
|
||||
return False, None, "7z not found. Install p7zip-full (or equivalent) first."
|
||||
if not _find_7z_binary():
|
||||
return False, None, "7z binary not found (bundled copy missing and no system 7z/7zz on PATH)"
|
||||
|
||||
if _is_dangerous_path(install_dir):
|
||||
return False, None, f"Refusing to install to dangerous path: {install_dir}"
|
||||
|
||||
@@ -30,12 +30,12 @@ class ModlistGalleryService:
|
||||
|
||||
# REMOVED: CACHE_VALIDITY_DAYS - metadata is now always fetched fresh from engine
|
||||
# Images are still cached indefinitely (managed separately)
|
||||
# CRITICAL: Thread lock to prevent concurrent engine calls that could cause recursive spawning
|
||||
_engine_call_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the gallery service"""
|
||||
self.config_handler = ConfigHandler()
|
||||
self._engine_process: Optional[subprocess.Popen] = None
|
||||
# Cache directories in Jackify Data Directory
|
||||
jackify_data_dir = get_jackify_data_dir()
|
||||
self.CACHE_DIR = jackify_data_dir / "modlist-cache" / "metadata"
|
||||
@@ -48,6 +48,15 @@ class ModlistGalleryService:
|
||||
self._allowed_tags_cache: Optional[set] = None
|
||||
self._allowed_tags_lookup: Optional[Dict[str, str]] = None
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Kill any in-progress engine call. Safe to call from any thread."""
|
||||
proc = self._engine_process
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_cache_dirs(self):
|
||||
"""Create cache directories if they don't exist"""
|
||||
self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -125,20 +134,28 @@ class ModlistGalleryService:
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
clean_env = get_clean_subprocess_env()
|
||||
|
||||
result = subprocess.run(
|
||||
self._engine_process = subprocess.Popen(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=300, # 5 minute timeout for large data
|
||||
env=clean_env
|
||||
env=clean_env,
|
||||
)
|
||||
try:
|
||||
stdout_data, stderr_data = self._engine_process.communicate(timeout=300)
|
||||
returncode = self._engine_process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
self._engine_process.kill()
|
||||
raise RuntimeError("jackify-engine timed out")
|
||||
finally:
|
||||
self._engine_process = None
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"jackify-engine failed: {result.stderr}")
|
||||
if returncode != 0:
|
||||
raise RuntimeError(f"jackify-engine failed: {stderr_data}")
|
||||
|
||||
# Parse JSON response - skip progress messages and extract JSON
|
||||
# jackify-engine prints progress to stdout before the JSON
|
||||
stdout = result.stdout.strip()
|
||||
stdout = stdout_data.strip()
|
||||
|
||||
# Find the start of JSON (first '{' on its own line)
|
||||
lines = stdout.split('\n')
|
||||
|
||||
@@ -152,31 +152,20 @@ class ModlistServiceInstallationMixin:
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
writeback_path = str(auth_service.get_token_writeback_path())
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
||||
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
|
||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
|
||||
env_overrides = {
|
||||
'JACKIFY_TOKEN_WRITEBACK': writeback_path,
|
||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': "1",
|
||||
}
|
||||
if oauth_info:
|
||||
env_overrides['NEXUS_OAUTH_INFO'] = oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
env_overrides['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
if api_key:
|
||||
env_overrides['NEXUS_API_KEY'] = api_key
|
||||
elif api_key:
|
||||
env_overrides['NEXUS_API_KEY'] = api_key
|
||||
|
||||
try:
|
||||
os.environ['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
if oauth_info:
|
||||
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
if api_key:
|
||||
os.environ['NEXUS_API_KEY'] = api_key
|
||||
elif api_key:
|
||||
os.environ['NEXUS_API_KEY'] = api_key
|
||||
else:
|
||||
if 'NEXUS_API_KEY' in os.environ:
|
||||
del os.environ['NEXUS_API_KEY']
|
||||
if 'NEXUS_OAUTH_INFO' in os.environ:
|
||||
del os.environ['NEXUS_OAUTH_INFO']
|
||||
|
||||
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
|
||||
|
||||
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
|
||||
if output_callback:
|
||||
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
|
||||
@@ -192,7 +181,7 @@ class ModlistServiceInstallationMixin:
|
||||
else:
|
||||
output_callback(f"File descriptor limit warning: {message}")
|
||||
|
||||
clean_env = get_clean_subprocess_env()
|
||||
clean_env = get_clean_subprocess_env(env_overrides)
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=False, env=clean_env, cwd=engine_dir
|
||||
@@ -322,12 +311,12 @@ class ModlistServiceInstallationMixin:
|
||||
output_callback("Installation completed successfully")
|
||||
return True
|
||||
|
||||
finally:
|
||||
for key, original_value in original_env_values.items():
|
||||
if original_value is not None:
|
||||
os.environ[key] = original_value
|
||||
elif key in os.environ:
|
||||
del os.environ[key]
|
||||
except Exception as e:
|
||||
error_msg = f"Error running Jackify Install Engine: {e}"
|
||||
logger.error(error_msg)
|
||||
if output_callback:
|
||||
output_callback(error_msg)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error running Jackify Install Engine: {e}"
|
||||
|
||||
@@ -147,6 +147,23 @@ class NexusDownloadService:
|
||||
output_path.unlink()
|
||||
return False
|
||||
|
||||
def get_latest_file_version(
|
||||
self,
|
||||
game_domain: str,
|
||||
mod_id: int,
|
||||
file_name_filter: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return the version string of the most recent file for a mod, or None."""
|
||||
files = self.get_mod_files(game_domain, mod_id)
|
||||
if not files:
|
||||
return None
|
||||
if file_name_filter:
|
||||
files = [f for f in files if file_name_filter.lower() in f.get("file_name", "").lower()]
|
||||
if not files:
|
||||
return None
|
||||
files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True)
|
||||
return files[0].get("version") or None
|
||||
|
||||
def download_latest_file(
|
||||
self,
|
||||
game_domain: str,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"SkyrimSE": {
|
||||
"mod_fixes": [
|
||||
{
|
||||
"mod": "Dialogue History",
|
||||
"create_dirs": [
|
||||
"users/steamuser/Documents/My Games/Skyrim Special Edition/Saves/DialogueHistory"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Fallout4": {
|
||||
"mod_fixes": [
|
||||
{ "mod": "MCM Booster", "disable": true }
|
||||
]
|
||||
},
|
||||
"SkyrimVR": { "mod_fixes": [] },
|
||||
"Fallout4VR": { "mod_fixes": [] },
|
||||
"Enderal": { "mod_fixes": [] }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Problem mods service.
|
||||
|
||||
Identifies and disables mods with known Proton compatibility issues by
|
||||
rewriting modlist.txt files atomically.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
|
||||
from jackify.backend.models.game_types import normalize_game_name
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROBLEM_MODS_MANIFEST_URL = (
|
||||
"https://raw.githubusercontent.com/Omni-guides/Jackify/main/manifests/problem_mods.json"
|
||||
)
|
||||
_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "problem_mods_manifest.json"
|
||||
|
||||
_CANONICAL_TO_MANIFEST_KEY = {
|
||||
"skyrim": "SkyrimSE",
|
||||
"skyrimse": "SkyrimSE",
|
||||
"fallout4": "Fallout4",
|
||||
"skyrimvr": "SkyrimVR",
|
||||
"fallout4vr": "Fallout4VR",
|
||||
"enderal": "Enderal",
|
||||
}
|
||||
|
||||
|
||||
def _load_bundled_manifest() -> dict:
|
||||
try:
|
||||
with open(_BUNDLED_MANIFEST_PATH, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("Bundled problem mods manifest load failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
_manifest_cache: Optional[dict] = None
|
||||
|
||||
|
||||
def _disk_cache_path() -> Path:
|
||||
return get_jackify_data_dir() / "manifests" / "problem_mods.json"
|
||||
|
||||
|
||||
def _load_disk_cache() -> Optional[dict]:
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_disk_cache(data: dict) -> None:
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".problem_mods_", suffix=".tmp")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2)
|
||||
os.replace(tmp, path)
|
||||
except Exception as e:
|
||||
logger.debug("Problem mods manifest disk save failed: %s", e)
|
||||
|
||||
|
||||
def fetch_remote_manifest() -> Optional[dict]:
|
||||
"""Fetch the remote problem mods manifest. Returns parsed dict or None on failure."""
|
||||
try:
|
||||
resp = requests.get(PROBLEM_MODS_MANIFEST_URL, timeout=8, verify=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("Problem mods manifest fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def apply_remote_manifest(data: dict) -> None:
|
||||
"""Store fetched manifest in memory and persist to disk."""
|
||||
global _manifest_cache
|
||||
_manifest_cache = data
|
||||
_save_disk_cache(data)
|
||||
|
||||
|
||||
def _effective_manifest() -> dict:
|
||||
if _manifest_cache is not None:
|
||||
return _manifest_cache
|
||||
disk = _load_disk_cache()
|
||||
if disk is not None:
|
||||
return disk
|
||||
return _load_bundled_manifest()
|
||||
|
||||
|
||||
def _get_mod_fixes(game_type: str) -> List[dict]:
|
||||
"""Return mod_fixes list for the given game type.
|
||||
|
||||
Handles old format (plain list of mod names = disable-only entries) so
|
||||
cached manifests from before the format change continue to work.
|
||||
"""
|
||||
canonical = normalize_game_name(game_type) or game_type.lower().replace(" ", "")
|
||||
manifest_key = _CANONICAL_TO_MANIFEST_KEY.get(canonical)
|
||||
if not manifest_key:
|
||||
return []
|
||||
raw = _effective_manifest().get(manifest_key, {})
|
||||
if isinstance(raw, list):
|
||||
return [{"mod": name, "disable": True} for name in raw]
|
||||
if isinstance(raw, dict):
|
||||
return raw.get("mod_fixes", [])
|
||||
return []
|
||||
|
||||
|
||||
def get_problem_mods(game_type: str) -> List[str]:
|
||||
"""Return names of mods flagged for disabling for the given game type."""
|
||||
return [fix["mod"] for fix in _get_mod_fixes(game_type) if fix.get("disable")]
|
||||
|
||||
|
||||
def get_enabled_mods(modlist_txt_path: Path) -> set:
|
||||
"""Return lowercase set of enabled mod names from a modlist.txt."""
|
||||
try:
|
||||
lines = modlist_txt_path.read_text(encoding="utf-8").splitlines()
|
||||
except Exception:
|
||||
return set()
|
||||
mods = set()
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("+"):
|
||||
name = stripped[1:]
|
||||
mods.add(name.lower())
|
||||
bare = re.sub(r'^\[.*?\]\s*', '', name)
|
||||
if bare != name:
|
||||
mods.add(bare.lower())
|
||||
return mods
|
||||
|
||||
|
||||
def create_prefix_dirs(wineprefix: Path, game_type: str, enabled_mods: set) -> List[str]:
|
||||
"""Create directories inside a Wine prefix for present problem mods.
|
||||
|
||||
Only acts when the associated mod is actually enabled in the modlist.
|
||||
Paths are relative to drive_c inside the prefix.
|
||||
Returns list of paths created.
|
||||
"""
|
||||
created: List[str] = []
|
||||
for fix in _get_mod_fixes(game_type):
|
||||
if not fix.get("create_dirs"):
|
||||
continue
|
||||
if fix.get("mod", "").lower() not in enabled_mods:
|
||||
continue
|
||||
for rel_path in fix["create_dirs"]:
|
||||
target = wineprefix / "drive_c" / rel_path
|
||||
if target.exists():
|
||||
continue
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Created prefix directory for '%s': %s", fix["mod"], target)
|
||||
created.append(rel_path)
|
||||
except Exception as e:
|
||||
logger.warning("Could not create prefix directory %s: %s", target, e)
|
||||
return created
|
||||
|
||||
|
||||
def disable_problem_mods(modlist_txt_path: Path, game_type: str) -> List[str]:
|
||||
"""Disable problem mods in modlist.txt by prepending '-' to enabled entries.
|
||||
|
||||
Reads modlist.txt, disables any enabled ('+name') line whose name is in the
|
||||
problem list, writes the file back atomically. Idempotent: already-disabled
|
||||
entries are not counted.
|
||||
|
||||
Returns list of mod names actually disabled (empty if none).
|
||||
"""
|
||||
problem_names = get_problem_mods(game_type)
|
||||
if not problem_names:
|
||||
return []
|
||||
|
||||
problem_set = {n.lower() for n in problem_names}
|
||||
|
||||
try:
|
||||
content = modlist_txt_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Could not read modlist.txt at %s: %s", modlist_txt_path, e)
|
||||
return []
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
disabled: List[str] = []
|
||||
new_lines: List[str] = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\r\n")
|
||||
if stripped.startswith("+"):
|
||||
name = stripped[1:]
|
||||
# Strip leading [tag] prefixes (e.g. "[PC] MCM Booster" -> "MCM Booster")
|
||||
bare_name = re.sub(r'^\[.*?\]\s*', '', name)
|
||||
if name.lower() in problem_set or bare_name.lower() in problem_set:
|
||||
eol = line[len(stripped):]
|
||||
new_lines.append(f"-{name}{eol}")
|
||||
disabled.append(name)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
if not disabled:
|
||||
return []
|
||||
|
||||
new_content = "".join(new_lines)
|
||||
try:
|
||||
dir_ = modlist_txt_path.parent
|
||||
fd, tmp_path = tempfile.mkstemp(dir=dir_, prefix=".modlist_", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(new_content)
|
||||
os.replace(tmp_path, modlist_txt_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Could not write modlist.txt at %s: %s", modlist_txt_path, e)
|
||||
return []
|
||||
|
||||
return disabled
|
||||
@@ -356,10 +356,12 @@ def apply_tool_config(
|
||||
"""
|
||||
Apply tool compatibility settings to the Wine prefix.
|
||||
|
||||
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK, which is
|
||||
required for Synthesis. Intentionally opt-in - the download is ~220MB and
|
||||
only appropriate when the user explicitly runs Configure Tool Compatibility
|
||||
from Additional Tasks.
|
||||
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK and flips the
|
||||
prefix to Windows 11, which are required for Synthesis. Intentionally opt-in -
|
||||
the download is ~220MB and the win11 flip has not been verified against NSF/CSF
|
||||
prefixes (see preserve_global_mscoree).
|
||||
The NuGet Root CA cert (also required for Synthesis) is applied regardless of
|
||||
this flag, since it has no interaction with mscoree hosting or Windows version.
|
||||
|
||||
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
|
||||
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
|
||||
@@ -383,9 +385,14 @@ def apply_tool_config(
|
||||
if install_dotnet9_sdk:
|
||||
_install_dotnet9_sdk(prefix_path, wine_bin, _log)
|
||||
_install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log)
|
||||
_install_nuget_cert(prefix_path, wine_bin, _log)
|
||||
_set_windows_version_win11(prefix_path, wine_bin, _log)
|
||||
|
||||
# NuGet cert import is independent of the SDK/win11 install above - it only adds a
|
||||
# Root CA entry and has no interaction with mscoree hosting, so it must not be
|
||||
# skipped for NSF/CSF modlists (which pass install_dotnet9_sdk=False to avoid the
|
||||
# win11 flip). Synthesis still needs this cert even on an NSF/CSF prefix.
|
||||
_install_nuget_cert(prefix_path, wine_bin, _log)
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -30,9 +30,9 @@ class ToolDefinition:
|
||||
tool_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
github_repo: str # e.g. "SulfurNitride/CLF3"
|
||||
asset_patterns: List[str] # ordered list of regex patterns to match release asset filename
|
||||
tier: int # 1 = Jackify invokes it, 2 = user runs it themselves
|
||||
github_repo: Optional[str] = None # e.g. "SulfurNitride/CLF3"; None for Nexus-only tools
|
||||
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
|
||||
@@ -45,6 +45,14 @@ class ToolDefinition:
|
||||
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
|
||||
|
||||
@property
|
||||
def upstream_url(self) -> Optional[str]:
|
||||
if self.github_repo:
|
||||
return f"https://github.com/{self.github_repo}"
|
||||
if self.nexus_mod_id:
|
||||
return f"https://www.nexusmods.com/{self.nexus_game_domain}/mods/{self.nexus_mod_id}"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStatus:
|
||||
@@ -107,13 +115,12 @@ TOOL_DEFINITIONS: List[ToolDefinition] = [
|
||||
tool_id="radium",
|
||||
display_name="Radium Textures",
|
||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.",
|
||||
github_repo="SulfurNitride/Radium-Textures",
|
||||
github_repo=None,
|
||||
asset_patterns=[r"radium.*linux.*x86_64", r"radium.*\.tar\.gz", r"radium.*\.zip"],
|
||||
executable_names=["radium", "radium-textures"],
|
||||
executable_names=["radium-textures", "radium"],
|
||||
tier=2,
|
||||
can_launch=True,
|
||||
nexus_mod_id=1660,
|
||||
nexus_file_filter="linux",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -145,10 +152,40 @@ def set_active_engine_id(tool_id: str) -> None:
|
||||
|
||||
|
||||
# -- remote manifest ---------------------------------------------------------
|
||||
TOOL_MANIFEST_URL = "https://raw.githubusercontent.com/Omni-guides/Jackify/main/tools_manifest.json"
|
||||
TOOL_MANIFEST_URL = "https://raw.githubusercontent.com/Omni-guides/Jackify/main/manifests/tools_manifest.json"
|
||||
_BUNDLED_MANIFEST_PATH = Path(__file__).parent / "tools_manifest.json"
|
||||
|
||||
|
||||
def _disk_cache_path() -> Path:
|
||||
from jackify.shared.paths import get_jackify_data_dir
|
||||
return get_jackify_data_dir() / "manifests" / "tools_manifest.json"
|
||||
|
||||
|
||||
def _save_disk_cache(entries: list) -> None:
|
||||
import tempfile
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tools_manifest_", suffix=".tmp")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(entries, fh, indent=2)
|
||||
os.replace(tmp, path)
|
||||
except Exception as e:
|
||||
logger.debug("Tool manifest disk save failed: %s", e)
|
||||
|
||||
|
||||
def _load_disk_cache() -> Optional[List[ToolDefinition]]:
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
entries = json.load(fh)
|
||||
if isinstance(entries, list):
|
||||
return _parse_manifest_entries(entries)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]:
|
||||
definitions = []
|
||||
for entry in entries:
|
||||
@@ -157,7 +194,7 @@ def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]:
|
||||
tool_id=entry["tool_id"],
|
||||
display_name=entry["display_name"],
|
||||
description=entry["description"],
|
||||
github_repo=entry["github_repo"],
|
||||
github_repo=entry.get("github_repo"),
|
||||
asset_patterns=entry["asset_patterns"],
|
||||
tier=entry.get("tier", 2),
|
||||
executable_names=entry.get("executable_names", []),
|
||||
@@ -199,15 +236,22 @@ def fetch_remote_manifest() -> Optional[List[ToolDefinition]]:
|
||||
entries = resp.json()
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
_save_disk_cache(entries)
|
||||
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]
|
||||
"""Remote manifest definitions if fetched this session, else disk cache, else bundled."""
|
||||
if _manifest_cache is not None:
|
||||
return [d for d in _manifest_cache if not d.hidden]
|
||||
disk = _load_disk_cache()
|
||||
if disk is not None:
|
||||
return [d for d in disk if not d.hidden]
|
||||
return [d for d in TOOL_DEFINITIONS if not d.hidden]
|
||||
|
||||
|
||||
def apply_remote_manifest(definitions: List[ToolDefinition]) -> None:
|
||||
"""Store fetched manifest as session cache and rebuild the tool map."""
|
||||
@@ -290,7 +334,7 @@ def fetch_release_list(github_repo: str, max_count: int = 10) -> List[dict]:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("Release list fetch failed for %s: %s", github_repo, e)
|
||||
logger.warning("Release list fetch failed for %s: %s", github_repo, e)
|
||||
return []
|
||||
|
||||
|
||||
@@ -341,38 +385,81 @@ def _verify_sha256_sums(sums_path: Path, target_path: Path) -> Tuple[bool, str]:
|
||||
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."""
|
||||
def _find_7z_binary() -> Optional[str]:
|
||||
"""Return path to 7z binary: bundled first, then system."""
|
||||
import shutil
|
||||
candidates = [
|
||||
Path(__file__).parent.parent.parent / "tools" / "7z",
|
||||
]
|
||||
appdir = os.environ.get("APPDIR")
|
||||
if appdir:
|
||||
candidates.insert(0, Path(appdir) / "opt" / "jackify" / "tools" / "7z")
|
||||
for c in candidates:
|
||||
if c.is_file() and os.access(c, os.X_OK):
|
||||
return str(c)
|
||||
return shutil.which("7z") or shutil.which("7zz")
|
||||
|
||||
|
||||
def _extract_archive(file_path: Path, target_dir: Path, delete_archive: bool = True) -> Tuple[bool, str]:
|
||||
"""Extract an archive or chmod an AppImage in place.
|
||||
|
||||
Deletes the archive after successful extraction unless delete_archive=False.
|
||||
Never deletes the archive on failure.
|
||||
"""
|
||||
import subprocess
|
||||
name_lower = file_path.name.lower()
|
||||
is_archive = False
|
||||
extracted = 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)
|
||||
extracted = True
|
||||
elif name_lower.endswith(".zip"):
|
||||
is_archive = True
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
zf.extractall(path=target_dir)
|
||||
extracted = True
|
||||
elif name_lower.endswith(".7z"):
|
||||
sevenzip = _find_7z_binary()
|
||||
if not sevenzip:
|
||||
return False, "7z binary not found - cannot extract .7z archive"
|
||||
result = subprocess.run(
|
||||
[sevenzip, "x", str(file_path), f"-o{target_dir}", "-y"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False, f"7z extraction failed: {result.stderr.strip() or result.stdout.strip()}"
|
||||
extracted = True
|
||||
elif name_lower.endswith(".appimage"):
|
||||
file_path.chmod(0o755)
|
||||
else:
|
||||
return False, f"Unsupported format: {file_path.name}"
|
||||
finally:
|
||||
if is_archive:
|
||||
if extracted and delete_archive:
|
||||
try:
|
||||
file_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if is_archive:
|
||||
if extracted:
|
||||
_chmod_elf_binaries(target_dir)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _extract_nested_archives(directory: Path) -> None:
|
||||
"""Extract any zip/tar.gz/7z files sitting directly inside directory, then delete them."""
|
||||
for child in list(directory.iterdir()):
|
||||
if not child.is_file():
|
||||
continue
|
||||
name_lower = child.name.lower()
|
||||
if any(name_lower.endswith(ext) for ext in (".zip", ".tar.gz", ".tgz", ".7z")):
|
||||
ok, err = _extract_archive(child, directory, delete_archive=True)
|
||||
if not ok:
|
||||
logger.warning("Nested archive extraction failed for %s: %s", child.name, err)
|
||||
|
||||
|
||||
def _chmod_elf_binaries(directory: Path) -> None:
|
||||
"""Set executable bit on any ELF binaries found directly in directory."""
|
||||
"""Set executable bit on any ELF binaries found in directory tree."""
|
||||
ELF_MAGIC = b'\x7fELF'
|
||||
for f in directory.iterdir():
|
||||
for f in directory.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
@@ -422,10 +509,20 @@ def _download_and_extract(
|
||||
return _extract_archive(temp_path, target_dir)
|
||||
|
||||
|
||||
def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, Optional[Path], str]:
|
||||
"""Attempt Nexus CDN download for premium users. Returns (success, file_path, message)."""
|
||||
_NEXUS_NOT_ELIGIBLE = "NEXUS_NOT_ELIGIBLE"
|
||||
|
||||
|
||||
def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, Optional[Path], str, Optional[str]]:
|
||||
"""Attempt Nexus CDN download for premium users.
|
||||
|
||||
Returns (success, file_path, message, version).
|
||||
message is _NEXUS_NOT_ELIGIBLE when the user is not authenticated or not premium,
|
||||
indicating a manual download dialog should be offered. Any other failure message
|
||||
means the user is premium but the download itself failed.
|
||||
version is the Nexus file version string on success, None otherwise.
|
||||
"""
|
||||
if not defn.nexus_mod_id:
|
||||
return False, None, "No Nexus mod configured"
|
||||
return False, None, _NEXUS_NOT_ELIGIBLE, None
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
||||
@@ -433,19 +530,24 @@ def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, O
|
||||
auth = NexusAuthService()
|
||||
token = auth.get_auth_token()
|
||||
if not token:
|
||||
return False, None, "No Nexus auth token"
|
||||
return False, None, _NEXUS_NOT_ELIGIBLE, None
|
||||
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(
|
||||
return False, None, _NEXUS_NOT_ELIGIBLE, None
|
||||
svc = NexusDownloadService(token)
|
||||
nexus_version = svc.get_latest_file_version(
|
||||
defn.nexus_game_domain, defn.nexus_mod_id,
|
||||
file_name_filter=defn.nexus_file_filter,
|
||||
)
|
||||
ok, path, msg = svc.download_latest_file(
|
||||
defn.nexus_game_domain, defn.nexus_mod_id, target_dir,
|
||||
file_name_filter=defn.nexus_file_filter,
|
||||
)
|
||||
return ok, path, msg
|
||||
return ok, path, msg, nexus_version if ok else None
|
||||
except Exception as e:
|
||||
logger.debug("Nexus download attempt failed for %s: %s", defn.tool_id, e)
|
||||
return False, None, str(e)
|
||||
logger.warning("Nexus download failed for %s: %s", defn.tool_id, e)
|
||||
return False, None, str(e), None
|
||||
|
||||
|
||||
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
|
||||
@@ -474,12 +576,32 @@ class ToolRegistry:
|
||||
def get_all_statuses(self) -> List[ToolStatus]:
|
||||
return [self._build_status(d) for d in get_effective_definitions()]
|
||||
|
||||
def _check_latest_nexus_version(self, defn: ToolDefinition) -> Optional[str]:
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
from jackify.backend.services.nexus_download_service import NexusDownloadService
|
||||
auth = NexusAuthService()
|
||||
token = auth.get_auth_token()
|
||||
if not token:
|
||||
return None
|
||||
return NexusDownloadService(token).get_latest_file_version(
|
||||
defn.nexus_game_domain, defn.nexus_mod_id,
|
||||
file_name_filter=defn.nexus_file_filter,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Nexus version check failed for %s: %s", defn.tool_id, e)
|
||||
return None
|
||||
|
||||
def check_latest_version(self, tool_id: str) -> Optional[str]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return None
|
||||
if defn.pinned_version:
|
||||
return defn.pinned_version
|
||||
if not defn.github_repo:
|
||||
if defn.nexus_mod_id:
|
||||
return self._check_latest_nexus_version(defn)
|
||||
return None
|
||||
if defn.include_prereleases:
|
||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||
if releases:
|
||||
@@ -504,10 +626,20 @@ class ToolRegistry:
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pin = version or defn.pinned_version
|
||||
nexus_ok, nexus_path, _ = _try_nexus_download(defn, install_dir) if not version else (False, None, "")
|
||||
nexus_ok, nexus_path, nexus_msg, nexus_version = _try_nexus_download(defn, install_dir) if not version else (False, None, _NEXUS_NOT_ELIGIBLE, None)
|
||||
if nexus_ok and nexus_path:
|
||||
ok, err = _extract_archive(nexus_path, install_dir)
|
||||
tag = pin or "nexus"
|
||||
if ok:
|
||||
_extract_nested_archives(install_dir)
|
||||
tag = pin or nexus_version or "nexus"
|
||||
elif not defn.github_repo:
|
||||
if nexus_msg == _NEXUS_NOT_ELIGIBLE:
|
||||
nexus_url = (
|
||||
f"https://www.nexusmods.com/{defn.nexus_game_domain}/mods/{defn.nexus_mod_id}"
|
||||
if defn.nexus_mod_id else ""
|
||||
)
|
||||
return False, f"NEXUS_MANUAL_REQUIRED:{nexus_url}"
|
||||
return False, nexus_msg or f"Failed to download {defn.display_name} from Nexus"
|
||||
else:
|
||||
if defn.include_prereleases and not pin:
|
||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||
@@ -545,6 +677,38 @@ class ToolRegistry:
|
||||
logger.info("Installed %s %s", defn.display_name, tag)
|
||||
return True, f"{defn.display_name} {tag} installed"
|
||||
|
||||
def install_from_archive(self, tool_id: str, archive_path: Path) -> Tuple[bool, str]:
|
||||
"""Install a tool from a locally downloaded archive (manual download fallback)."""
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
return False, f"Unknown tool: {tool_id}"
|
||||
|
||||
install_dir = TOOLS_BASE_DIR / tool_id
|
||||
install_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ok, err = _extract_archive(archive_path, install_dir, delete_archive=False)
|
||||
if not ok:
|
||||
return False, err
|
||||
|
||||
_extract_nested_archives(install_dir)
|
||||
exe_path = _find_executable(defn, install_dir)
|
||||
if exe_path:
|
||||
try:
|
||||
os.chmod(exe_path, 0o755)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manifest = _read_manifest(tool_id)
|
||||
_write_manifest(tool_id, {
|
||||
"installed_version": "manual",
|
||||
"previous_version": manifest.get("installed_version"),
|
||||
"binary_path": str(exe_path) if exe_path else None,
|
||||
"install_dir": str(install_dir),
|
||||
})
|
||||
|
||||
logger.info("Installed %s from local archive %s", defn.display_name, archive_path.name)
|
||||
return True, f"{defn.display_name} installed"
|
||||
|
||||
def update(self, tool_id: str) -> Tuple[bool, str]:
|
||||
defn = _TOOL_MAP.get(tool_id)
|
||||
if defn is None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"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",
|
||||
"github_repo": "Omni-guides/dev-jackify-engine",
|
||||
"asset_patterns": ["jackify-engine.*linux.*x64.*\\.tar\\.gz", "jackify-engine.*\\.tar\\.gz", "jackify-engine.*\\.zip"],
|
||||
"executable_names": ["jackify-engine"],
|
||||
"tier": 1,
|
||||
@@ -42,7 +42,7 @@
|
||||
"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",
|
||||
"github_repo": null,
|
||||
"asset_patterns": ["radium.*linux.*x86_64", "radium.*\\.tar\\.gz", "radium.*\\.zip"],
|
||||
"executable_names": ["radium", "radium-textures"],
|
||||
"tier": 2,
|
||||
@@ -50,7 +50,7 @@
|
||||
"can_launch": true,
|
||||
"nexus_mod_id": 1660,
|
||||
"nexus_game_domain": "site",
|
||||
"nexus_file_filter": "linux",
|
||||
"hidden": true
|
||||
"nexus_file_filter": null,
|
||||
"hidden": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Update-vs-new installation detection service.
|
||||
|
||||
Free functions used by both GUI (install_modlist_workflow.py) and CLI
|
||||
(modlist_operations.py) to avoid duplicated logic.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_version_token(value: Optional[str]) -> Optional[str]:
|
||||
"""Return a normalised version token for equality checks."""
|
||||
if value is None:
|
||||
return None
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
return None
|
||||
return token.lstrip("vV").lower()
|
||||
|
||||
|
||||
def normalize_modlist_name(value: Optional[str]) -> str:
|
||||
"""Return a case/whitespace-normalised modlist name for comparison."""
|
||||
return " ".join((value or "").strip().lower().split())
|
||||
|
||||
|
||||
def evaluate_update_candidate(
|
||||
modlist_name: str,
|
||||
install_dir: str,
|
||||
existing_appid: Optional[str],
|
||||
requested_version: Optional[str] = None,
|
||||
) -> tuple[bool, dict]:
|
||||
"""Decide whether update-mode should be offered.
|
||||
|
||||
Args:
|
||||
modlist_name: Name of the modlist being installed.
|
||||
install_dir: Resolved installation directory path.
|
||||
existing_appid: Steam AppID from an existing shortcut (None if not found).
|
||||
requested_version: Pre-computed normalised version from the selected modlist
|
||||
(pass None when unavailable, e.g. offline/file-based installs).
|
||||
|
||||
Returns:
|
||||
(eligible, result_dict) where eligible is True when update mode is safe to offer.
|
||||
"""
|
||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||
|
||||
result: dict = {
|
||||
"eligible": False,
|
||||
"reason": "unknown",
|
||||
"requested_version": None,
|
||||
"installed_version": None,
|
||||
"version_relation": "unknown",
|
||||
"installed_name": None,
|
||||
}
|
||||
|
||||
if not existing_appid:
|
||||
result["reason"] = "missing_shortcut_appid"
|
||||
return False, result
|
||||
|
||||
meta = read_modlist_meta(install_dir)
|
||||
if not meta:
|
||||
result["reason"] = "missing_meta"
|
||||
return False, result
|
||||
|
||||
installed_name = (meta.get("modlist_name") or "").strip()
|
||||
result["installed_name"] = installed_name
|
||||
|
||||
if normalize_modlist_name(installed_name) != normalize_modlist_name(modlist_name):
|
||||
result["reason"] = "modlist_name_mismatch"
|
||||
return False, result
|
||||
|
||||
installed_version = normalize_version_token(meta.get("modlist_version"))
|
||||
result["requested_version"] = requested_version
|
||||
result["installed_version"] = installed_version
|
||||
|
||||
if requested_version and installed_version:
|
||||
result["version_relation"] = (
|
||||
"same" if requested_version == installed_version else "different"
|
||||
)
|
||||
|
||||
result["eligible"] = True
|
||||
result["reason"] = "eligible"
|
||||
return True, result
|
||||
|
||||
|
||||
def find_existing_shortcut_appid(modlist_name: str, install_dir: str) -> Optional[str]:
|
||||
"""Return the Steam AppID of an existing shortcut for this install, or None."""
|
||||
try:
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
shortcut_handler = ShortcutHandler(
|
||||
steamdeck=platform_service.is_steamdeck, verbose=False
|
||||
)
|
||||
|
||||
install_real = os.path.realpath(install_dir)
|
||||
candidate_exes = [
|
||||
os.path.join(install_real, "ModOrganizer.exe"),
|
||||
os.path.join(install_real, "files", "ModOrganizer.exe"),
|
||||
]
|
||||
|
||||
for exe_path in candidate_exes:
|
||||
if not os.path.exists(exe_path):
|
||||
continue
|
||||
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
||||
if appid:
|
||||
return appid
|
||||
|
||||
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||
if (
|
||||
(shortcut.get("AppName", "").strip() == modlist_name.strip())
|
||||
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
|
||||
):
|
||||
raw_appid = shortcut.get("appid")
|
||||
if raw_appid is not None:
|
||||
return str(int(raw_appid) & 0xFFFFFFFF)
|
||||
except Exception as e:
|
||||
logger.warning("Update detection: failed shortcut lookup: %s", e)
|
||||
return None
|
||||
@@ -12,6 +12,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
import requests
|
||||
@@ -52,25 +53,61 @@ class UpdateService:
|
||||
self.github_api_base = "https://api.github.com"
|
||||
self.update_check_timeout = 10 # seconds
|
||||
|
||||
_UPDATE_CHECK_CACHE_HOURS = 24
|
||||
|
||||
def _get_last_check_timestamp(self) -> Optional[datetime]:
|
||||
"""Read last successful update-check timestamp from config."""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
raw = ConfigHandler().get('last_update_check')
|
||||
if raw:
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _save_last_check_timestamp(self) -> None:
|
||||
"""Store the current UTC time as the last successful update-check timestamp."""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
config = ConfigHandler()
|
||||
config.set('last_update_check', now)
|
||||
config.save_config()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_for_updates(self) -> Optional[UpdateInfo]:
|
||||
"""Check for available updates via GitHub releases API.
|
||||
|
||||
Returns UpdateInfo if an update is available, None otherwise.
|
||||
Skips the network call if a successful check was made within the last 24 hours.
|
||||
"""
|
||||
Check for available updates via GitHub releases API.
|
||||
|
||||
Returns:
|
||||
UpdateInfo if update available, None otherwise
|
||||
"""
|
||||
last_check = self._get_last_check_timestamp()
|
||||
if last_check is not None:
|
||||
age_hours = (datetime.now(timezone.utc) - last_check).total_seconds() / 3600
|
||||
if age_hours < self._UPDATE_CHECK_CACHE_HOURS:
|
||||
logger.debug("Skipping update check - last check was %.1f hours ago", age_hours)
|
||||
return None
|
||||
try:
|
||||
url = f"{self.github_api_base}/repos/{self.github_repo}/releases/latest"
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': f'Jackify/{self.current_version}'
|
||||
}
|
||||
|
||||
token = os.environ.get('GITHUB_TOKEN')
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
logger.debug(f"Checking for updates at {url}")
|
||||
response = requests.get(url, headers=headers, timeout=self.update_check_timeout)
|
||||
if response.status_code == 403:
|
||||
logger.debug("GitHub API rate limit reached (403) - update check skipped")
|
||||
return None
|
||||
response.raise_for_status()
|
||||
|
||||
release_data = response.json()
|
||||
self._save_last_check_timestamp()
|
||||
latest_version = release_data['tag_name'].lstrip('v')
|
||||
|
||||
if self._is_newer_version(latest_version):
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import Optional, Callable
|
||||
from ..handlers.subprocess_utils import get_clean_subprocess_env
|
||||
from .nexus_download_service import NexusDownloadService
|
||||
from .nexus_auth_service import NexusAuthService
|
||||
from .tool_registry import _find_7z_binary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -140,8 +141,11 @@ class VNVPostInstallService:
|
||||
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
elif suffix == ".7z":
|
||||
sevenzip = _find_7z_binary()
|
||||
if not sevenzip:
|
||||
return False, None, "7z binary not found (bundled copy missing and no system 7z/7zz on PATH)"
|
||||
result = subprocess.run(
|
||||
["7z", "x", "-y", f"-o{extract_dir}", str(archive_path)],
|
||||
[sevenzip, "x", "-y", f"-o{extract_dir}", str(archive_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
|
||||
Reference in New Issue
Block a user