mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 00:23:42 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
+1
-1
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.7.1"
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional, Dict, List, Any, Union
|
||||
from ..handlers.protontricks_handler import ProtontricksHandler
|
||||
from ..handlers.shortcut_handler import ShortcutHandler
|
||||
from ..handlers.menu_handler import MenuHandler, ModlistMenuHandler
|
||||
from ..handlers.ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION
|
||||
import logging
|
||||
from ..handlers.wabbajack_parser import WabbajackParser
|
||||
import re
|
||||
@@ -24,6 +24,12 @@ from .modlist_operations_configuration_cli import ModlistOperationsConfiguration
|
||||
from .modlist_operations_configuration_gui import ModlistOperationsConfigurationGUIMixin
|
||||
from .modlist_operations_game_detection import ModlistOperationsGameDetectionMixin
|
||||
from .modlist_operations_nexus import ModlistOperationsNexusMixin
|
||||
from jackify.backend.services.update_detection import (
|
||||
evaluate_update_candidate as _svc_evaluate,
|
||||
find_existing_shortcut_appid as _svc_find_appid,
|
||||
normalize_version_token,
|
||||
normalize_modlist_name,
|
||||
)
|
||||
|
||||
|
||||
def _get_user_proton_version():
|
||||
@@ -179,22 +185,12 @@ class ModlistInstallCLI(
|
||||
# Initialize process tracking for cleanup
|
||||
self._current_process = None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version_token(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
return None
|
||||
return token.lstrip("vV").lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_modlist_name(value: str | None) -> str:
|
||||
return " ".join((value or "").strip().lower().split())
|
||||
_normalize_version_token = staticmethod(normalize_version_token)
|
||||
_normalize_modlist_name = staticmethod(normalize_modlist_name)
|
||||
|
||||
def _get_requested_modlist_version(self) -> str | None:
|
||||
info = self.context.get("selected_modlist_info") or {}
|
||||
return self._normalize_version_token(info.get("version"))
|
||||
return normalize_version_token(info.get("version"))
|
||||
|
||||
def _evaluate_update_candidate(
|
||||
self,
|
||||
@@ -202,68 +198,11 @@ class ModlistInstallCLI(
|
||||
install_dir: str,
|
||||
existing_appid: str | None,
|
||||
) -> tuple[bool, dict]:
|
||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||
|
||||
result = {
|
||||
"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 self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
|
||||
result["reason"] = "modlist_name_mismatch"
|
||||
return False, result
|
||||
|
||||
requested_version = self._get_requested_modlist_version()
|
||||
installed_version = self._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
|
||||
return _svc_evaluate(modlist_name, install_dir, existing_appid, requested_version)
|
||||
|
||||
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||
try:
|
||||
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 = self.shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
||||
if appid:
|
||||
return appid
|
||||
|
||||
for shortcut in self.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:
|
||||
self.logger.warning("CLI update detection: failed shortcut lookup: %s", e)
|
||||
return None
|
||||
return _svc_find_appid(modlist_name, install_dir)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running jackify-engine process"""
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ..handlers.ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT,
|
||||
COLOR_RESET,
|
||||
COLOR_INFO,
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from ..handlers.ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT,
|
||||
COLOR_RESET,
|
||||
COLOR_INFO,
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..handlers.ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ Windows Registry Editor Version 5.00
|
||||
"xactengine3_5"="native,builtin"
|
||||
"xactengine3_6"="native,builtin"
|
||||
"xactengine3_7"="native,builtin"
|
||||
"atl110"="native,builtin"
|
||||
"msvcp110"="native,builtin"
|
||||
"msvcr110"="native,builtin"
|
||||
"vcomp110"="native,builtin"
|
||||
"concrt140"="native,builtin"
|
||||
"msvcp140"="native,builtin"
|
||||
"msvcp140_1"="native,builtin"
|
||||
|
||||
@@ -45,6 +45,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
return
|
||||
ConfigHandler._initialized = True
|
||||
|
||||
self._dirty_keys: set = set()
|
||||
|
||||
self.config_dir = os.path.expanduser("~/.config/jackify")
|
||||
self.config_file = os.path.join(self.config_dir, "config.json")
|
||||
self.settings = {
|
||||
@@ -146,7 +148,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
logger.info(f"Migrating config from {current_version} to {target_version}")
|
||||
|
||||
# Migration: v0.0.x -> v0.2.0
|
||||
# Encryption changed from cryptography (Fernet) to pycryptodome (AES-GCM)
|
||||
# Encryption changed from cryptography (Fernet) to pycryptodomex (AES-GCM)
|
||||
# Old encrypted API keys cannot be decrypted, must be re-entered
|
||||
from packaging import version
|
||||
if version.parse(current_version) < version.parse("0.2.0"):
|
||||
@@ -219,14 +221,34 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
logger.error(f"Error creating configuration directory: {e}")
|
||||
|
||||
def save_config(self):
|
||||
"""Save current configuration to file"""
|
||||
"""Save current configuration to file.
|
||||
|
||||
Reads disk state, overlays only dirty keys, then writes atomically.
|
||||
This prevents concurrent callers from clobbering each other's saves.
|
||||
"""
|
||||
import tempfile
|
||||
try:
|
||||
self._create_config_dir()
|
||||
# When dirty keys exist, read disk and overlay only those keys so concurrent
|
||||
# callers don't clobber each other. When no keys are dirty (migration/bootstrap
|
||||
# code that mutates self.settings directly), fall back to writing all settings.
|
||||
if self._dirty_keys:
|
||||
try:
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, 'r') as f:
|
||||
on_disk = json.load(f)
|
||||
else:
|
||||
on_disk = self.settings.copy()
|
||||
except Exception:
|
||||
on_disk = self.settings.copy()
|
||||
for key in self._dirty_keys:
|
||||
on_disk[key] = self.settings[key]
|
||||
else:
|
||||
on_disk = self.settings.copy()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.config_file), prefix='.config_tmp_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
json.dump(on_disk, f, indent=2)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, self.config_file)
|
||||
except Exception:
|
||||
@@ -235,6 +257,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
self._dirty_keys.clear()
|
||||
logger.debug("Saved configuration to file")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -242,35 +265,38 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
||||
return False
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""
|
||||
Get a configuration value by key.
|
||||
Always reads fresh from disk to avoid stale data.
|
||||
"""
|
||||
"""Return configuration value, preferring any unsaved in-memory value."""
|
||||
if key in self._dirty_keys:
|
||||
return self.settings.get(key, default)
|
||||
config = self._read_config_from_disk()
|
||||
return config.get(key, default)
|
||||
|
||||
def set(self, key, value):
|
||||
"""Set a configuration value"""
|
||||
"""Set a configuration value (marks key dirty until save_config is called)."""
|
||||
self.settings[key] = value
|
||||
self._dirty_keys.add(key)
|
||||
return True
|
||||
|
||||
|
||||
def update(self, settings_dict):
|
||||
"""Update multiple configuration values"""
|
||||
"""Update multiple configuration values (marks all keys dirty)."""
|
||||
self.settings.update(settings_dict)
|
||||
self._dirty_keys.update(settings_dict.keys())
|
||||
return True
|
||||
|
||||
def add_steam_library(self, path):
|
||||
"""Add a Steam library path to configuration"""
|
||||
if path not in self.settings["steam_libraries"]:
|
||||
self.settings["steam_libraries"].append(path)
|
||||
self._dirty_keys.add("steam_libraries")
|
||||
logger.debug(f"Added Steam library: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove_steam_library(self, path):
|
||||
"""Remove a Steam library path from configuration"""
|
||||
if path in self.settings["steam_libraries"]:
|
||||
self.settings["steam_libraries"].remove(path)
|
||||
self._dirty_keys.add("steam_libraries")
|
||||
logger.debug(f"Removed Steam library: {path}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -2,88 +2,24 @@
|
||||
Config handler API key encryption and storage.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from jackify.backend.utils.machine_crypto import encrypt as _encrypt, decrypt as _decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigEncryptionMixin:
|
||||
"""Mixin providing encryption and API key storage for ConfigHandler."""
|
||||
|
||||
def _get_encryption_key(self) -> bytes:
|
||||
"""Generate Fernet-compatible encryption key for API key storage."""
|
||||
import socket
|
||||
import getpass
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
machine_id = None
|
||||
try:
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except Exception:
|
||||
try:
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
key_material = f"{hostname}:{username}:{machine_id}:jackify" if machine_id else f"{hostname}:{username}:jackify"
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get machine info for encryption: %s", e)
|
||||
key_material = "jackify:default:key"
|
||||
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
def _encrypt_api_key(self, api_key: str) -> str:
|
||||
"""Encrypt API key using AES-GCM."""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
nonce = get_random_bytes(12)
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(api_key.encode('utf-8'))
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
except ImportError:
|
||||
logger.warning("pycryptodome not available, using base64 encoding (less secure)")
|
||||
return base64.b64encode(api_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error("Error encrypting API key: %s", e)
|
||||
return ""
|
||||
"""Encrypt API key using AES-GCM via machine_crypto."""
|
||||
return _encrypt(api_key)
|
||||
|
||||
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
||||
"""Decrypt API key using AES-GCM."""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
if not hasattr(AES, 'MODE_GCM'):
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
key = base64.urlsafe_b64decode(self._get_encryption_key())
|
||||
combined = base64.b64decode(encrypted_key.encode('utf-8'))
|
||||
nonce = combined[:12]
|
||||
tag = combined[-16:]
|
||||
ciphertext = combined[12:-16]
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
return plaintext.decode('utf-8')
|
||||
except ImportError:
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
except (AttributeError, Exception):
|
||||
try:
|
||||
return base64.b64decode(encrypted_key.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error("Error decrypting API key: %s", e)
|
||||
return None
|
||||
"""Decrypt API key. Returns None on any failure - never returns garbage."""
|
||||
return _decrypt(encrypted_key)
|
||||
|
||||
def save_api_key(self, api_key):
|
||||
"""Save Nexus API key with encryption."""
|
||||
|
||||
@@ -20,13 +20,13 @@ class FilesystemOwnershipMixin:
|
||||
def all_owned_by_user(path: Path) -> bool:
|
||||
"""Return True if all files and directories under path are owned by the current user."""
|
||||
uid = os.getuid()
|
||||
gid = os.getgid()
|
||||
gids = set([os.getgid()] + os.getgroups())
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in dirs + files:
|
||||
full_path = os.path.join(root, name)
|
||||
try:
|
||||
stat = os.stat(full_path)
|
||||
if stat.st_uid != uid or stat.st_gid != gid:
|
||||
if stat.st_uid != uid or stat.st_gid not in gids:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
@@ -50,7 +50,7 @@ class FilesystemOwnershipMixin:
|
||||
if not FilesystemOwnershipMixin.all_owned_by_user(path):
|
||||
try:
|
||||
user_name = pwd.getpwuid(os.geteuid()).pw_name
|
||||
group_name = grp.getgrgid(os.geteuid()).gr_name
|
||||
group_name = grp.getgrgid(os.getgid()).gr_name
|
||||
except KeyError:
|
||||
logger.error("Could not determine current user or group name.")
|
||||
return False, "Could not determine current user or group name."
|
||||
@@ -68,6 +68,9 @@ class FilesystemOwnershipMixin:
|
||||
return False, error_msg
|
||||
|
||||
logger.info("Files in %s are owned by current user, verifying permissions...", path)
|
||||
if FilesystemOwnershipMixin._perms_already_ok(path):
|
||||
logger.info("Permissions already correct for %s, skipping chmod", path)
|
||||
return True, ""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['chmod', '-R', '755', str(path)],
|
||||
@@ -84,6 +87,19 @@ class FilesystemOwnershipMixin:
|
||||
logger.warning("Error running chmod: %s, continuing anyway", e)
|
||||
return True, ""
|
||||
|
||||
@staticmethod
|
||||
def _perms_already_ok(path: Path) -> bool:
|
||||
"""Return True if every entry under path already has mode 0o755."""
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in dirs + files:
|
||||
full = os.path.join(root, name)
|
||||
try:
|
||||
if os.stat(full).st_mode & 0o777 != 0o755:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
||||
"""Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import glob # Add for the simpler tab completion
|
||||
|
||||
# Import colors from the new central location
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
|
||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_DISABLED, COLOR_ACTION, COLOR_INPUT
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import os
|
||||
import glob
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
|
||||
|
||||
READLINE_AVAILABLE = False
|
||||
READLINE_HAS_PROMPT = False
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
|
||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR
|
||||
from .resolution_handler import ResolutionHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -596,7 +596,9 @@ class ModlistConfigurationMixin:
|
||||
# dotnet9 SDK install also flips the prefix to win11; NSF/CSF was only
|
||||
# verified working on win10 + global-native, so we keep that state and skip
|
||||
# the dotnet9/win11 step. Whether Synthesis runs under global-native on an NSF
|
||||
# prefix is untested - revisit if a modlist needs live Synthesis.
|
||||
# prefix has not been specifically tested - revisit if a modlist needs live
|
||||
# Synthesis. The NuGet cert Synthesis needs is applied regardless (see
|
||||
# apply_tool_config), since it doesn't touch mscoree or Windows version.
|
||||
_nsf = getattr(self, '_nsf_detected', False)
|
||||
apply_tool_config(
|
||||
compatdata_path,
|
||||
|
||||
@@ -27,7 +27,7 @@ from .modlist_configuration import ModlistConfigurationMixin
|
||||
from .modlist_wine_ops import ModlistWineOpsMixin
|
||||
|
||||
# Import colors from the new central location
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_SELECTION, COLOR_ERROR
|
||||
|
||||
# Standard logging (no file handler)
|
||||
import logging
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional, Dict, List, Any, Union
|
||||
from .protontricks_handler import ProtontricksHandler
|
||||
from .shortcut_handler import ShortcutHandler
|
||||
from .menu_handler import MenuHandler, ModlistMenuHandler
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_SUCCESS, COLOR_WARNING, COLOR_SELECTION
|
||||
# Standard logging (no file handler) - LoggingHandler import removed
|
||||
import re
|
||||
import subprocess
|
||||
@@ -20,7 +20,7 @@ import time
|
||||
import pty
|
||||
|
||||
# Import UI Colors first - these should always be available
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_SELECTION, COLOR_WARNING
|
||||
|
||||
# Standard logging (no file handler) - LoggingHandler import removed
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT,
|
||||
COLOR_RESET,
|
||||
COLOR_INFO,
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
from .config_handler import ConfigHandler
|
||||
from .ui_colors import (
|
||||
from jackify.shared.colors import (
|
||||
COLOR_PROMPT,
|
||||
COLOR_RESET,
|
||||
COLOR_INFO,
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import signal
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -371,29 +371,89 @@ class NativeComponentInstaller:
|
||||
return False
|
||||
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
# x86 inner cab is 'a10', x64 inner cab is 'a12'
|
||||
|
||||
# DLL overrides must be written before the installer runs so Wine picks them up
|
||||
self._apply_dll_overrides()
|
||||
|
||||
env = self._wine_env_base()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, cab, dest, dlls in [(x86, 'a10', syswow64, _VCRUN2022_DLLS_X86),
|
||||
(x64, 'a12', system32, _VCRUN2022_DLLS_X64)]:
|
||||
arch_tmp = Path(tmpdir) / cab
|
||||
arch_tmp.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(arch_tmp), '-F', cab, str(exe)], capture_output=True)
|
||||
inner_cab = arch_tmp / cab
|
||||
if not inner_cab.is_file():
|
||||
self.logger.error("vcrun2022: inner cab '%s' not found in %s", cab, exe.name)
|
||||
return False
|
||||
for dll_name in dlls:
|
||||
subprocess.run([cabextract, '-d', str(dest), '-F', dll_name, str(inner_cab)], capture_output=True)
|
||||
if not (dest / 'msvcp140.dll').is_file():
|
||||
self.logger.error("vcrun2022: msvcp140.dll not extracted to %s", dest)
|
||||
return False
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# x86: pre-extract msvcp140.dll before running the installer.
|
||||
# Wine's builtin msvcp140 reports a higher version number so the installer
|
||||
# skips it without this manual extraction (Wine bug #57518).
|
||||
win32_tmp = tmpdir_path / 'win32'
|
||||
win32_tmp.mkdir()
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(win32_tmp), '-F', 'a10', str(x86)],
|
||||
capture_output=True,
|
||||
)
|
||||
inner_x86 = win32_tmp / 'a10'
|
||||
if inner_x86.is_file():
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(syswow64), '-F', 'msvcp140.dll', str(inner_x86)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
self.logger.warning("vcrun2022: x86 inner cab 'a10' not found, msvcp140.dll pre-extraction skipped")
|
||||
|
||||
self.logger.info("vcrun2022: running x86 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x86), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
)
|
||||
# 3010 = reboot required - normal for VC redist, treat as success
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2022: x86 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2022 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
# x64: same msvcp140.dll pre-extraction workaround
|
||||
win64_tmp = tmpdir_path / 'win64'
|
||||
win64_tmp.mkdir()
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(win64_tmp), '-F', 'a12', str(x64)],
|
||||
capture_output=True,
|
||||
)
|
||||
inner_x64 = win64_tmp / 'a12'
|
||||
if inner_x64.is_file():
|
||||
subprocess.run(
|
||||
[cabextract, '-d', str(system32), '-F', 'msvcp140.dll', str(inner_x64)],
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
self.logger.warning("vcrun2022: x64 inner cab 'a12' not found, msvcp140.dll pre-extraction skipped")
|
||||
|
||||
self.logger.info("vcrun2022: running x64 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x64), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=600,
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2022: x64 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2022 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
critical = [
|
||||
(syswow64 / 'msvcp140.dll', 'msvcp140.dll (x86)'),
|
||||
(syswow64 / 'vcruntime140.dll', 'vcruntime140.dll (x86)'),
|
||||
(system32 / 'msvcp140.dll', 'msvcp140.dll (x64)'),
|
||||
(system32 / 'vcruntime140.dll', 'vcruntime140.dll (x64)'),
|
||||
(system32 / 'vcruntime140_1.dll', 'vcruntime140_1.dll (x64)'),
|
||||
]
|
||||
for path, label in critical:
|
||||
if not path.is_file():
|
||||
self.logger.error("vcrun2022: %s missing after install", label)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _install_vcrun2012(self) -> bool:
|
||||
cabextract = self._get_cabextract()
|
||||
if not cabextract:
|
||||
self.logger.warning("cabextract not available for vcrun2012")
|
||||
return False
|
||||
cache_dir = get_jackify_data_dir() / 'component_cache' / 'vcrun2012'
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
x86 = cache_dir / 'vcredist_x86.exe'
|
||||
@@ -402,24 +462,38 @@ class NativeComponentInstaller:
|
||||
return False
|
||||
if not self._download_file(_VCRUN2012_X64_URL, x64):
|
||||
return False
|
||||
|
||||
self._apply_dll_overrides()
|
||||
|
||||
env = self._wine_env_base()
|
||||
syswow64, system32 = self._get_system_dirs()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
for exe, dest in [(x86, syswow64), (x64, system32)]:
|
||||
for cab_name in ('a2', 'a3'):
|
||||
td = Path(tmpdir) / (exe.stem + cab_name)
|
||||
td.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(td), '-F', cab_name, str(exe)], capture_output=True)
|
||||
inner = td / cab_name
|
||||
if not inner.is_file():
|
||||
continue
|
||||
dd = td / 'x'
|
||||
dd.mkdir()
|
||||
subprocess.run([cabextract, '-d', str(dd), '-L', '-F', 'F_CENTRAL_*', str(inner)], capture_output=True)
|
||||
for src in dd.iterdir():
|
||||
if src.name.startswith('f_central_'):
|
||||
shutil.copy2(src, dest / (src.name[10:].rsplit('_', 1)[0] + '.dll'))
|
||||
|
||||
self.logger.info("vcrun2012: running x86 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x86), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
self.logger.info("vcrun2012: running x64 installer")
|
||||
r = subprocess.run(
|
||||
[self.wine_binary, str(x64), '/q'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode not in (0, 3010):
|
||||
self.logger.error("vcrun2012: x64 installer failed (rc=%d)", r.returncode)
|
||||
self.logger.debug("vcrun2012 x64 stderr: %s", r.stderr.decode(errors='replace'))
|
||||
return False
|
||||
|
||||
if not (syswow64 / 'msvcr110.dll').is_file():
|
||||
self.logger.error("vcrun2012: msvcr110.dll not extracted to syswow64")
|
||||
self.logger.error("vcrun2012: msvcr110.dll missing after install")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ Handles encrypted storage and retrieval of OAuth tokens
|
||||
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict
|
||||
from pathlib import Path
|
||||
|
||||
from jackify.backend.utils.machine_crypto import encrypt as _mc_encrypt, decrypt as _mc_decrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,140 +40,13 @@ class OAuthTokenHandler:
|
||||
# Ensure config directory exists
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate encryption key based on machine-specific data
|
||||
self._encryption_key = self._generate_encryption_key()
|
||||
|
||||
def _generate_encryption_key(self) -> bytes:
|
||||
"""
|
||||
Generate encryption key based on machine-specific data using Fernet
|
||||
|
||||
Uses hostname + username + machine ID as key material, similar to DPAPI approach.
|
||||
This provides proper symmetric encryption while remaining machine-specific.
|
||||
|
||||
Returns:
|
||||
Fernet-compatible 32-byte encryption key
|
||||
"""
|
||||
import socket
|
||||
import getpass
|
||||
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
|
||||
# Try to get machine ID for additional entropy
|
||||
machine_id = None
|
||||
try:
|
||||
# Linux machine-id
|
||||
with open('/etc/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except (OSError, IOError):
|
||||
try:
|
||||
# Alternative locations
|
||||
with open('/var/lib/dbus/machine-id', 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
# Combine multiple sources of machine-specific data
|
||||
if machine_id:
|
||||
key_material = f"{hostname}:{username}:{machine_id}:jackify"
|
||||
else:
|
||||
key_material = f"{hostname}:{username}:jackify"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get machine info for encryption: {e}")
|
||||
key_material = "jackify:default:key"
|
||||
|
||||
# Generate 32-byte key using SHA256 for Fernet
|
||||
# Fernet requires base64-encoded 32-byte key
|
||||
key_bytes = hashlib.sha256(key_material.encode('utf-8')).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
def _encrypt_data(self, data: str) -> str:
|
||||
"""
|
||||
Encrypt data using AES-GCM (authenticated encryption)
|
||||
|
||||
Uses pycryptodome for cross-platform compatibility.
|
||||
AES-GCM provides authenticated encryption similar to Fernet.
|
||||
|
||||
Args:
|
||||
data: Plain text data
|
||||
|
||||
Returns:
|
||||
Encrypted data as base64 string (nonce:ciphertext:tag format)
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
# Derive 32-byte AES key from encryption_key (which is base64-encoded)
|
||||
key = base64.urlsafe_b64decode(self._encryption_key)
|
||||
|
||||
# Generate random nonce (12 bytes for GCM)
|
||||
nonce = get_random_bytes(12)
|
||||
|
||||
# Create AES-GCM cipher
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
|
||||
# Encrypt and get authentication tag
|
||||
data_bytes = data.encode('utf-8')
|
||||
ciphertext, tag = cipher.encrypt_and_digest(data_bytes)
|
||||
|
||||
# Combine nonce:ciphertext:tag and base64 encode
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
logger.error("pycryptodome package not available for token encryption")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt data: {e}")
|
||||
return ""
|
||||
"""Encrypt data using AES-GCM via machine_crypto."""
|
||||
return _mc_encrypt(data)
|
||||
|
||||
def _decrypt_data(self, encrypted_data: str) -> Optional[str]:
|
||||
"""
|
||||
Decrypt data using AES-GCM (authenticated encryption)
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted data string (base64-encoded nonce:ciphertext:tag)
|
||||
|
||||
Returns:
|
||||
Decrypted plain text or None on failure
|
||||
"""
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
# Check if MODE_GCM is available (pycryptodome has it, old pycrypto doesn't)
|
||||
if not hasattr(AES, 'MODE_GCM'):
|
||||
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
|
||||
return None
|
||||
|
||||
# Derive 32-byte AES key from encryption_key
|
||||
key = base64.urlsafe_b64decode(self._encryption_key)
|
||||
|
||||
# Decode base64 and split nonce:ciphertext:tag
|
||||
combined = base64.b64decode(encrypted_data.encode('utf-8'))
|
||||
nonce = combined[:12]
|
||||
tag = combined[-16:]
|
||||
ciphertext = combined[12:-16]
|
||||
|
||||
# Create AES-GCM cipher
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
|
||||
# Decrypt and verify authentication tag
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
|
||||
return plaintext.decode('utf-8')
|
||||
|
||||
except ImportError:
|
||||
logger.error("pycryptodome package not available for token decryption")
|
||||
return None
|
||||
except AttributeError:
|
||||
logger.error("pycryptodome required for token decryption (pycrypto doesn't support MODE_GCM)")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt data: {e}")
|
||||
return None
|
||||
"""Decrypt data. Returns None on any failure."""
|
||||
return _mc_decrypt(encrypted_data)
|
||||
|
||||
def save_token(self, token_data: Dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
# Import colors from the new central location
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_ERROR, COLOR_INFO
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_ERROR, COLOR_INFO
|
||||
|
||||
# Initialize logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,7 @@ class ShortcutLaunchOptionsMixin:
|
||||
def ensure_mounts_in_steam_compat(self, app_name: str, exe_path: str, *paths: str) -> str:
|
||||
"""Add mountpoints of any supplied paths to STEAM_COMPAT_MOUNTS if not already present.
|
||||
|
||||
Reads existing launch options and appends only what is missing — never overwrites
|
||||
Reads existing launch options and appends only what is missing - never overwrites
|
||||
unrelated options. Adds the top-level directory of each path so Proton's container
|
||||
can bind-mount the subtree into the prefix.
|
||||
|
||||
@@ -47,11 +47,11 @@ class ShortcutLaunchOptionsMixin:
|
||||
can stop Steam first, call apply_pending_mounts_update(), then restart Steam.
|
||||
|
||||
Returns:
|
||||
"unchanged" — mounts already correct, no action needed
|
||||
"updated" — Steam was not running; write succeeded
|
||||
"steam_running" — changes needed but deferred; call apply_pending_mounts_update()
|
||||
"unchanged" - mounts already correct, no action needed
|
||||
"updated" - Steam was not running; write succeeded
|
||||
"steam_running" - changes needed but deferred; call apply_pending_mounts_update()
|
||||
after stopping Steam
|
||||
"failed" — shortcut not found or write error
|
||||
"failed" - shortcut not found or write error
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path as _Path
|
||||
@@ -110,7 +110,7 @@ class ShortcutLaunchOptionsMixin:
|
||||
steam_running = False
|
||||
|
||||
if steam_running:
|
||||
# Defer the write — Steam holds shortcuts.vdf in memory and would clobber it.
|
||||
# Defer the write - Steam holds shortcuts.vdf in memory and would clobber it.
|
||||
# Store the pending options so the GUI can stop Steam, apply, then restart.
|
||||
self._pending_mounts_app_name = app_name
|
||||
self._pending_mounts_exe_path = exe_path
|
||||
|
||||
@@ -1,293 +1,21 @@
|
||||
"""Steam restart methods for ShortcutHandler (Mixin)."""
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_steam_exe():
|
||||
"""Resolve steam executable for legacy restart path (same logic as steam_restart_service)."""
|
||||
try:
|
||||
from jackify.backend.services.steam_restart_service import _get_steam_executable
|
||||
return _get_steam_executable(os.environ)
|
||||
except Exception:
|
||||
import shutil
|
||||
exe = shutil.which("steam")
|
||||
if exe:
|
||||
return exe
|
||||
for p in ("/usr/games/steam", "/usr/bin/steam"):
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
return "steam"
|
||||
|
||||
|
||||
class ShortcutSteamRestartMixin:
|
||||
"""Mixin providing Steam restart methods."""
|
||||
|
||||
def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Secure Steam restart with comprehensive error handling to prevent segfaults.
|
||||
Now delegates to the robust steam restart service for cross-distro compatibility.
|
||||
"""
|
||||
"""Delegate to steam_restart_service (canonical restart path)."""
|
||||
try:
|
||||
from ..services.steam_restart_service import robust_steam_restart
|
||||
return robust_steam_restart(progress_callback=status_callback, timeout=60)
|
||||
except ImportError as e:
|
||||
self.logger.error(f"Failed to import steam restart service: {e}")
|
||||
return self._legacy_secure_steam_restart(status_callback)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in robust steam restart: {e}")
|
||||
return self._legacy_secure_steam_restart(status_callback)
|
||||
|
||||
def _legacy_secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||
"""
|
||||
Legacy secure Steam restart implementation (fallback).
|
||||
"""
|
||||
self.logger.info("Attempting secure Steam restart sequence...")
|
||||
|
||||
def safe_subprocess_run(cmd, **kwargs):
|
||||
try:
|
||||
return subprocess.run(cmd, **kwargs)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subprocess error with cmd {cmd}: {e}")
|
||||
return subprocess.CompletedProcess(cmd, 1, "", str(e))
|
||||
|
||||
def safe_subprocess_popen(cmd, **kwargs):
|
||||
try:
|
||||
return subprocess.Popen(cmd, **kwargs)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Popen error with cmd {cmd}: {e}")
|
||||
return None
|
||||
|
||||
if self._is_steam_deck():
|
||||
self.logger.info("Detected Steam Deck. Using systemd to restart Steam.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Restarting Steam via systemd...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
try:
|
||||
result = safe_subprocess_run(['systemctl', '--user', 'restart', 'app-steam@autostart.service'], capture_output=True, text=True, timeout=30)
|
||||
self.logger.info(f"systemctl restart output: {result.stdout.strip()} {result.stderr.strip()}")
|
||||
time.sleep(10)
|
||||
check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if check.returncode == 0:
|
||||
self.logger.info("Steam restarted successfully via systemd.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Steam Started")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error("Steam did not start after systemd restart.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Start Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error restarting Steam via systemd: {e}")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Restart Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Stopping Steam...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info("Attempting clean Steam shutdown via 'steam -shutdown'...")
|
||||
shutdown_timeout = 30
|
||||
result = safe_subprocess_run(['steam', '-shutdown'], timeout=shutdown_timeout, check=False, capture_output=True, text=True)
|
||||
if result.returncode != 1:
|
||||
self.logger.debug("'steam -shutdown' command executed (exit code ignored, verification follows).")
|
||||
else:
|
||||
self.logger.warning(f"'steam -shutdown' had issues: {result.stderr}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error executing 'steam -shutdown': {e}. Will proceed to check processes.")
|
||||
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Waiting for Steam to close...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info("Verifying Steam processes are terminated...")
|
||||
max_attempts = 6
|
||||
steam_closed_successfully = False
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
check_cmd = ['pgrep', '-f', 'steamwebhelper']
|
||||
self.logger.debug(f"Executing check: {' '.join(check_cmd)}")
|
||||
result = safe_subprocess_run(check_cmd, capture_output=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
self.logger.info("No Steam web helper processes found via pgrep.")
|
||||
steam_closed_successfully = True
|
||||
break
|
||||
else:
|
||||
try:
|
||||
steam_pids = result.stdout.decode().strip().split('\n') if result.stdout else []
|
||||
self.logger.debug(f"Steam web helper processes still detected (PIDs: {steam_pids}). Waiting... (Attempt {attempt + 1}/{max_attempts} after shutdown cmd)")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error parsing pgrep output: {e}")
|
||||
time.sleep(5)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error checking Steam processes (attempt {attempt + 1}): {e}")
|
||||
time.sleep(5)
|
||||
|
||||
if not steam_closed_successfully:
|
||||
self.logger.debug("Steam processes still running after 'steam -shutdown'. Attempting fallback with 'pkill steam'...")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Force stopping Steam...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
try:
|
||||
self.logger.info("Attempting force shutdown via 'pkill steam'...")
|
||||
pkill_result = safe_subprocess_run(['pkill', '-f', 'steam'], timeout=15, check=False, capture_output=True, text=True)
|
||||
self.logger.info(f"pkill steam result: {pkill_result.returncode} - {pkill_result.stdout.strip()} {pkill_result.stderr.strip()}")
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
final_check = safe_subprocess_run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10)
|
||||
if final_check.returncode != 0:
|
||||
self.logger.info("Steam processes successfully terminated via pkill fallback.")
|
||||
steam_closed_successfully = True
|
||||
else:
|
||||
self.logger.debug("Steam processes still running after pkill fallback.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Shutdown Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during pkill fallback: {e}")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Shutdown Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
if not steam_closed_successfully:
|
||||
self.logger.error("Failed to terminate Steam processes via all methods.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Shutdown Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
self.logger.error("steam_restart_service unavailable: %s", e)
|
||||
return False
|
||||
|
||||
self.logger.info("Steam confirmed closed.")
|
||||
|
||||
steam_exe = _resolve_steam_exe()
|
||||
start_methods = [
|
||||
{"name": "Popen", "cmd": [steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}},
|
||||
{"name": "setsid", "cmd": ["setsid", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL}},
|
||||
{"name": "nohup", "cmd": ["nohup", steam_exe, "-silent"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True}}
|
||||
]
|
||||
steam_start_initiated = False
|
||||
|
||||
for i, method in enumerate(start_methods):
|
||||
method_name = method["name"]
|
||||
status_msg = f"Starting Steam ({method_name})"
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback(status_msg)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info(f"Attempting to start Steam using method: {method_name}")
|
||||
try:
|
||||
process = safe_subprocess_popen(method["cmd"], **method["kwargs"])
|
||||
if process is not None:
|
||||
self.logger.info(f"Initiated Steam start with {method_name}.")
|
||||
time.sleep(5)
|
||||
check_result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if check_result.returncode == 0:
|
||||
self.logger.info(f"Steam process detected after using {method_name}. Proceeding to wait phase.")
|
||||
steam_start_initiated = True
|
||||
break
|
||||
else:
|
||||
self.logger.warning(f"Steam process not detected after initiating with {method_name}. Trying next method.")
|
||||
else:
|
||||
self.logger.warning(f"Failed to start process with {method_name}. Trying next method.")
|
||||
except FileNotFoundError:
|
||||
self.logger.error(f"Command not found for method {method_name} (e.g., setsid, nohup). Trying next method.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting Steam with {method_name}: {e}. Trying next method.")
|
||||
|
||||
if not steam_start_initiated:
|
||||
self.logger.error("All methods to initiate Steam start failed.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Start Failed")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error("robust_steam_restart failed: %s", e)
|
||||
return False
|
||||
|
||||
status_msg = "Waiting for Steam to fully start"
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback(status_msg)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
|
||||
self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...")
|
||||
max_startup_wait = 120
|
||||
elapsed_wait = 0
|
||||
initial_wait_done = False
|
||||
|
||||
while elapsed_wait < max_startup_wait:
|
||||
try:
|
||||
result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
if not initial_wait_done:
|
||||
self.logger.info("Steam process detected. Waiting additional time for full initialization...")
|
||||
initial_wait_done = True
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
if initial_wait_done and elapsed_wait >= 15:
|
||||
final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
||||
if final_check.returncode == 0:
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Steam Started")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
self.logger.info("Steam confirmed running after wait.")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning("Steam process disappeared during final initialization wait.")
|
||||
break
|
||||
else:
|
||||
self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)")
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error during Steam startup wait: {e}")
|
||||
time.sleep(5)
|
||||
elapsed_wait += 5
|
||||
|
||||
self.logger.error("Steam failed to start/initialize within the allowed time.")
|
||||
if status_callback:
|
||||
try:
|
||||
status_callback("Start Timed Out")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Status callback error: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .ui_colors import COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_INFO, COLOR_RESET
|
||||
|
||||
def show_status(message: str):
|
||||
"""Show a single-line status message, overwriting the current line."""
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UI Color Constants
|
||||
"""
|
||||
|
||||
COLOR_PROMPT = '\033[93m' # Yellow
|
||||
COLOR_SELECTION = '\033[96m' # Cyan
|
||||
COLOR_RESET = '\033[0m'
|
||||
COLOR_INFO = '\033[94m' # Blue
|
||||
COLOR_ERROR = '\033[91m' # Red
|
||||
COLOR_SUCCESS = '\033[92m' # Green
|
||||
COLOR_WARNING = '\033[93m' # Yellow (reusing prompt color)
|
||||
COLOR_DISABLED = '\033[90m' # Grey
|
||||
|
||||
COLOR_ACTION = '\033[97m' # Bright White for action/descriptions
|
||||
COLOR_INPUT = '\033[97m' # Bright White for input prompts
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
UIHandler module for managing user interface operations.
|
||||
This module handles menus, prompts, and user interaction.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Tuple, Callable, Any
|
||||
from pathlib import Path
|
||||
|
||||
class UIHandler:
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def show_menu(self, title: str, options: List[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Display a menu and get user selection."""
|
||||
try:
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
|
||||
for i, option in enumerate(options, 1):
|
||||
print(f"{i}. {option['label']}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("\nEnter your choice (or 'q' to quit): ")
|
||||
if choice.lower() == 'q':
|
||||
return None
|
||||
|
||||
choice = int(choice)
|
||||
if 1 <= choice <= len(options):
|
||||
return options[choice - 1]['value']
|
||||
else:
|
||||
print("Invalid choice. Please try again.")
|
||||
except ValueError:
|
||||
print("Please enter a number.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show menu: {e}")
|
||||
return None
|
||||
|
||||
def show_progress(self, message: str, total: int = 100) -> None:
|
||||
"""Display a progress indicator."""
|
||||
try:
|
||||
print(f"\n{message}")
|
||||
print("[" + " " * 50 + "] 0%", end="\r")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show progress: {e}")
|
||||
|
||||
def update_progress(self, current: int, message: Optional[str] = None) -> None:
|
||||
"""Update the progress indicator."""
|
||||
try:
|
||||
if message:
|
||||
print(f"\n{message}")
|
||||
progress = int(current / 2)
|
||||
print("[" + "=" * progress + " " * (50 - progress) + f"] {current}%", end="\r")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update progress: {e}")
|
||||
|
||||
def show_error(self, message: str, details: Optional[str] = None) -> None:
|
||||
"""Display an error message."""
|
||||
try:
|
||||
print(f"\nError: {message}")
|
||||
if details:
|
||||
print(f"Details: {details}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show error: {e}")
|
||||
|
||||
def show_success(self, message: str, details: Optional[str] = None) -> None:
|
||||
"""Display a success message."""
|
||||
try:
|
||||
print(f"\n✓ Success: {message}")
|
||||
if details:
|
||||
print(f"Details: {details}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show success: {e}")
|
||||
|
||||
def show_warning(self, message: str, details: Optional[str] = None) -> None:
|
||||
"""Display a warning message."""
|
||||
try:
|
||||
print(f"\nWarning: {message}")
|
||||
if details:
|
||||
print(f"Details: {details}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show warning: {e}")
|
||||
|
||||
def get_input(self, prompt: str, default: Optional[str] = None) -> str:
|
||||
"""Get user input with optional default value."""
|
||||
try:
|
||||
if default:
|
||||
user_input = input(f"{prompt} [{default}]: ")
|
||||
return user_input if user_input else default
|
||||
return input(f"{prompt}: ")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get input: {e}")
|
||||
return ""
|
||||
|
||||
def get_confirmation(self, message: str, default: bool = True) -> bool:
|
||||
"""Get user confirmation for an action."""
|
||||
try:
|
||||
default_str = "Y/n" if default else "y/N"
|
||||
while True:
|
||||
response = input(f"{message} [{default_str}]: ").lower()
|
||||
if not response:
|
||||
return default
|
||||
if response in ['y', 'yes']:
|
||||
return True
|
||||
if response in ['n', 'no']:
|
||||
return False
|
||||
print("Please enter 'y' or 'n'.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get confirmation: {e}")
|
||||
return default
|
||||
|
||||
def show_list(self, title: str, items: List[str], selectable: bool = True) -> Optional[str]:
|
||||
"""Display a list of items, optionally selectable."""
|
||||
try:
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
print(f"{i}. {item}")
|
||||
|
||||
if selectable:
|
||||
while True:
|
||||
try:
|
||||
choice = input("\nEnter your choice (or 'q' to quit): ")
|
||||
if choice.lower() == 'q':
|
||||
return None
|
||||
|
||||
choice = int(choice)
|
||||
if 1 <= choice <= len(items):
|
||||
return items[choice - 1]
|
||||
else:
|
||||
print("Invalid choice. Please try again.")
|
||||
except ValueError:
|
||||
print("Please enter a number.")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show list: {e}")
|
||||
return None
|
||||
|
||||
def show_table(self, title: str, headers: List[str], rows: List[List[str]]) -> None:
|
||||
"""Display data in a table format."""
|
||||
try:
|
||||
print(f"\n{title}")
|
||||
print("=" * len(title))
|
||||
|
||||
# Calculate column widths
|
||||
widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
widths[i] = max(widths[i], len(str(cell)))
|
||||
|
||||
# Print headers
|
||||
header_str = " | ".join(f"{h:<{w}}" for h, w in zip(headers, widths))
|
||||
print(header_str)
|
||||
print("-" * len(header_str))
|
||||
|
||||
# Print rows
|
||||
for row in rows:
|
||||
print(" | ".join(f"{str(cell):<{w}}" for cell, w in zip(row, widths)))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show table: {e}")
|
||||
|
||||
def show_help(self, topic: str) -> None:
|
||||
"""Display help information for a topic."""
|
||||
try:
|
||||
print(f"\nHelp: {topic}")
|
||||
print("=" * (len(topic) + 6))
|
||||
print("Help content would be displayed here.")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to show help: {e}")
|
||||
|
||||
def clear_screen(self) -> None:
|
||||
"""Clear the terminal screen."""
|
||||
try:
|
||||
os.system('clear' if os.name == 'posix' else 'cls')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to clear screen: {e}")
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET, COLOR_WARNING
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import time
|
||||
import re
|
||||
|
||||
# Import UI Colors first - these should always be available
|
||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_WARNING
|
||||
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET, COLOR_INFO, COLOR_ERROR, COLOR_WARNING
|
||||
|
||||
# Import necessary components from other modules
|
||||
try:
|
||||
@@ -444,56 +444,3 @@ class InstallWabbajackHandler(
|
||||
print(f"\nDetailed log available at: {log_path}")
|
||||
print("───────────────────────────────────────────────────────────────────")
|
||||
|
||||
|
||||
# Example usage (for testing - keep this section for easy module testing)
|
||||
if __name__ == '__main__':
|
||||
# Configure logging for standalone testing
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
print("Testing Wabbajack Install Handler...")
|
||||
# Simulate running on or off deck
|
||||
test_on_deck = False
|
||||
print(f"Simulating run with steamdeck={test_on_deck}")
|
||||
|
||||
# Need dummy handlers for direct testing
|
||||
class DummyProton:
|
||||
which_protontricks = 'native'
|
||||
def check_and_setup_protontricks(self): return True
|
||||
def set_protontricks_permissions(self, path, steamdeck): return True
|
||||
def enable_dotfiles(self, appid): return True
|
||||
def _cleanup_wine_processes(self): pass
|
||||
def run_protontricks(self, *args, **kwargs): return subprocess.CompletedProcess(args=[], returncode=0)
|
||||
def list_non_steam_shortcuts(self): return {"Wabbajack": "12345"}
|
||||
|
||||
class DummyShortcut:
|
||||
def create_shortcut(self, *args, **kwargs): return True, "12345"
|
||||
def secure_steam_restart(self): return True
|
||||
|
||||
class DummyPath:
|
||||
def find_compat_data(self, appid): return Path(f"/tmp/test_compat/{appid}")
|
||||
def find_steam_library(self): return Path("/tmp/test_steam/steamapps/common")
|
||||
|
||||
class DummyVDF:
|
||||
@staticmethod
|
||||
def load(path):
|
||||
if "config.vdf" in str(path):
|
||||
# Simulate structure needed for proton check
|
||||
return {'UserLocalConfigStore': {'Software': {'Valve': {'Steam': {'apps': {'12345': {'CompatTool': 'proton_experimental'}}}}}}}
|
||||
return {}
|
||||
|
||||
handler = InstallWabbajackHandler(
|
||||
steamdeck=test_on_deck,
|
||||
protontricks_handler=DummyProton(),
|
||||
shortcut_handler=DummyShortcut(),
|
||||
path_handler=DummyPath(),
|
||||
vdf_handler=DummyVDF(),
|
||||
modlist_handler=ModlistHandler(),
|
||||
filesystem_handler=FileSystemHandler()
|
||||
)
|
||||
# Pre-create dummy compatdata dir for verification step
|
||||
if not Path("/tmp/test_compat/12345/pfx").exists():
|
||||
os.makedirs("/tmp/test_compat/12345/pfx", exist_ok=True)
|
||||
|
||||
handler.run_install_workflow()
|
||||
|
||||
print("\nTesting completed.")
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import os
|
||||
|
||||
from .status_utils import clear_status, show_status
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_PROMPT, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .status_utils import clear_status, show_status
|
||||
from .ui_colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
from jackify.shared.colors import COLOR_ERROR, COLOR_INFO, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -222,7 +222,15 @@ class WineUtilsProtonMixin:
|
||||
Path("/usr/share/steam/compatibilitytools.d"),
|
||||
Path("/usr/lib/steam/compatibilitytools.d"),
|
||||
]
|
||||
return [path for path in compat_paths if path.exists()]
|
||||
seen_real = set()
|
||||
result = []
|
||||
for path in compat_paths:
|
||||
if path.exists():
|
||||
real = path.resolve()
|
||||
if real not in seen_real:
|
||||
seen_real.add(real)
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]:
|
||||
@@ -441,10 +449,19 @@ class WineUtilsProtonMixin:
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions())
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
|
||||
all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions())
|
||||
_TYPE_RANK = {'GE-Proton': 2, 'ThirdParty-Proton': 1, 'Valve-Proton': 0}
|
||||
def _type_rank(v: dict) -> int:
|
||||
t = v.get('type', '')
|
||||
if t == 'GE-Proton':
|
||||
return 2 if v.get('major_version', 0) >= 10 else -1
|
||||
if t == 'ThirdParty-Proton':
|
||||
return 1
|
||||
if t == 'Valve-Proton':
|
||||
return 0
|
||||
return -1
|
||||
|
||||
all_versions.sort(
|
||||
key=lambda x: (
|
||||
_TYPE_RANK.get(x.get('type', ''), 0),
|
||||
_type_rank(x),
|
||||
x.get('major_version', 0),
|
||||
x.get('minor_version', 0),
|
||||
x.get('priority', 0),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Canonical game type strings, display names, and normalisation helpers."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Maps Jackify canonical game_type -> human-readable display name
|
||||
GAME_DISPLAY_NAMES: dict[str, str] = {
|
||||
'skyrim': 'Skyrim Special Edition',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'fallout3': 'Fallout 3',
|
||||
'oblivion': 'Oblivion',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'starfield': 'Starfield',
|
||||
'enderal': 'Enderal',
|
||||
'skyrimvr': 'Skyrim VR',
|
||||
'fallout4vr': 'Fallout 4 VR',
|
||||
'bg3': "Baldur's Gate 3",
|
||||
'cp2077': 'Cyberpunk 2077',
|
||||
}
|
||||
|
||||
# Maps lowercased human-readable / alternate name -> canonical game_type
|
||||
GAME_NAME_TO_TYPE: dict[str, str] = {
|
||||
'skyrim special edition': 'skyrim',
|
||||
'skyrim': 'skyrim',
|
||||
'skyrimspecialedition': 'skyrim',
|
||||
'fallout 4': 'fallout4',
|
||||
'fallout4': 'fallout4',
|
||||
'fallout new vegas': 'falloutnv',
|
||||
'falloutnv': 'falloutnv',
|
||||
'fallout 3': 'fallout3',
|
||||
'fallout3': 'fallout3',
|
||||
'oblivion': 'oblivion',
|
||||
'oblivion remastered': 'oblivion_remastered',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'oblivionremastered': 'oblivion_remastered',
|
||||
'starfield': 'starfield',
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal',
|
||||
'enderalspecialedition': 'enderal',
|
||||
'skyrim vr': 'skyrimvr',
|
||||
'skyrimvr': 'skyrimvr',
|
||||
'fallout 4 vr': 'fallout4vr',
|
||||
'fallout4vr': 'fallout4vr',
|
||||
"baldur's gate 3": 'bg3',
|
||||
'baldursgate3': 'bg3',
|
||||
'bg3': 'bg3',
|
||||
'cyberpunk 2077': 'cp2077',
|
||||
'cyberpunk2077': 'cp2077',
|
||||
'cp2077': 'cp2077',
|
||||
}
|
||||
|
||||
|
||||
def normalize_game_name(raw: str) -> Optional[str]:
|
||||
"""Return canonical game_type for a raw name, or None if unrecognised."""
|
||||
if not raw:
|
||||
return None
|
||||
return GAME_NAME_TO_TYPE.get(raw.lower())
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from jackify.backend.models.game_types import normalize_game_name
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModlistContext:
|
||||
@@ -39,7 +41,7 @@ class ModlistContext:
|
||||
return {
|
||||
'modlist_name': self.name,
|
||||
'install_dir': str(self.install_dir),
|
||||
'download_dir': str(self.download_dir),
|
||||
'download_dir': str(self.download_dir) if self.download_dir else None,
|
||||
'game_type': self.game_type,
|
||||
'nexus_api_key': self.nexus_api_key,
|
||||
'modlist_value': self.modlist_value,
|
||||
@@ -49,14 +51,16 @@ class ModlistContext:
|
||||
'skip_confirmation': self.skip_confirmation,
|
||||
'engine_installed': self.engine_installed,
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ModlistContext':
|
||||
"""Create from dictionary for legacy compatibility."""
|
||||
raw_dl = data.get('download_dir')
|
||||
download_dir = Path(raw_dl) if raw_dl and raw_dl != 'None' else None
|
||||
return cls(
|
||||
name=data.get('modlist_name', ''),
|
||||
install_dir=Path(data.get('install_dir', '')),
|
||||
download_dir=Path(data.get('download_dir', '')),
|
||||
download_dir=download_dir,
|
||||
game_type=data.get('game_type', ''),
|
||||
nexus_api_key=data.get('nexus_api_key', ''),
|
||||
modlist_value=data.get('modlist_value'),
|
||||
@@ -102,5 +106,42 @@ class ModlistInfo:
|
||||
result['status_down'] = self.status_down
|
||||
if hasattr(self, 'status_nsfw'):
|
||||
result['status_nsfw'] = self.status_nsfw
|
||||
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_modlist_context(
|
||||
name: str,
|
||||
install_dir,
|
||||
game_type: str,
|
||||
nexus_api_key: str = '',
|
||||
download_dir=None,
|
||||
modlist_value: Optional[str] = None,
|
||||
modlist_source: Optional[str] = None,
|
||||
resolution: Optional[str] = None,
|
||||
mo2_exe_path=None,
|
||||
skip_confirmation: bool = False,
|
||||
engine_installed: bool = False,
|
||||
enb_detected: bool = False,
|
||||
) -> ModlistContext:
|
||||
"""Canonical factory for ModlistContext.
|
||||
|
||||
Normalises game_type via game_types.normalize_game_name so both frontends
|
||||
produce consistent canonical strings. Falls back to the raw value when the
|
||||
name is not in the known map (unknown/custom game types).
|
||||
"""
|
||||
canonical = normalize_game_name(game_type)
|
||||
return ModlistContext(
|
||||
name=name,
|
||||
install_dir=install_dir,
|
||||
game_type=canonical if canonical else game_type,
|
||||
nexus_api_key=nexus_api_key,
|
||||
download_dir=download_dir,
|
||||
modlist_value=modlist_value,
|
||||
modlist_source=modlist_source,
|
||||
resolution=resolution,
|
||||
mo2_exe_path=mo2_exe_path,
|
||||
skip_confirmation=skip_confirmation,
|
||||
engine_installed=engine_installed,
|
||||
enb_detected=enb_detected,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Machine-tied AES-GCM encrypt/decrypt helpers.
|
||||
|
||||
Key derivation: sha256(hostname:username:machine-id:jackify) -> urlsafe_b64encode.
|
||||
decrypt() returns None on ANY failure - never returns garbage or plaintext leakage.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_machine_key() -> bytes:
|
||||
"""Return the machine-tied 32-byte AES key (base64-encoded for AES-GCM use)."""
|
||||
import socket
|
||||
import getpass
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
username = getpass.getuser()
|
||||
machine_id = None
|
||||
for id_path in ('/etc/machine-id', '/var/lib/dbus/machine-id'):
|
||||
try:
|
||||
with open(id_path, 'r') as f:
|
||||
machine_id = f.read().strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
key_material = (
|
||||
f"{hostname}:{username}:{machine_id}:jackify"
|
||||
if machine_id
|
||||
else f"{hostname}:{username}:jackify"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get machine info for encryption key: %s", e)
|
||||
key_material = "jackify:default:key"
|
||||
return base64.urlsafe_b64encode(hashlib.sha256(key_material.encode('utf-8')).digest())
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Encrypt plaintext with AES-GCM. Returns "" if pycryptodomex is unavailable."""
|
||||
try:
|
||||
from Cryptodome.Cipher import AES
|
||||
from Cryptodome.Random import get_random_bytes
|
||||
key = base64.urlsafe_b64decode(get_machine_key())
|
||||
nonce = get_random_bytes(12)
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
|
||||
combined = nonce + ciphertext + tag
|
||||
return base64.b64encode(combined).decode('utf-8')
|
||||
except ImportError:
|
||||
logger.warning("pycryptodomex not available - encryption disabled")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error("Encryption failed: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> Optional[str]:
|
||||
"""Decrypt ciphertext produced by encrypt(). Returns None on ANY failure."""
|
||||
try:
|
||||
from Cryptodome.Cipher import AES
|
||||
key = base64.urlsafe_b64decode(get_machine_key())
|
||||
combined = base64.b64decode(ciphertext.encode('utf-8'))
|
||||
if len(combined) < 28:
|
||||
return None
|
||||
nonce = combined[:12]
|
||||
tag = combined[-16:]
|
||||
data = combined[12:-16]
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
plaintext = cipher.decrypt_and_verify(data, tag)
|
||||
return plaintext.decode('utf-8')
|
||||
except ImportError:
|
||||
logger.warning("pycryptodomex not available - cannot decrypt")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -38,7 +38,7 @@ class AdditionalMenuHandler:
|
||||
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2")
|
||||
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
|
||||
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
|
||||
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton — not needed for standard modlist installs{COLOR_RESET}")
|
||||
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton - not needed for standard modlist installs{COLOR_RESET}")
|
||||
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle")
|
||||
print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}")
|
||||
print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization")
|
||||
|
||||
@@ -339,8 +339,9 @@ class ManualDownloadDialog(QDialog):
|
||||
colour = _STATUS_COLOURS.get(item.status, '#808080')
|
||||
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
|
||||
status_cell.setForeground(QColor(colour))
|
||||
if item.error_message:
|
||||
status_cell.setToolTip(item.error_message)
|
||||
tooltip_parts = [p for p in (item.download_reason, item.error_message) if p]
|
||||
if tooltip_parts:
|
||||
status_cell.setToolTip("\n".join(tooltip_parts))
|
||||
self._table.setItem(row, _COL_STATUS, status_cell)
|
||||
|
||||
def _update_row(self, row: int, item: DownloadItem) -> None:
|
||||
@@ -349,7 +350,8 @@ class ManualDownloadDialog(QDialog):
|
||||
if status_cell:
|
||||
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
|
||||
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
|
||||
status_cell.setToolTip(item.error_message or "")
|
||||
tooltip_parts = [p for p in (item.download_reason, item.error_message) if p]
|
||||
status_cell.setToolTip("\n".join(tooltip_parts))
|
||||
|
||||
def _rebuild_row_map(self) -> None:
|
||||
self._row_map.clear()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Guided dialog for installing a Nexus-only tool via manual browser download."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QLineEdit, QFileDialog, QFrame,
|
||||
)
|
||||
|
||||
from jackify.frontends.gui.services.message_service import open_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NexusManualInstallDialog(QDialog):
|
||||
"""
|
||||
Guides the user through manually downloading a Nexus-only tool and handing
|
||||
the archive to Jackify for extraction and installation.
|
||||
"""
|
||||
|
||||
def __init__(self, tool_id: str, display_name: str, nexus_url: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._tool_id = tool_id
|
||||
self._nexus_url = nexus_url
|
||||
self._archive_path: Optional[Path] = None
|
||||
|
||||
self.setWindowTitle(f"Install {display_name}")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(480)
|
||||
self.setStyleSheet("QDialog { background: #181818; color: #fff; }")
|
||||
self._build_ui(display_name)
|
||||
self.adjustSize()
|
||||
|
||||
def _build_ui(self, display_name: str) -> None:
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setSpacing(0)
|
||||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
card = QFrame(self)
|
||||
card.setObjectName("dialogCard")
|
||||
card.setFrameShape(QFrame.StyledPanel)
|
||||
card.setFrameShadow(QFrame.Raised)
|
||||
card.setStyleSheet(
|
||||
"QFrame#dialogCard { "
|
||||
" background: #2d2d2d; "
|
||||
" border-radius: 12px; "
|
||||
" border: 1px solid #555; "
|
||||
"}"
|
||||
)
|
||||
card_layout = QVBoxLayout(card)
|
||||
card_layout.setSpacing(16)
|
||||
card_layout.setContentsMargins(28, 28, 28, 28)
|
||||
|
||||
title_label = QLabel(f"Manual download required: {display_name}")
|
||||
title_label.setStyleSheet("color: #3fd0ea; font-size: 14px; font-weight: 600;")
|
||||
title_label.setWordWrap(True)
|
||||
card_layout.addWidget(title_label)
|
||||
|
||||
body_label = QLabel(
|
||||
f"{display_name} is only available on Nexus Mods. As you do not have Nexus "
|
||||
"Premium, please perform the following steps manually:\n\n"
|
||||
f"1. Click 'Open Nexus Page' below and click Manual Download on the Nexus page "
|
||||
f"to download {display_name}\n"
|
||||
"2. Once the download is complete, click 'Browse...' below and select the "
|
||||
"downloaded archive\n"
|
||||
"3. Click Install to complete the installation."
|
||||
)
|
||||
body_label.setWordWrap(True)
|
||||
card_layout.addWidget(body_label)
|
||||
|
||||
nexus_btn = QPushButton("Open Nexus Page")
|
||||
nexus_btn.clicked.connect(self._open_nexus)
|
||||
card_layout.addWidget(nexus_btn)
|
||||
|
||||
file_row = QHBoxLayout()
|
||||
self._file_edit = QLineEdit()
|
||||
self._file_edit.setPlaceholderText("No file selected...")
|
||||
self._file_edit.setReadOnly(True)
|
||||
self._file_edit.setStyleSheet(
|
||||
"QLineEdit { "
|
||||
" background: #1a1a1a; "
|
||||
" color: #fff; "
|
||||
" border: 1px solid #555; "
|
||||
" border-radius: 4px; "
|
||||
" padding: 8px; "
|
||||
"}"
|
||||
)
|
||||
file_row.addWidget(self._file_edit)
|
||||
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn.setMinimumWidth(90)
|
||||
browse_btn.clicked.connect(self._browse)
|
||||
file_row.addWidget(browse_btn)
|
||||
card_layout.addLayout(file_row)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setMinimumWidth(100)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
|
||||
self._install_btn = QPushButton("Install")
|
||||
self._install_btn.setDefault(True)
|
||||
self._install_btn.setMinimumWidth(100)
|
||||
self._install_btn.setEnabled(False)
|
||||
self._install_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(self._install_btn)
|
||||
|
||||
card_layout.addLayout(btn_row)
|
||||
main_layout.addWidget(card)
|
||||
|
||||
def _open_nexus(self) -> None:
|
||||
if self._nexus_url:
|
||||
open_url(self._nexus_url)
|
||||
|
||||
def _browse(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select downloaded archive",
|
||||
str(Path.home() / "Downloads"),
|
||||
"Archives (*.zip *.tar.gz *.tar.xz *.7z);;All files (*)",
|
||||
)
|
||||
if path:
|
||||
self._archive_path = Path(path)
|
||||
self._file_edit.setText(path)
|
||||
self._install_btn.setEnabled(True)
|
||||
|
||||
@property
|
||||
def selected_archive(self) -> Optional[Path]:
|
||||
return self._archive_path
|
||||
|
||||
@property
|
||||
def tool_id(self) -> str:
|
||||
return self._tool_id
|
||||
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from jackify.backend.services.nxm_url import NxmUrl
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
import jackify.backend.services.nxm_session as nxm_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -117,7 +118,7 @@ class _DownloadThread(QThread):
|
||||
self.progress.emit(downloaded, total)
|
||||
|
||||
|
||||
class NxmDownloadDialog(QDialog):
|
||||
class NxmDownloadDialog(ThreadLifecycleMixin, QDialog):
|
||||
"""Modlist picker and download runner for incoming nxm:// links.
|
||||
|
||||
When auto_start_modlist is provided the picker is hidden and the download
|
||||
|
||||
@@ -72,9 +72,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
main_layout.addLayout(btn_layout)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Exception in SettingsDialog.__init__: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"Exception in SettingsDialog.__init__: {e}", exc_info=True)
|
||||
|
||||
def _toggle_api_key_visibility(self, checked):
|
||||
eye_icon = QIcon.fromTheme("view-visible")
|
||||
@@ -402,7 +400,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
screen.refresh_paths()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not refresh GUI paths: {e}")
|
||||
logger.warning(f"Could not refresh GUI paths: {e}")
|
||||
|
||||
def _bold_label(self, text):
|
||||
label = QLabel(text)
|
||||
|
||||
@@ -16,6 +16,8 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QPixmap, QIcon, QFont
|
||||
|
||||
from jackify.frontends.gui.services.message_service import open_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -38,6 +40,8 @@ class SuccessDialog(QDialog):
|
||||
time_taken: str,
|
||||
game_name: str = None,
|
||||
verification_results=None,
|
||||
disabled_problem_mods=None,
|
||||
readme_url: str = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
@@ -46,6 +50,8 @@ class SuccessDialog(QDialog):
|
||||
self.time_taken = time_taken
|
||||
self.game_name = game_name
|
||||
self.verification_results = verification_results
|
||||
self.disabled_problem_mods = disabled_problem_mods or []
|
||||
self.readme_url = readme_url
|
||||
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
|
||||
self.setWindowModality(Qt.NonModal)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
@@ -180,6 +186,25 @@ class SuccessDialog(QDialog):
|
||||
if self.verification_results is not None:
|
||||
self._add_verification_section(card_layout)
|
||||
|
||||
# Problem mods that were auto-disabled
|
||||
if self.disabled_problem_mods:
|
||||
self._add_problem_mods_section(card_layout)
|
||||
|
||||
# Readme link (install workflow only)
|
||||
if self.readme_url:
|
||||
readme_label = QLabel(
|
||||
f'<a href="{self.readme_url}" style="color:#3fd0ea; text-decoration:none;">'
|
||||
"Open modlist readme"
|
||||
"</a>"
|
||||
)
|
||||
readme_label.setAlignment(Qt.AlignCenter)
|
||||
readme_label.setStyleSheet(
|
||||
"QLabel { color: #3fd0ea; font-size: 11px; margin-top: 4px; padding: 4px; background-color: transparent; }"
|
||||
)
|
||||
readme_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
readme_label.linkActivated.connect(open_url)
|
||||
card_layout.addWidget(readme_label)
|
||||
|
||||
# Subtle Ko-Fi support link
|
||||
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#3fd0ea; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
|
||||
kofi_label.setAlignment(Qt.AlignCenter)
|
||||
@@ -193,7 +218,7 @@ class SuccessDialog(QDialog):
|
||||
"}"
|
||||
)
|
||||
kofi_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
kofi_label.setOpenExternalLinks(True)
|
||||
kofi_label.linkActivated.connect(open_url)
|
||||
card_layout.addWidget(kofi_label)
|
||||
|
||||
layout.addStretch()
|
||||
@@ -390,6 +415,29 @@ class SuccessDialog(QDialog):
|
||||
except Exception as exc:
|
||||
logger.error("Could not open verification dialog: %s", exc)
|
||||
|
||||
def _add_problem_mods_section(self, card_layout):
|
||||
"""Add an auto-disabled problem mods section to the card layout."""
|
||||
from PySide6.QtWidgets import QFrame
|
||||
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.HLine)
|
||||
sep.setStyleSheet("color: #444;")
|
||||
card_layout.addWidget(sep)
|
||||
|
||||
header = QLabel("Compatibility Notice")
|
||||
header.setStyleSheet("font-size: 12px; font-weight: bold; color: #c8a050; margin-top: 4px;")
|
||||
card_layout.addWidget(header)
|
||||
|
||||
msg_text = (
|
||||
"Due to known compatibility issues with Proton, the following mods were "
|
||||
"automatically disabled:\n\n"
|
||||
+ "\n".join(f" - {name}" for name in self.disabled_problem_mods)
|
||||
)
|
||||
msg_label = QLabel(msg_text)
|
||||
msg_label.setWordWrap(True)
|
||||
msg_label.setStyleSheet("font-size: 11px; color: #bbb; margin-bottom: 4px;")
|
||||
card_layout.addWidget(msg_label)
|
||||
|
||||
def _update_countdown(self):
|
||||
if self._countdown > 0:
|
||||
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
|
||||
|
||||
@@ -310,7 +310,12 @@ def main(initial_nxm_url: str = ""):
|
||||
|
||||
# Global cleanup function for signal handling
|
||||
def emergency_cleanup():
|
||||
logger.debug("Cleanup: terminating jackify-engine processes")
|
||||
logger.debug("Cleanup: draining QThreads and terminating jackify-engine processes")
|
||||
try:
|
||||
from jackify.frontends.gui.mixins.thread_registry import drain_all_threads
|
||||
drain_all_threads(timeout_ms=5000)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
||||
@@ -389,6 +394,7 @@ def main(initial_nxm_url: str = ""):
|
||||
# Start background update check after window is shown
|
||||
window._check_for_updates_on_startup()
|
||||
window._check_tool_updates_on_startup()
|
||||
window._prefetch_manifests_on_startup()
|
||||
|
||||
if initial_nxm_url:
|
||||
from PySide6.QtCore import QTimer
|
||||
@@ -397,7 +403,12 @@ def main(initial_nxm_url: str = ""):
|
||||
# Ensure cleanup on exit
|
||||
import atexit
|
||||
atexit.register(emergency_cleanup)
|
||||
|
||||
try:
|
||||
from jackify.frontends.gui.mixins.thread_registry import drain_all_threads
|
||||
app.aboutToQuit.connect(lambda: drain_all_threads(timeout_ms=8000))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return app.exec()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -64,14 +64,14 @@ class MainWindowBackendMixin:
|
||||
if status['target_achieved']:
|
||||
logger.debug(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
|
||||
else:
|
||||
print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
|
||||
logger.info(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
|
||||
else:
|
||||
status = resource_manager.get_limit_status()
|
||||
print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
|
||||
logger.warning(f"Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
if config_handler.get('debug_mode', False):
|
||||
instructions = resource_manager.get_manual_increase_instructions()
|
||||
print(f"Manual increase instructions available for {instructions['distribution']}")
|
||||
logger.debug(f"Manual increase instructions available for {instructions['distribution']}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Error applying resource limits: {e}")
|
||||
logger.warning(f"Error applying resource limits: {e}")
|
||||
|
||||
@@ -6,6 +6,7 @@ Settings, About, open URL, cleanup_processes, closeEvent.
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import warnings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,15 +26,19 @@ class MainWindowDialogsMixin:
|
||||
return None
|
||||
|
||||
# Disconnect all signals before stopping to prevent callbacks to a dying widget.
|
||||
try:
|
||||
thread.finished.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"):
|
||||
# disconnect() with no receivers connected raises via Python's warnings module
|
||||
# (not as a catchable exception), so it must be suppressed rather than try/excepted.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
getattr(thread, _sig).disconnect()
|
||||
thread.finished.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"):
|
||||
try:
|
||||
getattr(thread, _sig).disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
@@ -78,9 +83,7 @@ class MainWindowDialogsMixin:
|
||||
dlg.finished.connect(on_dialog_finished)
|
||||
dlg.exec()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Exception in open_settings_dialog: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"Exception in open_settings_dialog: {e}", exc_info=True)
|
||||
self._settings_dialog = None
|
||||
|
||||
def open_about_dialog(self):
|
||||
@@ -104,9 +107,7 @@ class MainWindowDialogsMixin:
|
||||
dlg.finished.connect(on_dialog_finished)
|
||||
dlg.exec()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Exception in open_about_dialog: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"Exception in open_about_dialog: {e}", exc_info=True)
|
||||
self._about_dialog = None
|
||||
|
||||
def _open_url(self, url: str):
|
||||
@@ -186,15 +187,12 @@ class MainWindowDialogsMixin:
|
||||
screen.cleanup_processes()
|
||||
elif hasattr(screen, 'cleanup'):
|
||||
screen.cleanup()
|
||||
elif hasattr(screen, 'worker'):
|
||||
worker = getattr(screen, 'worker', None)
|
||||
setattr(screen, 'worker', self._stop_qthread(worker, f"{screen.__class__.__name__}.worker"))
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup: {e}")
|
||||
logger.error(f"Error during cleanup: {e}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._save_geometry_on_quit()
|
||||
|
||||
@@ -53,17 +53,17 @@ class MainWindowStartupMixin:
|
||||
return
|
||||
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
|
||||
if not is_installed:
|
||||
print(f"Protontricks not found: {details}")
|
||||
logger.warning(f"Protontricks not found: {details}")
|
||||
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
|
||||
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
|
||||
result = dialog.exec()
|
||||
if result == QDialog.Rejected:
|
||||
print("User chose to exit due to missing protontricks")
|
||||
logger.info("User chose to exit due to missing protontricks")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.debug(f"Protontricks detected: {details}")
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
|
||||
def _check_tool_updates_on_startup(self):
|
||||
class _ToolUpdateCheckThread(QThread):
|
||||
@@ -106,6 +106,40 @@ class MainWindowStartupMixin:
|
||||
self._tool_update_check_thread.updates_found.connect(on_result)
|
||||
self._tool_update_check_thread.start()
|
||||
|
||||
def _prefetch_manifests_on_startup(self):
|
||||
class _ManifestPrefetchThread(QThread):
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.tool_registry import (
|
||||
fetch_remote_manifest as fetch_tools,
|
||||
apply_remote_manifest as apply_tools,
|
||||
)
|
||||
tools = fetch_tools()
|
||||
if tools:
|
||||
apply_tools(tools)
|
||||
logger.info("Tools manifest refreshed at startup (%d tools)", len(tools))
|
||||
else:
|
||||
logger.info("Tools manifest prefetch returned no data (bundled manifest in use)")
|
||||
except Exception as e:
|
||||
logger.info("Tools manifest prefetch failed: %s", e)
|
||||
|
||||
try:
|
||||
from jackify.backend.services.problem_mods_service import (
|
||||
fetch_remote_manifest as fetch_problems,
|
||||
apply_remote_manifest as apply_problems,
|
||||
)
|
||||
problems = fetch_problems()
|
||||
if problems:
|
||||
apply_problems(problems)
|
||||
logger.info("Problem mods manifest refreshed at startup")
|
||||
else:
|
||||
logger.info("Problem mods manifest prefetch returned no data (bundled manifest in use)")
|
||||
except Exception as e:
|
||||
logger.info("Problem mods manifest prefetch failed: %s", e)
|
||||
|
||||
self._manifest_prefetch_thread = _ManifestPrefetchThread()
|
||||
self._manifest_prefetch_thread.start()
|
||||
|
||||
def _check_for_updates_on_startup(self):
|
||||
try:
|
||||
logger.debug("Checking for updates on startup...")
|
||||
|
||||
@@ -72,30 +72,43 @@ class MainWindowUIMixin:
|
||||
bottom_bar_style += " border: 2px solid lime;"
|
||||
bottom_bar.setStyleSheet(bottom_bar_style)
|
||||
|
||||
# Three-zone layout (left / center / right) with equal stretch factors on the
|
||||
# outer zones, so the center zone (Ko-fi) stays visually centered on the bar
|
||||
# regardless of how wide the version label or Settings/About block are.
|
||||
left_zone = QWidget()
|
||||
left_zone_layout = QHBoxLayout(left_zone)
|
||||
left_zone_layout.setContentsMargins(0, 0, 0, 0)
|
||||
version_label = QLabel(f"Jackify v{__version__}")
|
||||
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
|
||||
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
|
||||
bottom_bar_layout.addStretch(1)
|
||||
left_zone_layout.addWidget(version_label, alignment=Qt.AlignLeft)
|
||||
left_zone_layout.addStretch(1)
|
||||
bottom_bar_layout.addWidget(left_zone, 1)
|
||||
|
||||
kofi_link = QLabel('<a href="#" style="color:#3fd0ea; text-decoration:none;">Support on Ko-fi</a>')
|
||||
kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;")
|
||||
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
kofi_link.setOpenExternalLinks(False)
|
||||
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
|
||||
kofi_link.setToolTip("Support Jackify development")
|
||||
bottom_bar_layout.addWidget(kofi_link)
|
||||
bottom_bar_layout.addStretch(1)
|
||||
bottom_bar_layout.addWidget(kofi_link, 0, alignment=Qt.AlignCenter)
|
||||
|
||||
right_zone = QWidget()
|
||||
right_zone_layout = QHBoxLayout(right_zone)
|
||||
right_zone_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_zone_layout.addStretch(1)
|
||||
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
|
||||
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
||||
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
settings_btn.setOpenExternalLinks(False)
|
||||
settings_btn.linkActivated.connect(self.open_settings_dialog)
|
||||
bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
|
||||
right_zone_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
|
||||
about_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">About</a>')
|
||||
about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
||||
about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
about_btn.setOpenExternalLinks(False)
|
||||
about_btn.linkActivated.connect(self.open_about_dialog)
|
||||
bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight)
|
||||
right_zone_layout.addWidget(about_btn, alignment=Qt.AlignRight)
|
||||
bottom_bar_layout.addWidget(right_zone, 1)
|
||||
|
||||
central_widget = QWidget()
|
||||
main_layout = QVBoxLayout()
|
||||
|
||||
@@ -22,13 +22,9 @@ import logging
|
||||
import warnings
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from jackify.frontends.gui.mixins.thread_registry import register_managed_thread
|
||||
|
||||
# Module-level registry keeps references to parked threads alive independent
|
||||
# of screen widget lifetime. Screens are destroyed on navigation; without this,
|
||||
# _parked_threads on self evaporates and the GC destroys still-running threads,
|
||||
# triggering Qt's "QThread: Destroyed while thread is still running" abort.
|
||||
_PARKED_THREAD_REGISTRY: set = set()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThreadLifecycleMixin:
|
||||
@@ -38,8 +34,8 @@ class ThreadLifecycleMixin:
|
||||
"""Disconnect a thread from this screen and let it finish on its own.
|
||||
|
||||
Disconnects the named signals so no callbacks fire on this (potentially
|
||||
dying) widget. Keeps a reference in _parked_threads so the thread is
|
||||
not garbage-collected before it finishes.
|
||||
dying) widget. Keeps a reference alive via the global registry until the
|
||||
thread finishes.
|
||||
|
||||
Returns None so callers can do: self.thread = self._park_thread(self.thread, [...])
|
||||
"""
|
||||
@@ -54,13 +50,9 @@ class ThreadLifecycleMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Register in the module-level set so the reference survives screen destruction.
|
||||
# Remove from registry when the thread finishes so it can be GC'd cleanly.
|
||||
_PARKED_THREAD_REGISTRY.add(thread)
|
||||
try:
|
||||
thread.finished.connect(lambda t=thread: _PARKED_THREAD_REGISTRY.discard(t))
|
||||
except Exception:
|
||||
pass
|
||||
# Hand the thread to the global registry so it survives screen destruction
|
||||
# and is drained cleanly on app exit.
|
||||
register_managed_thread(thread)
|
||||
return None
|
||||
|
||||
def hideEvent(self, event):
|
||||
@@ -71,6 +63,14 @@ class ThreadLifecycleMixin:
|
||||
pass
|
||||
self._park_all_threads()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Park all running threads when the widget is closed."""
|
||||
self._park_all_threads()
|
||||
try:
|
||||
super().closeEvent(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
|
||||
"""Kill wine/winetricks subprocesses on user-initiated cancel.
|
||||
|
||||
@@ -105,19 +105,11 @@ class ThreadLifecycleMixin:
|
||||
"""Park every running QThread attribute found on this instance.
|
||||
|
||||
Inspects instance variables, disconnects common signal names from any
|
||||
running QThread, and parks them. Used in cleanup_processes() / closeEvent().
|
||||
running QThread, and registers them globally. Used in cleanup_processes()
|
||||
/ closeEvent() / hideEvent().
|
||||
"""
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
_common_signals = (
|
||||
"finished_signal",
|
||||
"progress_update",
|
||||
"workflow_complete",
|
||||
"configuration_complete",
|
||||
"error_occurred",
|
||||
"status_update",
|
||||
"finished",
|
||||
)
|
||||
from jackify.frontends.gui.mixins.thread_registry import _COMMON_SIGNAL_NAMES
|
||||
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
try:
|
||||
@@ -125,7 +117,7 @@ class ThreadLifecycleMixin:
|
||||
continue
|
||||
if not value.isRunning():
|
||||
continue
|
||||
signal_names = [s for s in _common_signals if hasattr(value, s)]
|
||||
signal_names = [s for s in _COMMON_SIGNAL_NAMES if hasattr(value, s)]
|
||||
setattr(self, attr_name, self._park_thread(value, signal_names))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Application-wide QThread registry.
|
||||
|
||||
All managed threads are registered here. On app exit `drain_all_threads` disconnects
|
||||
signals, requests cancellation, and waits for each thread so no QThread outlives its
|
||||
Python wrapper or fires signals into destroyed widgets.
|
||||
|
||||
Usage in threads that are not owned by a ThreadLifecycleMixin widget (e.g. orphaned
|
||||
workers) call `register_managed_thread` directly. ThreadLifecycleMixin calls it
|
||||
automatically inside `_park_thread`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Central set of live managed threads. Python objects are kept alive here until their
|
||||
# QThread.finished signal fires, preventing GC from destroying running threads.
|
||||
_MANAGED_THREADS: set = set()
|
||||
|
||||
# Common signal names to disconnect during drain / park.
|
||||
_COMMON_SIGNAL_NAMES = (
|
||||
"finished",
|
||||
"finished_signal",
|
||||
"progress_update",
|
||||
"workflow_complete",
|
||||
"configuration_complete",
|
||||
"error_occurred",
|
||||
"status_update",
|
||||
"output_received",
|
||||
"progress_received",
|
||||
"installation_finished",
|
||||
"cache_ready",
|
||||
"update_available",
|
||||
"no_update",
|
||||
"check_failed",
|
||||
"completed",
|
||||
"done",
|
||||
"name_ready",
|
||||
"progress",
|
||||
)
|
||||
|
||||
|
||||
def register_managed_thread(thread) -> None:
|
||||
"""Add a QThread to the global registry and auto-remove it when it finishes.
|
||||
|
||||
Safe to call multiple times on the same thread.
|
||||
"""
|
||||
if thread is None:
|
||||
return
|
||||
_MANAGED_THREADS.add(thread)
|
||||
try:
|
||||
thread.finished.connect(lambda t=thread: _MANAGED_THREADS.discard(t))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def drain_all_threads(timeout_ms: int = 8000) -> None:
|
||||
"""Disconnect signals, request cancellation, and wait for every registered thread.
|
||||
|
||||
Called on `QApplication.aboutToQuit` and from the emergency cleanup handler.
|
||||
Does not call terminate() - threads are given `timeout_ms` to finish gracefully.
|
||||
If a thread does not exit in time, a warning is logged and we move on.
|
||||
"""
|
||||
snapshot = list(_MANAGED_THREADS)
|
||||
if not snapshot:
|
||||
return
|
||||
|
||||
logger.debug("Draining %d managed thread(s)", len(snapshot))
|
||||
for thread in snapshot:
|
||||
try:
|
||||
if not thread.isRunning():
|
||||
_MANAGED_THREADS.discard(thread)
|
||||
continue
|
||||
except RuntimeError:
|
||||
_MANAGED_THREADS.discard(thread)
|
||||
continue
|
||||
|
||||
# Disconnect all known signals so no callbacks fire into destroyed widgets.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
for name in _COMMON_SIGNAL_NAMES:
|
||||
try:
|
||||
getattr(thread, name).disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Signal cancellation where supported.
|
||||
if hasattr(thread, "cancel"):
|
||||
try:
|
||||
thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not thread.wait(timeout_ms):
|
||||
logger.warning(
|
||||
"Thread %s did not stop within %dms during drain",
|
||||
thread.__class__.__name__,
|
||||
timeout_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
thread.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_MANAGED_THREADS.discard(thread)
|
||||
|
||||
logger.debug("Thread drain complete")
|
||||
@@ -17,11 +17,12 @@ from PySide6.QtGui import QFont
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
from ..utils import set_responsive_minimum
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdditionalTasksScreen(QWidget):
|
||||
class AdditionalTasksScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Additional Tasks screen for automation and standalone tools."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
|
||||
import warnings
|
||||
from PySide6.QtWidgets import *
|
||||
from PySide6.QtCore import *
|
||||
from PySide6.QtGui import *
|
||||
@@ -58,7 +59,6 @@ class ConfigureExistingModlistScreen(
|
||||
super().hideEvent(event)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
try:
|
||||
self._vnv_controller.cleanup()
|
||||
@@ -67,7 +67,6 @@ class ConfigureExistingModlistScreen(
|
||||
pass
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
self._park_all_threads()
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
"""Handle Cancel button - clean up processes and go back"""
|
||||
@@ -91,7 +90,7 @@ class ConfigureExistingModlistScreen(
|
||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to set initial collapsed state: {e}")
|
||||
logger.warning(f"Failed to set initial collapsed state: {e}")
|
||||
|
||||
# Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list()
|
||||
# which fires via _debug_screen_change on every navigation to this screen.
|
||||
@@ -183,11 +182,13 @@ class ConfigureExistingModlistScreen(
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||
try:
|
||||
getattr(self.config_thread, sig_name).disconnect()
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||
try:
|
||||
getattr(self.config_thread, sig_name).disconnect()
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import QThread, Signal, QObject
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class ConfigureExistingModlistShortcutsMixin:
|
||||
@@ -75,14 +76,16 @@ class ConfigureExistingModlistShortcutsMixin:
|
||||
if hasattr(self, '_park_thread'):
|
||||
self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
|
||||
else:
|
||||
try:
|
||||
self._shortcut_loader.finished_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._shortcut_loader.error_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self._shortcut_loader.finished_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._shortcut_loader.error_signal.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
if not hasattr(self, '_old_loaders'):
|
||||
self._old_loaders = []
|
||||
self._old_loaders.append(self._shortcut_loader)
|
||||
@@ -101,14 +104,13 @@ class ConfigureExistingModlistShortcutsMixin:
|
||||
def _on_shortcuts_loaded(self, shortcuts):
|
||||
"""Update UI when shortcuts are loaded"""
|
||||
self.mo2_shortcuts = shortcuts
|
||||
|
||||
# Update the dropdown
|
||||
|
||||
if hasattr(self, 'shortcut_combo'):
|
||||
self.shortcut_combo.clear()
|
||||
self.shortcut_combo.setEnabled(True)
|
||||
self.shortcut_combo.addItem("Please Select...")
|
||||
self.shortcut_map.clear()
|
||||
|
||||
|
||||
for shortcut in self.mo2_shortcuts:
|
||||
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||
self.shortcut_combo.addItem(display)
|
||||
|
||||
@@ -15,13 +15,33 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
|
||||
|
||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||
"""Detect special game type using the canonical ModlistHandler detection."""
|
||||
"""Detect game type for the verifier from ModOrganizer.ini."""
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||
return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim'
|
||||
special = ModlistHandler().detect_special_game_type(install_dir)
|
||||
if special:
|
||||
return special
|
||||
except Exception as e:
|
||||
logger.warning("Game type detection failed, defaulting to skyrim: %s", e)
|
||||
return 'skyrim'
|
||||
logger.warning("Special game type detection failed: %s", e)
|
||||
|
||||
# detect_special_game_type only covers non-default games; read gameName= directly
|
||||
try:
|
||||
from pathlib import Path
|
||||
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||
if mo2_ini.exists():
|
||||
for raw_line in mo2_ini.read_text(errors='ignore').splitlines():
|
||||
line = raw_line.strip().lower()
|
||||
if line.startswith("gamename="):
|
||||
val = line[len("gamename="):]
|
||||
if "fallout 4" in val:
|
||||
return "fallout4"
|
||||
if "skyrim" in val:
|
||||
return "skyrim"
|
||||
except Exception as e:
|
||||
logger.warning("ModOrganizer.ini gameName read failed: %s", e)
|
||||
|
||||
logger.warning("Could not determine game type for %s, verifier will run generic checks only", install_dir)
|
||||
return 'unknown'
|
||||
|
||||
def validate_and_start_configure(self):
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
ConfigureNewModlistScreen for Jackify GUI
|
||||
"""
|
||||
import logging
|
||||
import warnings
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
|
||||
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
|
||||
from PySide6.QtGui import QPixmap, QTextCursor
|
||||
@@ -126,12 +127,14 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
|
||||
@@ -93,7 +93,6 @@ class ConfigureNewModlistDialogsMixin:
|
||||
super().hideEvent(event)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
try:
|
||||
self._vnv_controller.cleanup()
|
||||
@@ -103,7 +102,6 @@ class ConfigureNewModlistDialogsMixin:
|
||||
self._stop_focus_reclaim()
|
||||
if hasattr(self, 'file_progress_list'):
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
self._park_all_threads()
|
||||
|
||||
def show_shortcut_conflict_dialog(self, conflicts):
|
||||
"""Show dialog to reuse an existing shortcut or choose a new name."""
|
||||
|
||||
@@ -601,9 +601,9 @@ class ConfigureNewModlistUISetupMixin:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
return True # Continue anyway
|
||||
|
||||
@@ -4,6 +4,7 @@ from PySide6.QtCore import QThread, Signal
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import warnings
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
from jackify.shared.errors import configuration_failed
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
@@ -262,12 +263,14 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
"""Safely release the automated prefix thread after it has finished."""
|
||||
if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None:
|
||||
return
|
||||
try:
|
||||
self.automated_prefix_thread.progress_update.disconnect()
|
||||
self.automated_prefix_thread.workflow_complete.disconnect()
|
||||
self.automated_prefix_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.automated_prefix_thread.progress_update.disconnect()
|
||||
self.automated_prefix_thread.workflow_complete.disconnect()
|
||||
self.automated_prefix_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
if self.automated_prefix_thread.isRunning():
|
||||
self.automated_prefix_thread.quit()
|
||||
self.automated_prefix_thread.wait(5000)
|
||||
|
||||
@@ -438,5 +438,3 @@ class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget):
|
||||
except Exception as e:
|
||||
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
|
||||
@@ -30,6 +30,7 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,7 +75,7 @@ class MO2SetupWorker(QThread):
|
||||
self.setup_complete.emit(False, None, str(e))
|
||||
|
||||
|
||||
class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
class InstallMO2Screen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
"""Standalone MO2 setup screen"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
@@ -498,23 +499,12 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
self.go_back()
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Stop active MO2 worker and CPU tracking before screen/app shutdown."""
|
||||
self._stop_focus_reclaim()
|
||||
try:
|
||||
self.file_progress_list.stop_cpu_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.worker is not None:
|
||||
try:
|
||||
if self.worker.isRunning():
|
||||
self.worker.requestInterruption()
|
||||
self.worker.wait(10000)
|
||||
self.worker.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self.worker = None
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
self.file_progress_list.clear()
|
||||
self.console.clear()
|
||||
|
||||
@@ -198,8 +198,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height)
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
print(f"DEBUG: Error calculating upper section height: {e}")
|
||||
pass
|
||||
logger.debug(f"Error calculating upper section height: {e}")
|
||||
|
||||
# Calculate heights immediately after forcing layout update
|
||||
# Prevents visible layout shift
|
||||
@@ -356,7 +355,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
@@ -424,7 +423,6 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
super().hideEvent(event)
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
self._stop_clf3_decompress_pulse()
|
||||
|
||||
fpl = getattr(self, 'file_progress_list', None)
|
||||
@@ -440,74 +438,23 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
||||
|
||||
self._stop_focus_reclaim()
|
||||
|
||||
# Disconnect all thread signals before any stopping - prevents callbacks to
|
||||
# a dying widget if threads emit between now and actual termination.
|
||||
self._park_all_threads()
|
||||
|
||||
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
|
||||
thread = getattr(self, attr_name, None)
|
||||
if thread is None:
|
||||
return
|
||||
# install_thread needs cancel() to kill the child subprocess, not just
|
||||
# signal disconnection. The extended wait gives the engine time to flush.
|
||||
thread = getattr(self, 'install_thread', None)
|
||||
if thread is not None:
|
||||
try:
|
||||
running = thread.isRunning()
|
||||
except RuntimeError:
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
if not running:
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
logger.debug(f"Stopping {attr_name}")
|
||||
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
running = False
|
||||
if running:
|
||||
try:
|
||||
getattr(thread, cancel_method)()
|
||||
thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if thread.wait(cooperative_ms):
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, waiting for forced shutdown window")
|
||||
try:
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
getattr(thread, cancel_method)()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if not thread.wait(force_ms):
|
||||
logger.error(f"ERROR: {attr_name} still running after forced shutdown window")
|
||||
except Exception:
|
||||
pass
|
||||
setattr(self, attr_name, None)
|
||||
|
||||
# Always stop installer thread first; it needs cancel() not terminate().
|
||||
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
|
||||
|
||||
# Stop any remaining QThread instances on this object, regardless of attribute name.
|
||||
from PySide6.QtCore import QThread
|
||||
for attr_name, value in list(vars(self).items()):
|
||||
if attr_name == 'install_thread':
|
||||
continue
|
||||
try:
|
||||
if isinstance(value, QThread):
|
||||
_stop_thread(attr_name)
|
||||
except Exception:
|
||||
pass
|
||||
if not thread.wait(15000):
|
||||
logger.warning("install_thread did not stop in 15s")
|
||||
thread.wait(10000)
|
||||
self.install_thread = None
|
||||
|
||||
def cancel_installation(self):
|
||||
"""Cancel the currently running installation"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
|
||||
import warnings
|
||||
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
@@ -178,6 +179,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected,
|
||||
'readme_url': getattr(self, '_readme_url', None),
|
||||
},
|
||||
)
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
@@ -233,12 +235,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
@@ -525,12 +529,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
# Clean up old thread if exists and wait for it to finish
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass # Ignore errors if already disconnected
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass # Ignore errors if already disconnected
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||
@@ -618,7 +624,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
|
||||
except Exception as e:
|
||||
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
|
||||
self.progress_update.emit(f"DEBUG: {error_details}")
|
||||
self.progress_update.emit(f"ERROR: {error_details}")
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self)
|
||||
|
||||
@@ -307,7 +307,7 @@ class InstallerThread(QThread):
|
||||
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
|
||||
|
||||
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
|
||||
of continued silence — so extended waits remain visible to the user.
|
||||
of continued silence - so extended waits remain visible to the user.
|
||||
Samples /proc/<pid>/io to show write throughput, giving the user concrete
|
||||
evidence that extraction is progressing even when CLF3 emits no output.
|
||||
Resets when real output arrives so a new silence period can trigger it again.
|
||||
@@ -360,7 +360,7 @@ class InstallerThread(QThread):
|
||||
JSON progress events (keyed on "type") and plain human-readable text both arrive
|
||||
on stdout. Manual download events (keyed on "event") also appear on stdout.
|
||||
|
||||
Extraction dispatch counters ("Extracting: N/M") are dropped — named per-archive
|
||||
Extraction dispatch counters ("Extracting: N/M") are dropped - named per-archive
|
||||
completion lines already cover this information.
|
||||
Directive counters ("Processing: N/M") are buffered; only the final value is
|
||||
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
|
||||
@@ -391,7 +391,7 @@ class InstallerThread(QThread):
|
||||
self.progress_updated.emit(state)
|
||||
msg = state.message
|
||||
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
|
||||
# Relabel as "Queuing" — the N/M counter tracks archive dispatch
|
||||
# Relabel as "Queuing" - the N/M counter tracks archive dispatch
|
||||
# to worker threads, not completion of decompression.
|
||||
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
|
||||
from jackify.frontends.gui.utils import browse_directory, browse_file
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -160,7 +161,11 @@ class ModlistSelectionMixin:
|
||||
|
||||
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
|
||||
metadata = self._gallery_dlg.selected_metadata
|
||||
self.modlist_btn.setText(metadata.title)
|
||||
metrics = QFontMetrics(self.modlist_btn.font())
|
||||
available_width = self.modlist_btn.width() - 24 # padding allowance
|
||||
elided_title = metrics.elidedText(metadata.title, Qt.ElideRight, available_width)
|
||||
self.modlist_btn.setText(elided_title)
|
||||
self.modlist_btn.setToolTip(metadata.title)
|
||||
self.selected_modlist_info = {
|
||||
'machine_url': metadata.namespacedName,
|
||||
'title': metadata.title,
|
||||
|
||||
@@ -162,6 +162,7 @@ class InstallModlistUISetupMixin:
|
||||
# --- Modlist Selection ---
|
||||
self.modlist_btn = QPushButton("Select Modlist")
|
||||
self.modlist_btn.setMinimumWidth(300)
|
||||
self.modlist_btn.setMaximumWidth(300)
|
||||
self.modlist_btn.clicked.connect(self.open_modlist_dialog)
|
||||
self.modlist_btn.setEnabled(False)
|
||||
online_layout.addWidget(QLabel("Game Type:"))
|
||||
|
||||
@@ -6,6 +6,12 @@ import shutil
|
||||
import time
|
||||
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from jackify.backend.services.update_detection import (
|
||||
evaluate_update_candidate as _svc_evaluate,
|
||||
find_existing_shortcut_appid as _svc_find_appid,
|
||||
normalize_version_token,
|
||||
normalize_modlist_name,
|
||||
)
|
||||
from .install_modlist_output_mixin import InstallModlistOutputMixin
|
||||
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
|
||||
|
||||
@@ -15,27 +21,16 @@ logger = logging.getLogger(__name__)
|
||||
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
|
||||
"""Mixin providing installation workflow methods for InstallModlistScreen."""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version_token(value: str | None) -> str | None:
|
||||
"""Return a normalized version token for lightweight equality checks."""
|
||||
if value is None:
|
||||
return None
|
||||
token = str(value).strip()
|
||||
if not token:
|
||||
return None
|
||||
token = token.lstrip("vV")
|
||||
return token.lower()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_modlist_name(value: str | None) -> str:
|
||||
return " ".join((value or "").strip().lower().split())
|
||||
# normalize_version_token and normalize_modlist_name are imported from update_detection service
|
||||
_normalize_version_token = staticmethod(normalize_version_token)
|
||||
_normalize_modlist_name = staticmethod(normalize_modlist_name)
|
||||
|
||||
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
|
||||
"""Return selected modlist version from gallery metadata when available."""
|
||||
if install_mode != "online":
|
||||
return None
|
||||
info = getattr(self, "selected_modlist_info", None) or {}
|
||||
return self._normalize_version_token(info.get("version"))
|
||||
return normalize_version_token(info.get("version"))
|
||||
|
||||
def _evaluate_update_candidate(
|
||||
self,
|
||||
@@ -44,52 +39,8 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
|
||||
install_mode: str,
|
||||
existing_appid: str | None,
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Decide whether update-mode prompt should be shown.
|
||||
|
||||
Policy:
|
||||
- Require existing shortcut AppID and jackify_meta.json.
|
||||
- Require modlist identity match (requested name == installed meta name).
|
||||
- Version relation is informational:
|
||||
- `different` when both requested/installed versions are available and differ.
|
||||
- `same` when both are available and equal.
|
||||
- `unknown` when either side is missing.
|
||||
"""
|
||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||
|
||||
result = {
|
||||
"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 self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
|
||||
result["reason"] = "modlist_name_mismatch"
|
||||
return False, result
|
||||
|
||||
requested_version = self._get_requested_modlist_version(install_mode)
|
||||
installed_version = self._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
|
||||
return _svc_evaluate(modlist_name, install_dir, existing_appid, requested_version)
|
||||
|
||||
def _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
|
||||
"""Return ModOrganizer.ini path for standard/special layouts."""
|
||||
@@ -276,38 +227,7 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
|
||||
|
||||
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||
"""Return existing Steam shortcut AppID for this install dir/name when present."""
|
||||
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"), # Somnium layout
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
# Fallback: match by name + start dir from shortcuts.vdf even if exe moved
|
||||
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
|
||||
return _svc_find_appid(modlist_name, install_dir)
|
||||
|
||||
def _prompt_update_or_new_install(
|
||||
self,
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
|
||||
from .install_modlist_installer_thread import InstallerThread
|
||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||
from jackify.backend.models.game_types import GAME_DISPLAY_NAMES, GAME_NAME_TO_TYPE
|
||||
from jackify.shared.errors import install_dir_create_failed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -307,57 +308,20 @@ class InstallWorkflowExecutionMixin:
|
||||
if result:
|
||||
if isinstance(result, tuple):
|
||||
game_type, raw_game_type = result
|
||||
# Get display name for the game
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
if game_type == 'unknown' and raw_game_type:
|
||||
game_name = raw_game_type
|
||||
else:
|
||||
game_name = display_names.get(game_type, game_type)
|
||||
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
|
||||
else:
|
||||
game_type = result
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
game_name = display_names.get(game_type, game_type)
|
||||
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
|
||||
else:
|
||||
# For online modlists, try to get game type from selected modlist
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
readme_url = self.selected_modlist_info.get('readme_url')
|
||||
game_name = self.selected_modlist_info.get('game', '')
|
||||
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
'skyrim special edition': 'skyrim',
|
||||
'skyrim': 'skyrim',
|
||||
'fallout 4': 'fallout4',
|
||||
'fallout new vegas': 'falloutnv',
|
||||
'oblivion': 'oblivion',
|
||||
'starfield': 'starfield',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'oblivion remastered': 'oblivion_remastered',
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal',
|
||||
'skyrim vr': 'skyrimvr',
|
||||
'fallout 4 vr': 'fallout4vr',
|
||||
'cyberpunk 2077': 'cp2077',
|
||||
"baldur's gate 3": 'bg3',
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
game_type = GAME_NAME_TO_TYPE.get(game_name.lower())
|
||||
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
@@ -504,7 +468,7 @@ class InstallWorkflowExecutionMixin:
|
||||
readme_url = readme_url.replace("raw.githubusercontent.com", "github.com")
|
||||
readme_url = readme_url.replace("/main/", "/blob/main/")
|
||||
readme_url = readme_url.replace("/master/", "/blob/master/")
|
||||
logger.info(f"Opening modlist readme: {readme_url}")
|
||||
logger.info("Opening modlist readme: %s", readme_url)
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
|
||||
@@ -512,6 +476,7 @@ class InstallWorkflowExecutionMixin:
|
||||
"Modlist readme opened in your browser. "
|
||||
"Check it for any manual post-install steps before launching the game."
|
||||
)
|
||||
self._readme_url = readme_url or None
|
||||
|
||||
logger.debug(f"Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}")
|
||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||
@@ -620,8 +585,9 @@ class InstallWorkflowExecutionMixin:
|
||||
concurrent_limit = max(1, min(5, concurrent_limit))
|
||||
|
||||
self._safe_append_text(
|
||||
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
|
||||
f"Opening download dialog - check your taskbar if it does not appear in front.\n"
|
||||
f"\n[Manual Download Required] {count} file(s) need manual download "
|
||||
f"(rate limit, access error, or non-premium).\n"
|
||||
f"Opening download dialog - it will appear in front momentarily.\n"
|
||||
)
|
||||
logger.info(
|
||||
f"[MDL-1006] Manual download protocol initialized | count={count} "
|
||||
@@ -663,3 +629,5 @@ class InstallWorkflowExecutionMixin:
|
||||
|
||||
if not self._manual_dl_dialog.isVisible():
|
||||
self._manual_dl_dialog.show()
|
||||
self._manual_dl_dialog.raise_()
|
||||
self._manual_dl_dialog.activateWindow()
|
||||
|
||||
@@ -233,8 +233,8 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
logger.error(f"Error checking protontricks: {e}")
|
||||
MessageService.warning(self, "Protontricks Check Failed",
|
||||
f"Unable to verify protontricks installation: {e}\n\n"
|
||||
"Continuing anyway, but some features may not work correctly.")
|
||||
return True # Continue anyway
|
||||
@@ -305,14 +305,10 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
dlg.exec()
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
# Disconnect all signals first - prevents callbacks to a dying widget.
|
||||
self._park_all_threads()
|
||||
|
||||
# install_thread gets a cooperative cancel signal on top of the park.
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
try:
|
||||
self.install_thread.cancel()
|
||||
if self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEd
|
||||
from PySide6.QtCore import Qt, QTimer, QSize
|
||||
from PySide6.QtGui import QFont
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||
from jackify.frontends.gui.services.message_service import open_url
|
||||
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
|
||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||
|
||||
@@ -110,7 +111,8 @@ class TTWUISetupMixin:
|
||||
)
|
||||
instruction_text.setWordWrap(True)
|
||||
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
||||
instruction_text.setOpenExternalLinks(True)
|
||||
instruction_text.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
instruction_text.linkActivated.connect(open_url)
|
||||
user_config_vbox.addWidget(instruction_text)
|
||||
|
||||
# --- Compact Form Grid for inputs (align with other screens) ---
|
||||
|
||||
@@ -91,6 +91,52 @@ class InstallVerifierMixin:
|
||||
except Exception as e:
|
||||
logger.warning("JContainers fix check failed (non-fatal): %s", e)
|
||||
|
||||
def _apply_problem_mods_disable(
|
||||
self, install_dir: str, game_type: str, success_params: dict, appid: str = ""
|
||||
) -> None:
|
||||
"""Disable known-problematic mods and apply prefix fixes across all profiles."""
|
||||
try:
|
||||
from jackify.backend.services.problem_mods_service import (
|
||||
disable_problem_mods,
|
||||
create_prefix_dirs,
|
||||
get_enabled_mods,
|
||||
)
|
||||
|
||||
install_path = Path(install_dir)
|
||||
all_disabled: list = []
|
||||
all_enabled_mods: set = set()
|
||||
for modlist_txt in install_path.glob("profiles/*/modlist.txt"):
|
||||
disabled = disable_problem_mods(modlist_txt, game_type)
|
||||
for name in disabled:
|
||||
if name not in all_disabled:
|
||||
all_disabled.append(name)
|
||||
all_enabled_mods |= get_enabled_mods(modlist_txt)
|
||||
|
||||
if all_disabled:
|
||||
logger.info(
|
||||
"Disabled %d problem mod(s) for %s (%s): %s",
|
||||
len(all_disabled),
|
||||
success_params.get("modlist_name", ""),
|
||||
game_type,
|
||||
", ".join(all_disabled),
|
||||
)
|
||||
success_params["disabled_problem_mods"] = all_disabled
|
||||
|
||||
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
|
||||
pfx = _resolve_pfx_for_appid(resolved_appid) if resolved_appid else None
|
||||
if pfx and all_enabled_mods:
|
||||
created = create_prefix_dirs(pfx, game_type, all_enabled_mods)
|
||||
if created:
|
||||
logger.info(
|
||||
"Created %d prefix dir(s) for %s (%s): %s",
|
||||
len(created),
|
||||
success_params.get("modlist_name", ""),
|
||||
game_type,
|
||||
", ".join(created),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Problem mods fix check failed (non-fatal): %s", e)
|
||||
|
||||
def _run_verifier_then_show_success(
|
||||
self,
|
||||
install_dir: str,
|
||||
@@ -105,6 +151,7 @@ class InstallVerifierMixin:
|
||||
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
|
||||
"""
|
||||
self._maybe_apply_jcontainers_fix(install_dir, game_type)
|
||||
self._apply_problem_mods_disable(install_dir, game_type, success_params, appid)
|
||||
if hasattr(self, "progress_indicator"):
|
||||
self.progress_indicator.set_status("Verifying installation...", 100)
|
||||
if hasattr(self, "file_progress_list"):
|
||||
@@ -170,6 +217,8 @@ class InstallVerifierMixin:
|
||||
time_taken=params["time_taken"],
|
||||
game_name=params.get("game_name"),
|
||||
verification_results=verification_results,
|
||||
disabled_problem_mods=params.get("disabled_problem_mods"),
|
||||
readme_url=params.get("readme_url"),
|
||||
parent=self,
|
||||
)
|
||||
dlg.show()
|
||||
|
||||
@@ -4,6 +4,7 @@ Enhanced Modlist Gallery Screen for Jackify GUI.
|
||||
Provides visual browsing, filtering, and selection of modlists using
|
||||
rich metadata from jackify-engine.
|
||||
"""
|
||||
import warnings
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout,
|
||||
@@ -255,23 +256,27 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
|
||||
# Kill any in-progress engine subprocess so the loader thread exits
|
||||
# naturally and releases the engine call lock.
|
||||
if hasattr(self, 'gallery_service'):
|
||||
self.gallery_service.cancel()
|
||||
|
||||
for attr in ('_loader_thread', '_validation_thread'):
|
||||
thread = getattr(self, attr, None)
|
||||
if thread is None:
|
||||
continue
|
||||
# Disconnect all signals before terminating - prevents callbacks into
|
||||
# a partially-destroyed dialog
|
||||
try:
|
||||
thread.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
thread.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
if thread.isRunning():
|
||||
# terminate() is required here: these threads run plain Python code
|
||||
# with no Qt event loop, so quit() is a no-op and wait() alone
|
||||
# would time out, leaving the thread running when the C++ QThread
|
||||
# object is destroyed (which aborts the process).
|
||||
thread.terminate()
|
||||
thread.wait(3000)
|
||||
# Give the thread time to exit naturally after cancel().
|
||||
# Only fall back to terminate() if it is still stuck after that.
|
||||
if not thread.wait(2000):
|
||||
thread.terminate()
|
||||
thread.wait(1000)
|
||||
|
||||
# Abort any pending image network requests
|
||||
if hasattr(self, 'image_manager'):
|
||||
|
||||
@@ -28,6 +28,7 @@ from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||
from ..utils import set_responsive_minimum
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
# Constants
|
||||
DEBUG_BORDERS = False
|
||||
@@ -35,7 +36,7 @@ DEBUG_BORDERS = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModlistTasksScreen(QWidget):
|
||||
class ModlistTasksScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""
|
||||
Migrated Modlist Tasks screen that uses backend services directly.
|
||||
|
||||
@@ -233,5 +234,4 @@ class ModlistTasksScreen(QWidget):
|
||||
pass
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources when the screen is closed"""
|
||||
pass
|
||||
self._park_all_threads()
|
||||
@@ -8,6 +8,7 @@ the cards are rebuilt and version checks restart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
@@ -74,6 +75,22 @@ class _ToolActionThread(QThread):
|
||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||
|
||||
|
||||
class _ArchiveInstallThread(QThread):
|
||||
finished_signal = Signal(str, bool, str) # tool_id, success, message
|
||||
|
||||
def __init__(self, tool_id: str, archive_path: Path):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._archive_path = archive_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ok, msg = ToolRegistry().install_from_archive(self._tool_id, self._archive_path)
|
||||
except Exception as e:
|
||||
ok, msg = False, str(e)
|
||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||
|
||||
|
||||
class _ManifestFetchThread(QThread):
|
||||
manifest_ready = Signal(list) # List[ToolDefinition]
|
||||
|
||||
@@ -258,7 +275,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
|
||||
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
|
||||
current_ids = set(self._cards.keys())
|
||||
new_ids = {d.tool_id for d in definitions}
|
||||
new_ids = {d.tool_id for d in definitions if not d.hidden}
|
||||
apply_remote_manifest(definitions)
|
||||
if current_ids != new_ids:
|
||||
if self._version_thread and self._version_thread.isRunning():
|
||||
@@ -336,6 +353,13 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
def _on_action_finished(self, tool_id: str, success: bool, message: str):
|
||||
self._action_thread = None
|
||||
card = self._cards.get(tool_id)
|
||||
|
||||
if not success and message.startswith("NEXUS_MANUAL_REQUIRED:"):
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
self._start_nexus_manual_install(tool_id, message[len("NEXUS_MANUAL_REQUIRED:"):])
|
||||
return
|
||||
|
||||
if success:
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if status and status.installed and card:
|
||||
@@ -351,6 +375,23 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
card.set_busy(False)
|
||||
MessageService.warning(self, "Failed", message)
|
||||
|
||||
def _start_nexus_manual_install(self, tool_id: str, nexus_url: str) -> None:
|
||||
from jackify.frontends.gui.dialogs.nexus_manual_install_dialog import NexusManualInstallDialog
|
||||
defn = next((d for d in get_effective_definitions() if d.tool_id == tool_id), None)
|
||||
display_name = defn.display_name if defn else tool_id
|
||||
dlg = NexusManualInstallDialog(tool_id, display_name, nexus_url, parent=self)
|
||||
if dlg.exec() != NexusManualInstallDialog.Accepted:
|
||||
return
|
||||
archive = dlg.selected_archive
|
||||
if not archive:
|
||||
return
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_busy(True, "Installing...")
|
||||
self._action_thread = _ArchiveInstallThread(tool_id, archive)
|
||||
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||
self._action_thread.start()
|
||||
|
||||
def _on_update_all(self):
|
||||
updates = [tid for tid, card in self._cards.items()
|
||||
if card._status.installed and card._status.update_available]
|
||||
@@ -389,6 +430,9 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if not status:
|
||||
return
|
||||
if not status.definition.github_repo:
|
||||
MessageService.warning(self, "Change Version", "Version selection is only available for GitHub-hosted tools.")
|
||||
return
|
||||
if card:
|
||||
card.set_busy(True, "Fetching releases...")
|
||||
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
|
||||
@@ -465,5 +509,3 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
|
||||
@@ -6,7 +6,6 @@ Engines show Set Active / Active badge; tools with can_launch show Launch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
@@ -16,7 +15,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.frontends.gui.services.message_service import MessageService, open_url
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +27,12 @@ _C_SET_ACTIVE = "#4a5568"
|
||||
_C_BACK = "#4a5568"
|
||||
_C_DISABLED = "#333"
|
||||
|
||||
_STYLE_BTN_INVISIBLE = (
|
||||
"QPushButton { background: transparent; border: none; color: transparent; "
|
||||
"font-size: 11px; font-weight: bold; padding: 4px 8px; min-width: 90px; }"
|
||||
"QPushButton:hover { background: transparent; }"
|
||||
)
|
||||
|
||||
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
|
||||
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
|
||||
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
||||
@@ -78,7 +83,18 @@ class ToolCard(QFrame):
|
||||
|
||||
info_col = QVBoxLayout()
|
||||
info_col.setSpacing(2)
|
||||
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
|
||||
url = status.definition.upstream_url
|
||||
if url:
|
||||
name_html = (
|
||||
f'<a href="{url}" style="color: #e0e0e0; text-decoration: none; font-weight: bold;">'
|
||||
f'{status.definition.display_name}</a>'
|
||||
)
|
||||
else:
|
||||
name_html = f"<b>{status.definition.display_name}</b>"
|
||||
self._name_label = QLabel(name_html)
|
||||
self._name_label.setTextFormat(Qt.RichText)
|
||||
self._name_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
self._name_label.linkActivated.connect(self._open_url)
|
||||
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||
info_col.addWidget(self._name_label)
|
||||
desc_label = QLabel(status.definition.description)
|
||||
@@ -118,7 +134,6 @@ class ToolCard(QFrame):
|
||||
btn_col.addWidget(self._btn_primary)
|
||||
self._btn_update = QPushButton("Update")
|
||||
self._btn_update.setFixedWidth(100)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
|
||||
btn_col.addWidget(self._btn_update)
|
||||
self._btn_more = QPushButton("...")
|
||||
@@ -150,7 +165,10 @@ class ToolCard(QFrame):
|
||||
self._btn_primary.setText(self._busy_label or "Working...")
|
||||
self._btn_primary.setEnabled(False)
|
||||
self._btn_primary.setVisible(True)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_update.setStyleSheet(
|
||||
_STYLE_BTN_INVISIBLE
|
||||
)
|
||||
self._btn_update.setEnabled(False)
|
||||
self._btn_more.setEnabled(False)
|
||||
return
|
||||
|
||||
@@ -170,9 +188,7 @@ class ToolCard(QFrame):
|
||||
|
||||
iv = self._status.installed_version or "-"
|
||||
lv = self._status.latest_version or "checking..."
|
||||
self._version_label.setText(
|
||||
f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}"
|
||||
)
|
||||
self._version_label.setText(f"Installed: {iv}\nLatest: {lv}")
|
||||
|
||||
if not installed:
|
||||
self._btn_primary.setText("Install")
|
||||
@@ -197,9 +213,14 @@ class ToolCard(QFrame):
|
||||
else:
|
||||
self._btn_primary.setVisible(False)
|
||||
|
||||
self._btn_update.setVisible(installed and update_avail and not self._busy)
|
||||
if installed and update_avail:
|
||||
if installed and update_avail and not self._busy:
|
||||
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
|
||||
self._btn_update.setEnabled(True)
|
||||
else:
|
||||
self._btn_update.setStyleSheet(
|
||||
_STYLE_BTN_INVISIBLE
|
||||
)
|
||||
self._btn_update.setEnabled(False)
|
||||
self._btn_more.setEnabled(not self._busy)
|
||||
|
||||
def set_latest_version(self, tag: str) -> bool:
|
||||
@@ -289,6 +310,9 @@ class ToolCard(QFrame):
|
||||
else:
|
||||
self._launch()
|
||||
|
||||
def _open_url(self, url: str):
|
||||
open_url(url)
|
||||
|
||||
def _launch(self):
|
||||
binary = ToolRegistry().get_binary_path(self._tool_id)
|
||||
if not binary:
|
||||
@@ -313,13 +337,18 @@ class ToolCard(QFrame):
|
||||
"QMenu::item:disabled { color: #555; }"
|
||||
)
|
||||
defn = self._status.definition
|
||||
upstream_action = menu.addAction("Open Website")
|
||||
upstream_action.setEnabled(bool(defn.upstream_url))
|
||||
menu.addSeparator()
|
||||
downgrade_action = menu.addAction("Change Version")
|
||||
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
|
||||
uninstall_action = menu.addAction("Uninstall")
|
||||
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
|
||||
|
||||
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
|
||||
if chosen == downgrade_action and downgrade_action.isEnabled():
|
||||
if chosen == upstream_action and defn.upstream_url:
|
||||
self._open_url(defn.upstream_url)
|
||||
elif chosen == downgrade_action and downgrade_action.isEnabled():
|
||||
self.action_requested.emit(self._tool_id, "downgrade")
|
||||
elif chosen == uninstall_action and uninstall_action.isEnabled():
|
||||
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..utils import set_responsive_minimum, browse_directory
|
||||
from ..widgets.file_progress_list import FileProgressList
|
||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -89,7 +90,7 @@ class WabbajackInstallerWorker(QThread):
|
||||
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
|
||||
|
||||
|
||||
class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
class WabbajackInstallerScreen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
"""Wabbajack installer GUI screen following standard Jackify layout"""
|
||||
|
||||
resize_request = Signal(str)
|
||||
@@ -389,14 +390,13 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
# Get shortcut name
|
||||
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
|
||||
|
||||
# Confirm with user (standard dialog - no safety countdown needed for this operation)
|
||||
confirm = MessageService.question(
|
||||
self,
|
||||
"Confirm Installation",
|
||||
f"Install Wabbajack to:\n{self.install_folder}\n\n"
|
||||
"This will download Wabbajack, add to Steam, install WebView2,\n"
|
||||
"and configure the Wine prefix automatically.\n\n"
|
||||
"Steam will be restarted during installation.\n\n"
|
||||
"Warning: Steam will be restarted during installation - this will close any running game.\n\n"
|
||||
"Continue?",
|
||||
safety_level="medium",
|
||||
)
|
||||
@@ -620,15 +620,6 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._stop_focus_reclaim()
|
||||
if self.worker is not None:
|
||||
try:
|
||||
if self.worker.isRunning():
|
||||
self.worker.requestInterruption()
|
||||
self.worker.wait(5000)
|
||||
self.worker.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
self.worker = None
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Called when widget becomes visible"""
|
||||
|
||||
@@ -3,9 +3,31 @@ Non-Focus-Stealing Message Service for Jackify
|
||||
Provides message boxes that don't steal focus from the current application
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import subprocess
|
||||
import warnings
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def open_url(url: str) -> None:
|
||||
"""Open a URL in the system browser, safe to call from within an AppImage."""
|
||||
env = os.environ.copy()
|
||||
if "APPIMAGE" in env or "APPDIR" in env:
|
||||
for var in ("LD_LIBRARY_PATH", "PYTHONPATH", "PYTHONHOME", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH"):
|
||||
env.pop(var, None)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["xdg-open", url], env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to open URL %s: %s", url, e)
|
||||
from PySide6.QtWidgets import (
|
||||
QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout,
|
||||
QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy,
|
||||
@@ -61,14 +83,16 @@ class SafeMessageBox(NonFocusMessageBox):
|
||||
self._setup_low_safety(danger_action, safe_action)
|
||||
# --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons ---
|
||||
if is_question and hasattr(self, 'proceed_btn'):
|
||||
self.proceed_btn.setText(danger_action)
|
||||
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
|
||||
self.proceed_btn.clicked.disconnect()
|
||||
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
|
||||
self.cancel_btn.setText(safe_action)
|
||||
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
|
||||
self.cancel_btn.clicked.disconnect()
|
||||
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
self.proceed_btn.setText(danger_action)
|
||||
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
|
||||
self.proceed_btn.clicked.disconnect()
|
||||
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
|
||||
self.cancel_btn.setText(safe_action)
|
||||
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
|
||||
self.cancel_btn.clicked.disconnect()
|
||||
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
|
||||
|
||||
def _setup_high_safety(self, danger_action: str, safe_action: str):
|
||||
"""High safety: requires typing confirmation code"""
|
||||
|
||||
@@ -6,6 +6,7 @@ worker thread management, and completion callbacks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
@@ -301,6 +302,8 @@ class VNVAutomationController(QObject):
|
||||
dialog.load_items(manager.items)
|
||||
dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state))
|
||||
dialog.show()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
|
||||
def _cancel_manual_download_flow(self, on_complete, state: dict) -> None:
|
||||
if state["done"]:
|
||||
@@ -342,10 +345,12 @@ class VNVAutomationController(QObject):
|
||||
self._manual_dialog = None
|
||||
self._manual_manager = None
|
||||
if dialog is not None:
|
||||
try:
|
||||
dialog.finished.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
dialog.finished.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
dialog.close()
|
||||
except Exception:
|
||||
|
||||
@@ -7,9 +7,12 @@ R&D NOTE: This is experimental code for investigation purposes.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import logging
|
||||
import shiboken6
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QHBoxLayout, QSizePolicy
|
||||
@@ -113,7 +116,7 @@ class _CpuWorker(QThread):
|
||||
def _debug_log(message):
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
if ConfigHandler().get('debug_mode', False):
|
||||
print(message)
|
||||
logger.debug(message)
|
||||
|
||||
|
||||
class FileProgressList(QWidget):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -667,7 +667,7 @@ _LEGACY_GAME_TYPES = {"falloutnv", "fallout3", "oblivion"}
|
||||
def _is_nsf_prefix(pfx: Path) -> bool:
|
||||
"""Return True if this prefix has global mscoree=native in Wine DllOverrides.
|
||||
|
||||
All Skyrim prefixes get a per-exe AppDefaults\\SkyrimSE.exe mscoree override —
|
||||
All Skyrim prefixes get a per-exe AppDefaults\\SkyrimSE.exe mscoree override -
|
||||
that does not qualify. NSF/CSF prefixes additionally have it in the global
|
||||
DllOverrides section, which is what this checks.
|
||||
"""
|
||||
@@ -686,7 +686,7 @@ def _is_nsf_prefix(pfx: Path) -> bool:
|
||||
|
||||
|
||||
def check_native_dotnet(pfx: Path, r: Results, game_type: str = ""):
|
||||
"""Check native dotnet40/48 installation — only meaningful for NSF/CSF prefixes.
|
||||
"""Check native dotnet40/48 installation - only meaningful for NSF/CSF prefixes.
|
||||
|
||||
Non-NSF prefixes always have phantom NDP keys written by Wine Mono; checking
|
||||
them produces false positives. Skip entirely unless global mscoree=native is set.
|
||||
@@ -1079,7 +1079,7 @@ def check_ttw_installation(modlist_dir: Path, game_type: str, r: Results, modlis
|
||||
|
||||
def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
||||
"""Check whether Tool Compatibility Config has been applied to this prefix."""
|
||||
# Tool compat is not applied for these game types — nothing to check
|
||||
# Tool compat is not applied for these game types - nothing to check
|
||||
_no_tool_compat = ("falloutnv", "fallout3", "enderal", "cp2077", "bg3", "skyrimvr", "fallout4vr")
|
||||
if game_type in _no_tool_compat:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user