Release v0.7.1 - Remote Manifest System, Stability Fixes

This commit is contained in:
Omni
2026-07-02 21:04:19 +01:00
parent 6def3b480a
commit e5d329a2dc
96 changed files with 1950 additions and 1382 deletions
+12 -73
View File
@@ -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__)
+4
View File
@@ -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"
+37 -11
View File
@@ -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."""
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+6 -133
View File
@@ -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 -1
View File
@@ -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."""
-16
View File
@@ -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
-179
View File
@@ -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__)
+1 -54
View File
@@ -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__)
+20 -3
View File
@@ -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),
+57
View File
@@ -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())
+46 -5
View File
@@ -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.
+194 -30
View File
@@ -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:
+4 -4
View File
@@ -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
+43 -6
View File
@@ -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,
+78
View File
@@ -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