mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:33:42 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user