mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-13 23:33:43 +02:00
Release v0.7.1 - Remote Manifest System, Stability Fixes
This commit is contained in:
@@ -1,5 +1,31 @@
|
|||||||
# Jackify Changelog
|
# Jackify Changelog
|
||||||
|
|
||||||
|
## v0.7.1 - Remote Manifest System, Stability Fixes
|
||||||
|
**Release Date:** 2026-07-02
|
||||||
|
|
||||||
|
### Remote Manifest System
|
||||||
|
Jackify can now receive updated instructions without a new release. Tool definitions, problem mods, and compatibility data are fetched from a remote manifest hosted on github at runtime. The first use of this is automatic "problem mod" detection: a JSON-based manifest identifies mods known to cause issues under Proton and the specific action needed for each - disabling the mod, creating directories it expects to exist, etc - applied automatically during the install, with the affected list shown in the success window. The manifest can be updated centrally as new problem mods are identified (or removed), without needing to publish a new Jackify version, or any action by the user. This modular, manifest-based approach will be expanded upon in future versions of Jackify.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Improved reliability of vcrun2022 and vcrun2012 native component installation, resolving DLL load failures in modlists that need them.
|
||||||
|
- Synthesis's required NuGet security certificate is no longer skipped on modlists that also use NetScriptFramework (NSF/CSF).
|
||||||
|
- Problem mod fixes that create extra directories (e.g. Dialogue History) are no longer applied unless the mod is actually present in your modlist.
|
||||||
|
- Proton auto-detection no longer selects GE-Proton 9.x when a current version is available. Old GE-Proton builds are now ranked below current Valve Proton releases.
|
||||||
|
- Closing the modlist gallery before it finishes loading no longer prevents it from loading again without restarting Jackify.
|
||||||
|
- Fixed intermittent crashes when closing screens or dialogs during installation and configuration workflows.
|
||||||
|
- Fixed ownership check incorrectly flagging modlist files as broken when they are owned by a supplementary group (e.g. autologin). The suggested fix command also showed the wrong group name.
|
||||||
|
- If Nexus API key decryption fails, Jackify now prompts you to re-authenticate rather than quietly using a garbage key.
|
||||||
|
- Permissions check on large installs no longer walks the entire directory tree when everything is already correct.
|
||||||
|
- Tools Hub: tools marked hidden in the remote manifest now disappear immediately on refresh rather than on next restart.
|
||||||
|
- Tools Hub: tools with no GitHub release (Nexus-only) now fall back to manual download rather than failing silently.
|
||||||
|
- Standalone MO2 setup now correctly uses Jackify's bundled 7z rather than requiring one to be installed system-wide.
|
||||||
|
- Manual download dialog now comes to the front automatically when it opens during a non-premium install.
|
||||||
|
- Failed downloads now report the mod name and reason in the error rather than a generic failure message.
|
||||||
|
- Wabbajack installer now warns that Steam will close any running game during installation.
|
||||||
|
- Update checks against the GitHub API now respect a `GITHUB_TOKEN` env var and cache the result for 24 hours - useful on shared IPs that hit the unauthenticated rate limit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
|
## v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
|
||||||
**Release Date:** 21/06/26
|
**Release Date:** 21/06/26
|
||||||
|
|
||||||
@@ -44,6 +70,7 @@
|
|||||||
- Fixed: Manual downloads could open the same download link a second time within seconds if the file was still being written to disk when the watcher first detected it.
|
- Fixed: Manual downloads could open the same download link a second time within seconds if the file was still being written to disk when the watcher first detected it.
|
||||||
- Fixed: Files that finished downloading were sometimes not picked up by the watch folder scanner, leaving items stuck on "pending" and requiring manual intervention.
|
- Fixed: Files that finished downloading were sometimes not picked up by the watch folder scanner, leaving items stuck on "pending" and requiring manual intervention.
|
||||||
- Added a "Scan Now" button to the manual download dialog to force an immediate re-scan of the watch directory without waiting for the next automatic poll.
|
- Added a "Scan Now" button to the manual download dialog to force an immediate re-scan of the watch directory without waiting for the next automatic poll.
|
||||||
|
- JContainers64.dll Linux crash fix: Skyrim SE modlists using SKSE 1.6.1170 with JContainers are detected automatically during configuration. The incompatible DLL is replaced with a Linux-compatible build, with the original backed up.
|
||||||
|
|
||||||
### Engine (0.5.6)
|
### Engine (0.5.6)
|
||||||
- Heroic Launcher game detection (GOG + Epic, native and Flatpak).
|
- Heroic Launcher game detection (GOG + Epic, native and Flatpak).
|
||||||
|
|||||||
+1
-1
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
|||||||
Wabbajack modlists natively on Linux systems.
|
Wabbajack modlists natively on Linux systems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.7.0"
|
__version__ = "0.7.1"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional, Dict, List, Any, Union
|
|||||||
from ..handlers.protontricks_handler import ProtontricksHandler
|
from ..handlers.protontricks_handler import ProtontricksHandler
|
||||||
from ..handlers.shortcut_handler import ShortcutHandler
|
from ..handlers.shortcut_handler import ShortcutHandler
|
||||||
from ..handlers.menu_handler import MenuHandler, ModlistMenuHandler
|
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
|
import logging
|
||||||
from ..handlers.wabbajack_parser import WabbajackParser
|
from ..handlers.wabbajack_parser import WabbajackParser
|
||||||
import re
|
import re
|
||||||
@@ -24,6 +24,12 @@ from .modlist_operations_configuration_cli import ModlistOperationsConfiguration
|
|||||||
from .modlist_operations_configuration_gui import ModlistOperationsConfigurationGUIMixin
|
from .modlist_operations_configuration_gui import ModlistOperationsConfigurationGUIMixin
|
||||||
from .modlist_operations_game_detection import ModlistOperationsGameDetectionMixin
|
from .modlist_operations_game_detection import ModlistOperationsGameDetectionMixin
|
||||||
from .modlist_operations_nexus import ModlistOperationsNexusMixin
|
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():
|
def _get_user_proton_version():
|
||||||
@@ -179,22 +185,12 @@ class ModlistInstallCLI(
|
|||||||
# Initialize process tracking for cleanup
|
# Initialize process tracking for cleanup
|
||||||
self._current_process = None
|
self._current_process = None
|
||||||
|
|
||||||
@staticmethod
|
_normalize_version_token = staticmethod(normalize_version_token)
|
||||||
def _normalize_version_token(value: str | None) -> str | None:
|
_normalize_modlist_name = staticmethod(normalize_modlist_name)
|
||||||
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())
|
|
||||||
|
|
||||||
def _get_requested_modlist_version(self) -> str | None:
|
def _get_requested_modlist_version(self) -> str | None:
|
||||||
info = self.context.get("selected_modlist_info") or {}
|
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(
|
def _evaluate_update_candidate(
|
||||||
self,
|
self,
|
||||||
@@ -202,68 +198,11 @@ class ModlistInstallCLI(
|
|||||||
install_dir: str,
|
install_dir: str,
|
||||||
existing_appid: str | None,
|
existing_appid: str | None,
|
||||||
) -> tuple[bool, dict]:
|
) -> 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()
|
requested_version = self._get_requested_modlist_version()
|
||||||
installed_version = self._normalize_version_token(meta.get("modlist_version"))
|
return _svc_evaluate(modlist_name, install_dir, existing_appid, requested_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(self, modlist_name: str, install_dir: str) -> str | None:
|
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||||
try:
|
return _svc_find_appid(modlist_name, install_dir)
|
||||||
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
|
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up any running jackify-engine process"""
|
"""Clean up any running jackify-engine process"""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..handlers.ui_colors import (
|
from jackify.shared.colors import (
|
||||||
COLOR_PROMPT,
|
COLOR_PROMPT,
|
||||||
COLOR_RESET,
|
COLOR_RESET,
|
||||||
COLOR_INFO,
|
COLOR_INFO,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
from ..handlers.ui_colors import (
|
from jackify.shared.colors import (
|
||||||
COLOR_PROMPT,
|
COLOR_PROMPT,
|
||||||
COLOR_RESET,
|
COLOR_RESET,
|
||||||
COLOR_INFO,
|
COLOR_INFO,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ Windows Registry Editor Version 5.00
|
|||||||
"xactengine3_5"="native,builtin"
|
"xactengine3_5"="native,builtin"
|
||||||
"xactengine3_6"="native,builtin"
|
"xactengine3_6"="native,builtin"
|
||||||
"xactengine3_7"="native,builtin"
|
"xactengine3_7"="native,builtin"
|
||||||
|
"atl110"="native,builtin"
|
||||||
|
"msvcp110"="native,builtin"
|
||||||
|
"msvcr110"="native,builtin"
|
||||||
|
"vcomp110"="native,builtin"
|
||||||
"concrt140"="native,builtin"
|
"concrt140"="native,builtin"
|
||||||
"msvcp140"="native,builtin"
|
"msvcp140"="native,builtin"
|
||||||
"msvcp140_1"="native,builtin"
|
"msvcp140_1"="native,builtin"
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
|||||||
return
|
return
|
||||||
ConfigHandler._initialized = True
|
ConfigHandler._initialized = True
|
||||||
|
|
||||||
|
self._dirty_keys: set = set()
|
||||||
|
|
||||||
self.config_dir = os.path.expanduser("~/.config/jackify")
|
self.config_dir = os.path.expanduser("~/.config/jackify")
|
||||||
self.config_file = os.path.join(self.config_dir, "config.json")
|
self.config_file = os.path.join(self.config_dir, "config.json")
|
||||||
self.settings = {
|
self.settings = {
|
||||||
@@ -146,7 +148,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
|||||||
logger.info(f"Migrating config from {current_version} to {target_version}")
|
logger.info(f"Migrating config from {current_version} to {target_version}")
|
||||||
|
|
||||||
# Migration: v0.0.x -> v0.2.0
|
# 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
|
# Old encrypted API keys cannot be decrypted, must be re-entered
|
||||||
from packaging import version
|
from packaging import version
|
||||||
if version.parse(current_version) < version.parse("0.2.0"):
|
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}")
|
logger.error(f"Error creating configuration directory: {e}")
|
||||||
|
|
||||||
def save_config(self):
|
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
|
import tempfile
|
||||||
try:
|
try:
|
||||||
self._create_config_dir()
|
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_')
|
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.config_file), prefix='.config_tmp_')
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, 'w') as f:
|
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.chmod(tmp_path, 0o600)
|
||||||
os.replace(tmp_path, self.config_file)
|
os.replace(tmp_path, self.config_file)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -235,6 +257,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
self._dirty_keys.clear()
|
||||||
logger.debug("Saved configuration to file")
|
logger.debug("Saved configuration to file")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -242,35 +265,38 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
"""
|
"""Return configuration value, preferring any unsaved in-memory value."""
|
||||||
Get a configuration value by key.
|
if key in self._dirty_keys:
|
||||||
Always reads fresh from disk to avoid stale data.
|
return self.settings.get(key, default)
|
||||||
"""
|
|
||||||
config = self._read_config_from_disk()
|
config = self._read_config_from_disk()
|
||||||
return config.get(key, default)
|
return config.get(key, default)
|
||||||
|
|
||||||
def set(self, key, value):
|
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.settings[key] = value
|
||||||
|
self._dirty_keys.add(key)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def update(self, settings_dict):
|
def update(self, settings_dict):
|
||||||
"""Update multiple configuration values"""
|
"""Update multiple configuration values (marks all keys dirty)."""
|
||||||
self.settings.update(settings_dict)
|
self.settings.update(settings_dict)
|
||||||
|
self._dirty_keys.update(settings_dict.keys())
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def add_steam_library(self, path):
|
def add_steam_library(self, path):
|
||||||
"""Add a Steam library path to configuration"""
|
"""Add a Steam library path to configuration"""
|
||||||
if path not in self.settings["steam_libraries"]:
|
if path not in self.settings["steam_libraries"]:
|
||||||
self.settings["steam_libraries"].append(path)
|
self.settings["steam_libraries"].append(path)
|
||||||
|
self._dirty_keys.add("steam_libraries")
|
||||||
logger.debug(f"Added Steam library: {path}")
|
logger.debug(f"Added Steam library: {path}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def remove_steam_library(self, path):
|
def remove_steam_library(self, path):
|
||||||
"""Remove a Steam library path from configuration"""
|
"""Remove a Steam library path from configuration"""
|
||||||
if path in self.settings["steam_libraries"]:
|
if path in self.settings["steam_libraries"]:
|
||||||
self.settings["steam_libraries"].remove(path)
|
self.settings["steam_libraries"].remove(path)
|
||||||
|
self._dirty_keys.add("steam_libraries")
|
||||||
logger.debug(f"Removed Steam library: {path}")
|
logger.debug(f"Removed Steam library: {path}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -2,88 +2,24 @@
|
|||||||
Config handler API key encryption and storage.
|
Config handler API key encryption and storage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from jackify.backend.utils.machine_crypto import encrypt as _encrypt, decrypt as _decrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConfigEncryptionMixin:
|
class ConfigEncryptionMixin:
|
||||||
"""Mixin providing encryption and API key storage for ConfigHandler."""
|
"""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:
|
def _encrypt_api_key(self, api_key: str) -> str:
|
||||||
"""Encrypt API key using AES-GCM."""
|
"""Encrypt API key using AES-GCM via machine_crypto."""
|
||||||
try:
|
return _encrypt(api_key)
|
||||||
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 ""
|
|
||||||
|
|
||||||
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
def _decrypt_api_key(self, encrypted_key: str) -> Optional[str]:
|
||||||
"""Decrypt API key using AES-GCM."""
|
"""Decrypt API key. Returns None on any failure - never returns garbage."""
|
||||||
try:
|
return _decrypt(encrypted_key)
|
||||||
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
|
|
||||||
|
|
||||||
def save_api_key(self, api_key):
|
def save_api_key(self, api_key):
|
||||||
"""Save Nexus API key with encryption."""
|
"""Save Nexus API key with encryption."""
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ class FilesystemOwnershipMixin:
|
|||||||
def all_owned_by_user(path: Path) -> bool:
|
def all_owned_by_user(path: Path) -> bool:
|
||||||
"""Return True if all files and directories under path are owned by the current user."""
|
"""Return True if all files and directories under path are owned by the current user."""
|
||||||
uid = os.getuid()
|
uid = os.getuid()
|
||||||
gid = os.getgid()
|
gids = set([os.getgid()] + os.getgroups())
|
||||||
for root, dirs, files in os.walk(path):
|
for root, dirs, files in os.walk(path):
|
||||||
for name in dirs + files:
|
for name in dirs + files:
|
||||||
full_path = os.path.join(root, name)
|
full_path = os.path.join(root, name)
|
||||||
try:
|
try:
|
||||||
stat = os.stat(full_path)
|
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
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@@ -50,7 +50,7 @@ class FilesystemOwnershipMixin:
|
|||||||
if not FilesystemOwnershipMixin.all_owned_by_user(path):
|
if not FilesystemOwnershipMixin.all_owned_by_user(path):
|
||||||
try:
|
try:
|
||||||
user_name = pwd.getpwuid(os.geteuid()).pw_name
|
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:
|
except KeyError:
|
||||||
logger.error("Could not determine current user or group name.")
|
logger.error("Could not determine current user or group name.")
|
||||||
return False, "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
|
return False, error_msg
|
||||||
|
|
||||||
logger.info("Files in %s are owned by current user, verifying permissions...", path)
|
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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['chmod', '-R', '755', str(path)],
|
['chmod', '-R', '755', str(path)],
|
||||||
@@ -84,6 +87,19 @@ class FilesystemOwnershipMixin:
|
|||||||
logger.warning("Error running chmod: %s, continuing anyway", e)
|
logger.warning("Error running chmod: %s, continuing anyway", e)
|
||||||
return True, ""
|
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
|
@staticmethod
|
||||||
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
def set_ownership_and_permissions_sudo(path: Path, status_callback=None) -> bool:
|
||||||
"""Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility."""
|
"""Deprecated: use verify_ownership_and_permissions() instead. Kept for backwards compatibility."""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
import glob # Add for the simpler tab completion
|
import glob # Add for the simpler tab completion
|
||||||
|
|
||||||
# Import colors from the new central location
|
# 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_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
|
||||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_DISABLED, COLOR_ACTION, COLOR_INPUT
|
COLOR_SUCCESS, COLOR_WARNING, COLOR_DISABLED, COLOR_ACTION, COLOR_INPUT
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import glob
|
import glob
|
||||||
|
|
||||||
from .ui_colors import COLOR_PROMPT, COLOR_RESET
|
from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
|
||||||
|
|
||||||
READLINE_AVAILABLE = False
|
READLINE_AVAILABLE = False
|
||||||
READLINE_HAS_PROMPT = False
|
READLINE_HAS_PROMPT = False
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
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_PROMPT, COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_ERROR,
|
||||||
COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT
|
COLOR_SUCCESS, COLOR_WARNING, COLOR_ACTION, COLOR_INPUT
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
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
|
from .resolution_handler import ResolutionHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -596,7 +596,9 @@ class ModlistConfigurationMixin:
|
|||||||
# dotnet9 SDK install also flips the prefix to win11; NSF/CSF was only
|
# 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
|
# 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
|
# 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)
|
_nsf = getattr(self, '_nsf_detected', False)
|
||||||
apply_tool_config(
|
apply_tool_config(
|
||||||
compatdata_path,
|
compatdata_path,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .modlist_configuration import ModlistConfigurationMixin
|
|||||||
from .modlist_wine_ops import ModlistWineOpsMixin
|
from .modlist_wine_ops import ModlistWineOpsMixin
|
||||||
|
|
||||||
# Import colors from the new central location
|
# 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)
|
# Standard logging (no file handler)
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Optional, Dict, List, Any, Union
|
|||||||
from .protontricks_handler import ProtontricksHandler
|
from .protontricks_handler import ProtontricksHandler
|
||||||
from .shortcut_handler import ShortcutHandler
|
from .shortcut_handler import ShortcutHandler
|
||||||
from .menu_handler import MenuHandler, ModlistMenuHandler
|
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
|
# Standard logging (no file handler) - LoggingHandler import removed
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -20,7 +20,7 @@ import time
|
|||||||
import pty
|
import pty
|
||||||
|
|
||||||
# Import UI Colors first - these should always be available
|
# 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
|
# Standard logging (no file handler) - LoggingHandler import removed
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback
|
from .engine_monitor import EnginePerformanceMonitor, create_stall_alert_callback
|
||||||
from .ui_colors import (
|
from jackify.shared.colors import (
|
||||||
COLOR_PROMPT,
|
COLOR_PROMPT,
|
||||||
COLOR_RESET,
|
COLOR_RESET,
|
||||||
COLOR_INFO,
|
COLOR_INFO,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
from .config_handler import ConfigHandler
|
from .config_handler import ConfigHandler
|
||||||
from .ui_colors import (
|
from jackify.shared.colors import (
|
||||||
COLOR_PROMPT,
|
COLOR_PROMPT,
|
||||||
COLOR_RESET,
|
COLOR_RESET,
|
||||||
COLOR_INFO,
|
COLOR_INFO,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import signal
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
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
|
from jackify.shared.paths import get_jackify_logs_dir
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -371,29 +371,89 @@ class NativeComponentInstaller:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
syswow64, system32 = self._get_system_dirs()
|
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:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
for exe, cab, dest, dlls in [(x86, 'a10', syswow64, _VCRUN2022_DLLS_X86),
|
tmpdir_path = Path(tmpdir)
|
||||||
(x64, 'a12', system32, _VCRUN2022_DLLS_X64)]:
|
|
||||||
arch_tmp = Path(tmpdir) / cab
|
# x86: pre-extract msvcp140.dll before running the installer.
|
||||||
arch_tmp.mkdir()
|
# Wine's builtin msvcp140 reports a higher version number so the installer
|
||||||
subprocess.run([cabextract, '-d', str(arch_tmp), '-F', cab, str(exe)], capture_output=True)
|
# skips it without this manual extraction (Wine bug #57518).
|
||||||
inner_cab = arch_tmp / cab
|
win32_tmp = tmpdir_path / 'win32'
|
||||||
if not inner_cab.is_file():
|
win32_tmp.mkdir()
|
||||||
self.logger.error("vcrun2022: inner cab '%s' not found in %s", cab, exe.name)
|
subprocess.run(
|
||||||
return False
|
[cabextract, '-d', str(win32_tmp), '-F', 'a10', str(x86)],
|
||||||
for dll_name in dlls:
|
capture_output=True,
|
||||||
subprocess.run([cabextract, '-d', str(dest), '-F', dll_name, str(inner_cab)], capture_output=True)
|
)
|
||||||
if not (dest / 'msvcp140.dll').is_file():
|
inner_x86 = win32_tmp / 'a10'
|
||||||
self.logger.error("vcrun2022: msvcp140.dll not extracted to %s", dest)
|
if inner_x86.is_file():
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
def _install_vcrun2012(self) -> bool:
|
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 = get_jackify_data_dir() / 'component_cache' / 'vcrun2012'
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
x86 = cache_dir / 'vcredist_x86.exe'
|
x86 = cache_dir / 'vcredist_x86.exe'
|
||||||
@@ -402,24 +462,38 @@ class NativeComponentInstaller:
|
|||||||
return False
|
return False
|
||||||
if not self._download_file(_VCRUN2012_X64_URL, x64):
|
if not self._download_file(_VCRUN2012_X64_URL, x64):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._apply_dll_overrides()
|
||||||
|
|
||||||
|
env = self._wine_env_base()
|
||||||
syswow64, system32 = self._get_system_dirs()
|
syswow64, system32 = self._get_system_dirs()
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
for exe, dest in [(x86, syswow64), (x64, system32)]:
|
self.logger.info("vcrun2012: running x86 installer")
|
||||||
for cab_name in ('a2', 'a3'):
|
r = subprocess.run(
|
||||||
td = Path(tmpdir) / (exe.stem + cab_name)
|
[self.wine_binary, str(x86), '/q'],
|
||||||
td.mkdir()
|
env=env,
|
||||||
subprocess.run([cabextract, '-d', str(td), '-F', cab_name, str(exe)], capture_output=True)
|
capture_output=True,
|
||||||
inner = td / cab_name
|
timeout=300,
|
||||||
if not inner.is_file():
|
)
|
||||||
continue
|
if r.returncode not in (0, 3010):
|
||||||
dd = td / 'x'
|
self.logger.error("vcrun2012: x86 installer failed (rc=%d)", r.returncode)
|
||||||
dd.mkdir()
|
self.logger.debug("vcrun2012 x86 stderr: %s", r.stderr.decode(errors='replace'))
|
||||||
subprocess.run([cabextract, '-d', str(dd), '-L', '-F', 'F_CENTRAL_*', str(inner)], capture_output=True)
|
return False
|
||||||
for src in dd.iterdir():
|
|
||||||
if src.name.startswith('f_central_'):
|
self.logger.info("vcrun2012: running x64 installer")
|
||||||
shutil.copy2(src, dest / (src.name[10:].rsplit('_', 1)[0] + '.dll'))
|
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():
|
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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ Handles encrypted storage and retrieval of OAuth tokens
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jackify.backend.utils.machine_crypto import encrypt as _mc_encrypt, decrypt as _mc_decrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,140 +40,13 @@ class OAuthTokenHandler:
|
|||||||
# Ensure config directory exists
|
# Ensure config directory exists
|
||||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
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:
|
def _encrypt_data(self, data: str) -> str:
|
||||||
"""
|
"""Encrypt data using AES-GCM via machine_crypto."""
|
||||||
Encrypt data using AES-GCM (authenticated encryption)
|
return _mc_encrypt(data)
|
||||||
|
|
||||||
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 ""
|
|
||||||
|
|
||||||
def _decrypt_data(self, encrypted_data: str) -> Optional[str]:
|
def _decrypt_data(self, encrypted_data: str) -> Optional[str]:
|
||||||
"""
|
"""Decrypt data. Returns None on any failure."""
|
||||||
Decrypt data using AES-GCM (authenticated encryption)
|
return _mc_decrypt(encrypted_data)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def save_token(self, token_data: Dict) -> bool:
|
def save_token(self, token_data: Dict) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
# Import colors from the new central location
|
# 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
|
# Initialize logger
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
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.
|
"""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
|
unrelated options. Adds the top-level directory of each path so Proton's container
|
||||||
can bind-mount the subtree into the prefix.
|
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.
|
can stop Steam first, call apply_pending_mounts_update(), then restart Steam.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
"unchanged" — mounts already correct, no action needed
|
"unchanged" - mounts already correct, no action needed
|
||||||
"updated" — Steam was not running; write succeeded
|
"updated" - Steam was not running; write succeeded
|
||||||
"steam_running" — changes needed but deferred; call apply_pending_mounts_update()
|
"steam_running" - changes needed but deferred; call apply_pending_mounts_update()
|
||||||
after stopping Steam
|
after stopping Steam
|
||||||
"failed" — shortcut not found or write error
|
"failed" - shortcut not found or write error
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
@@ -110,7 +110,7 @@ class ShortcutLaunchOptionsMixin:
|
|||||||
steam_running = False
|
steam_running = False
|
||||||
|
|
||||||
if steam_running:
|
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.
|
# Store the pending options so the GUI can stop Steam, apply, then restart.
|
||||||
self._pending_mounts_app_name = app_name
|
self._pending_mounts_app_name = app_name
|
||||||
self._pending_mounts_exe_path = exe_path
|
self._pending_mounts_exe_path = exe_path
|
||||||
|
|||||||
@@ -1,293 +1,21 @@
|
|||||||
"""Steam restart methods for ShortcutHandler (Mixin)."""
|
"""Steam restart methods for ShortcutHandler (Mixin)."""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class ShortcutSteamRestartMixin:
|
||||||
"""Mixin providing Steam restart methods."""
|
"""Mixin providing Steam restart methods."""
|
||||||
|
|
||||||
def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
def secure_steam_restart(self, status_callback: Optional[Callable[[str], None]] = None) -> bool:
|
||||||
"""
|
"""Delegate to steam_restart_service (canonical restart path)."""
|
||||||
Secure Steam restart with comprehensive error handling to prevent segfaults.
|
|
||||||
Now delegates to the robust steam restart service for cross-distro compatibility.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
from ..services.steam_restart_service import robust_steam_restart
|
from ..services.steam_restart_service import robust_steam_restart
|
||||||
return robust_steam_restart(progress_callback=status_callback, timeout=60)
|
return robust_steam_restart(progress_callback=status_callback, timeout=60)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
self.logger.error(f"Failed to import steam restart service: {e}")
|
self.logger.error("steam_restart_service unavailable: %s", 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}")
|
|
||||||
return False
|
return False
|
||||||
|
except Exception as e:
|
||||||
self.logger.info("Steam confirmed closed.")
|
self.logger.error("robust_steam_restart failed: %s", e)
|
||||||
|
|
||||||
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}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
status_msg = "Waiting for Steam to fully start"
|
|
||||||
if status_callback:
|
|
||||||
try:
|
|
||||||
status_callback(status_msg)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Status callback error: {e}")
|
|
||||||
|
|
||||||
self.logger.info("Waiting up to 2 minutes for Steam to fully initialize...")
|
|
||||||
max_startup_wait = 120
|
|
||||||
elapsed_wait = 0
|
|
||||||
initial_wait_done = False
|
|
||||||
|
|
||||||
while elapsed_wait < max_startup_wait:
|
|
||||||
try:
|
|
||||||
result = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
|
||||||
if result.returncode == 0:
|
|
||||||
if not initial_wait_done:
|
|
||||||
self.logger.info("Steam process detected. Waiting additional time for full initialization...")
|
|
||||||
initial_wait_done = True
|
|
||||||
time.sleep(5)
|
|
||||||
elapsed_wait += 5
|
|
||||||
if initial_wait_done and elapsed_wait >= 15:
|
|
||||||
final_check = safe_subprocess_run(['pgrep', '-f', 'steam'], capture_output=True, timeout=10)
|
|
||||||
if final_check.returncode == 0:
|
|
||||||
if status_callback:
|
|
||||||
try:
|
|
||||||
status_callback("Steam Started")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Status callback error: {e}")
|
|
||||||
self.logger.info("Steam confirmed running after wait.")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.logger.warning("Steam process disappeared during final initialization wait.")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
self.logger.debug(f"Steam process not yet detected. Waiting... ({elapsed_wait + 5}s)")
|
|
||||||
time.sleep(5)
|
|
||||||
elapsed_wait += 5
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error during Steam startup wait: {e}")
|
|
||||||
time.sleep(5)
|
|
||||||
elapsed_wait += 5
|
|
||||||
|
|
||||||
self.logger.error("Steam failed to start/initialize within the allowed time.")
|
|
||||||
if status_callback:
|
|
||||||
try:
|
|
||||||
status_callback("Start Timed Out")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Status callback error: {e}")
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from .ui_colors import COLOR_INFO, COLOR_RESET
|
from jackify.shared.colors import COLOR_INFO, COLOR_RESET
|
||||||
|
|
||||||
def show_status(message: str):
|
def show_status(message: str):
|
||||||
"""Show a single-line status message, overwriting the current line."""
|
"""Show a single-line status message, overwriting the current line."""
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
UI Color Constants
|
|
||||||
"""
|
|
||||||
|
|
||||||
COLOR_PROMPT = '\033[93m' # Yellow
|
|
||||||
COLOR_SELECTION = '\033[96m' # Cyan
|
|
||||||
COLOR_RESET = '\033[0m'
|
|
||||||
COLOR_INFO = '\033[94m' # Blue
|
|
||||||
COLOR_ERROR = '\033[91m' # Red
|
|
||||||
COLOR_SUCCESS = '\033[92m' # Green
|
|
||||||
COLOR_WARNING = '\033[93m' # Yellow (reusing prompt color)
|
|
||||||
COLOR_DISABLED = '\033[90m' # Grey
|
|
||||||
|
|
||||||
COLOR_ACTION = '\033[97m' # Bright White for action/descriptions
|
|
||||||
COLOR_INPUT = '\033[97m' # Bright White for input prompts
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
"""
|
|
||||||
UIHandler module for managing user interface operations.
|
|
||||||
This module handles menus, prompts, and user interaction.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
from typing import Optional, List, Dict, Tuple, Callable, Any
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
class UIHandler:
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def show_menu(self, title: str, options: List[Dict[str, Any]]) -> Optional[str]:
|
|
||||||
"""Display a menu and get user selection."""
|
|
||||||
try:
|
|
||||||
print(f"\n{title}")
|
|
||||||
print("=" * len(title))
|
|
||||||
|
|
||||||
for i, option in enumerate(options, 1):
|
|
||||||
print(f"{i}. {option['label']}")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
choice = input("\nEnter your choice (or 'q' to quit): ")
|
|
||||||
if choice.lower() == 'q':
|
|
||||||
return None
|
|
||||||
|
|
||||||
choice = int(choice)
|
|
||||||
if 1 <= choice <= len(options):
|
|
||||||
return options[choice - 1]['value']
|
|
||||||
else:
|
|
||||||
print("Invalid choice. Please try again.")
|
|
||||||
except ValueError:
|
|
||||||
print("Please enter a number.")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show menu: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def show_progress(self, message: str, total: int = 100) -> None:
|
|
||||||
"""Display a progress indicator."""
|
|
||||||
try:
|
|
||||||
print(f"\n{message}")
|
|
||||||
print("[" + " " * 50 + "] 0%", end="\r")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show progress: {e}")
|
|
||||||
|
|
||||||
def update_progress(self, current: int, message: Optional[str] = None) -> None:
|
|
||||||
"""Update the progress indicator."""
|
|
||||||
try:
|
|
||||||
if message:
|
|
||||||
print(f"\n{message}")
|
|
||||||
progress = int(current / 2)
|
|
||||||
print("[" + "=" * progress + " " * (50 - progress) + f"] {current}%", end="\r")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to update progress: {e}")
|
|
||||||
|
|
||||||
def show_error(self, message: str, details: Optional[str] = None) -> None:
|
|
||||||
"""Display an error message."""
|
|
||||||
try:
|
|
||||||
print(f"\nError: {message}")
|
|
||||||
if details:
|
|
||||||
print(f"Details: {details}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show error: {e}")
|
|
||||||
|
|
||||||
def show_success(self, message: str, details: Optional[str] = None) -> None:
|
|
||||||
"""Display a success message."""
|
|
||||||
try:
|
|
||||||
print(f"\n✓ Success: {message}")
|
|
||||||
if details:
|
|
||||||
print(f"Details: {details}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show success: {e}")
|
|
||||||
|
|
||||||
def show_warning(self, message: str, details: Optional[str] = None) -> None:
|
|
||||||
"""Display a warning message."""
|
|
||||||
try:
|
|
||||||
print(f"\nWarning: {message}")
|
|
||||||
if details:
|
|
||||||
print(f"Details: {details}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show warning: {e}")
|
|
||||||
|
|
||||||
def get_input(self, prompt: str, default: Optional[str] = None) -> str:
|
|
||||||
"""Get user input with optional default value."""
|
|
||||||
try:
|
|
||||||
if default:
|
|
||||||
user_input = input(f"{prompt} [{default}]: ")
|
|
||||||
return user_input if user_input else default
|
|
||||||
return input(f"{prompt}: ")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to get input: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def get_confirmation(self, message: str, default: bool = True) -> bool:
|
|
||||||
"""Get user confirmation for an action."""
|
|
||||||
try:
|
|
||||||
default_str = "Y/n" if default else "y/N"
|
|
||||||
while True:
|
|
||||||
response = input(f"{message} [{default_str}]: ").lower()
|
|
||||||
if not response:
|
|
||||||
return default
|
|
||||||
if response in ['y', 'yes']:
|
|
||||||
return True
|
|
||||||
if response in ['n', 'no']:
|
|
||||||
return False
|
|
||||||
print("Please enter 'y' or 'n'.")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to get confirmation: {e}")
|
|
||||||
return default
|
|
||||||
|
|
||||||
def show_list(self, title: str, items: List[str], selectable: bool = True) -> Optional[str]:
|
|
||||||
"""Display a list of items, optionally selectable."""
|
|
||||||
try:
|
|
||||||
print(f"\n{title}")
|
|
||||||
print("=" * len(title))
|
|
||||||
|
|
||||||
for i, item in enumerate(items, 1):
|
|
||||||
print(f"{i}. {item}")
|
|
||||||
|
|
||||||
if selectable:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
choice = input("\nEnter your choice (or 'q' to quit): ")
|
|
||||||
if choice.lower() == 'q':
|
|
||||||
return None
|
|
||||||
|
|
||||||
choice = int(choice)
|
|
||||||
if 1 <= choice <= len(items):
|
|
||||||
return items[choice - 1]
|
|
||||||
else:
|
|
||||||
print("Invalid choice. Please try again.")
|
|
||||||
except ValueError:
|
|
||||||
print("Please enter a number.")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show list: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def show_table(self, title: str, headers: List[str], rows: List[List[str]]) -> None:
|
|
||||||
"""Display data in a table format."""
|
|
||||||
try:
|
|
||||||
print(f"\n{title}")
|
|
||||||
print("=" * len(title))
|
|
||||||
|
|
||||||
# Calculate column widths
|
|
||||||
widths = [len(h) for h in headers]
|
|
||||||
for row in rows:
|
|
||||||
for i, cell in enumerate(row):
|
|
||||||
widths[i] = max(widths[i], len(str(cell)))
|
|
||||||
|
|
||||||
# Print headers
|
|
||||||
header_str = " | ".join(f"{h:<{w}}" for h, w in zip(headers, widths))
|
|
||||||
print(header_str)
|
|
||||||
print("-" * len(header_str))
|
|
||||||
|
|
||||||
# Print rows
|
|
||||||
for row in rows:
|
|
||||||
print(" | ".join(f"{str(cell):<{w}}" for cell, w in zip(row, widths)))
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show table: {e}")
|
|
||||||
|
|
||||||
def show_help(self, topic: str) -> None:
|
|
||||||
"""Display help information for a topic."""
|
|
||||||
try:
|
|
||||||
print(f"\nHelp: {topic}")
|
|
||||||
print("=" * (len(topic) + 6))
|
|
||||||
print("Help content would be displayed here.")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to show help: {e}")
|
|
||||||
|
|
||||||
def clear_screen(self) -> None:
|
|
||||||
"""Clear the terminal screen."""
|
|
||||||
try:
|
|
||||||
os.system('clear' if os.name == 'posix' else 'cls')
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to clear screen: {e}")
|
|
||||||
@@ -7,7 +7,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import requests
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import time
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
# Import UI Colors first - these should always be available
|
# 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
|
# Import necessary components from other modules
|
||||||
try:
|
try:
|
||||||
@@ -444,56 +444,3 @@ class InstallWabbajackHandler(
|
|||||||
print(f"\nDetailed log available at: {log_path}")
|
print(f"\nDetailed log available at: {log_path}")
|
||||||
print("───────────────────────────────────────────────────────────────────")
|
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 pathlib import Path
|
||||||
from typing import Optional, Tuple
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from .status_utils import clear_status, show_status
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .status_utils import clear_status, show_status
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -222,7 +222,15 @@ class WineUtilsProtonMixin:
|
|||||||
Path("/usr/share/steam/compatibilitytools.d"),
|
Path("/usr/share/steam/compatibilitytools.d"),
|
||||||
Path("/usr/lib/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
|
@staticmethod
|
||||||
def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]:
|
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_ge_proton_versions())
|
||||||
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
|
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
|
||||||
all_versions.extend(WineUtilsProtonMixin.scan_valve_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(
|
all_versions.sort(
|
||||||
key=lambda x: (
|
key=lambda x: (
|
||||||
_TYPE_RANK.get(x.get('type', ''), 0),
|
_type_rank(x),
|
||||||
x.get('major_version', 0),
|
x.get('major_version', 0),
|
||||||
x.get('minor_version', 0),
|
x.get('minor_version', 0),
|
||||||
x.get('priority', 0),
|
x.get('priority', 0),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Canonical game type strings, display names, and normalisation helpers."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Maps Jackify canonical game_type -> human-readable display name
|
||||||
|
GAME_DISPLAY_NAMES: dict[str, str] = {
|
||||||
|
'skyrim': 'Skyrim Special Edition',
|
||||||
|
'fallout4': 'Fallout 4',
|
||||||
|
'falloutnv': 'Fallout New Vegas',
|
||||||
|
'fallout3': 'Fallout 3',
|
||||||
|
'oblivion': 'Oblivion',
|
||||||
|
'oblivion_remastered': 'Oblivion Remastered',
|
||||||
|
'starfield': 'Starfield',
|
||||||
|
'enderal': 'Enderal',
|
||||||
|
'skyrimvr': 'Skyrim VR',
|
||||||
|
'fallout4vr': 'Fallout 4 VR',
|
||||||
|
'bg3': "Baldur's Gate 3",
|
||||||
|
'cp2077': 'Cyberpunk 2077',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maps lowercased human-readable / alternate name -> canonical game_type
|
||||||
|
GAME_NAME_TO_TYPE: dict[str, str] = {
|
||||||
|
'skyrim special edition': 'skyrim',
|
||||||
|
'skyrim': 'skyrim',
|
||||||
|
'skyrimspecialedition': 'skyrim',
|
||||||
|
'fallout 4': 'fallout4',
|
||||||
|
'fallout4': 'fallout4',
|
||||||
|
'fallout new vegas': 'falloutnv',
|
||||||
|
'falloutnv': 'falloutnv',
|
||||||
|
'fallout 3': 'fallout3',
|
||||||
|
'fallout3': 'fallout3',
|
||||||
|
'oblivion': 'oblivion',
|
||||||
|
'oblivion remastered': 'oblivion_remastered',
|
||||||
|
'oblivion_remastered': 'oblivion_remastered',
|
||||||
|
'oblivionremastered': 'oblivion_remastered',
|
||||||
|
'starfield': 'starfield',
|
||||||
|
'enderal': 'enderal',
|
||||||
|
'enderal special edition': 'enderal',
|
||||||
|
'enderalspecialedition': 'enderal',
|
||||||
|
'skyrim vr': 'skyrimvr',
|
||||||
|
'skyrimvr': 'skyrimvr',
|
||||||
|
'fallout 4 vr': 'fallout4vr',
|
||||||
|
'fallout4vr': 'fallout4vr',
|
||||||
|
"baldur's gate 3": 'bg3',
|
||||||
|
'baldursgate3': 'bg3',
|
||||||
|
'bg3': 'bg3',
|
||||||
|
'cyberpunk 2077': 'cp2077',
|
||||||
|
'cyberpunk2077': 'cp2077',
|
||||||
|
'cp2077': 'cp2077',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_game_name(raw: str) -> Optional[str]:
|
||||||
|
"""Return canonical game_type for a raw name, or None if unrecognised."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return GAME_NAME_TO_TYPE.get(raw.lower())
|
||||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from jackify.backend.models.game_types import normalize_game_name
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModlistContext:
|
class ModlistContext:
|
||||||
@@ -39,7 +41,7 @@ class ModlistContext:
|
|||||||
return {
|
return {
|
||||||
'modlist_name': self.name,
|
'modlist_name': self.name,
|
||||||
'install_dir': str(self.install_dir),
|
'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,
|
'game_type': self.game_type,
|
||||||
'nexus_api_key': self.nexus_api_key,
|
'nexus_api_key': self.nexus_api_key,
|
||||||
'modlist_value': self.modlist_value,
|
'modlist_value': self.modlist_value,
|
||||||
@@ -49,14 +51,16 @@ class ModlistContext:
|
|||||||
'skip_confirmation': self.skip_confirmation,
|
'skip_confirmation': self.skip_confirmation,
|
||||||
'engine_installed': self.engine_installed,
|
'engine_installed': self.engine_installed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> 'ModlistContext':
|
def from_dict(cls, data: Dict[str, Any]) -> 'ModlistContext':
|
||||||
"""Create from dictionary for legacy compatibility."""
|
"""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(
|
return cls(
|
||||||
name=data.get('modlist_name', ''),
|
name=data.get('modlist_name', ''),
|
||||||
install_dir=Path(data.get('install_dir', '')),
|
install_dir=Path(data.get('install_dir', '')),
|
||||||
download_dir=Path(data.get('download_dir', '')),
|
download_dir=download_dir,
|
||||||
game_type=data.get('game_type', ''),
|
game_type=data.get('game_type', ''),
|
||||||
nexus_api_key=data.get('nexus_api_key', ''),
|
nexus_api_key=data.get('nexus_api_key', ''),
|
||||||
modlist_value=data.get('modlist_value'),
|
modlist_value=data.get('modlist_value'),
|
||||||
@@ -102,5 +106,42 @@ class ModlistInfo:
|
|||||||
result['status_down'] = self.status_down
|
result['status_down'] = self.status_down
|
||||||
if hasattr(self, 'status_nsfw'):
|
if hasattr(self, 'status_nsfw'):
|
||||||
result['status_nsfw'] = 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:
|
else:
|
||||||
lines.append("Steam: not detected")
|
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 = [
|
proton_scan = [
|
||||||
(native_steam / "steamapps/common", "valve"),
|
(native_steam / "steamapps/common", "valve"),
|
||||||
(flatpak_steam / "data/Steam/steamapps/common", "valve"),
|
(flatpak_steam / "data/Steam/steamapps/common", "valve"),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class DownloadItem:
|
|||||||
status: STATUS = "pending"
|
status: STATUS = "pending"
|
||||||
local_path: Optional[str] = None
|
local_path: Optional[str] = None
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
|
download_reason: Optional[str] = None
|
||||||
needs_user_retry: bool = False
|
needs_user_retry: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -55,6 +56,21 @@ class DownloadItem:
|
|||||||
or evt.get('url')
|
or evt.get('url')
|
||||||
or ''
|
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(
|
item = cls(
|
||||||
file_name=evt.get('file_name', ''),
|
file_name=evt.get('file_name', ''),
|
||||||
nexus_url=source_url,
|
nexus_url=source_url,
|
||||||
@@ -66,6 +82,7 @@ class DownloadItem:
|
|||||||
index=evt.get('index', 0),
|
index=evt.get('index', 0),
|
||||||
total=evt.get('total', 0),
|
total=evt.get('total', 0),
|
||||||
loop_iteration=loop_iteration,
|
loop_iteration=loop_iteration,
|
||||||
|
download_reason=evt.get('reason') or evt.get('download_reason') or None,
|
||||||
)
|
)
|
||||||
if not item.nexus_url:
|
if not item.nexus_url:
|
||||||
# Engine contract says nexus_url should be present and non-empty.
|
# 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 re
|
||||||
import shutil
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -18,6 +17,8 @@ from typing import Callable, Optional, Tuple
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from jackify.backend.services.tool_registry import _find_7z_binary
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,10 +42,14 @@ class MO2SetupService:
|
|||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""Extract the MO2 archive without interactive prompts and honor cancellation."""
|
"""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
|
process = None
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
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,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -102,8 +107,8 @@ class MO2SetupService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not shutil.which('7z'):
|
if not _find_7z_binary():
|
||||||
return False, None, "7z not found. Install p7zip-full (or equivalent) first."
|
return False, None, "7z binary not found (bundled copy missing and no system 7z/7zz on PATH)"
|
||||||
|
|
||||||
if _is_dangerous_path(install_dir):
|
if _is_dangerous_path(install_dir):
|
||||||
return False, None, f"Refusing to install to 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
|
# REMOVED: CACHE_VALIDITY_DAYS - metadata is now always fetched fresh from engine
|
||||||
# Images are still cached indefinitely (managed separately)
|
# 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()
|
_engine_call_lock = threading.Lock()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the gallery service"""
|
"""Initialize the gallery service"""
|
||||||
self.config_handler = ConfigHandler()
|
self.config_handler = ConfigHandler()
|
||||||
|
self._engine_process: Optional[subprocess.Popen] = None
|
||||||
# Cache directories in Jackify Data Directory
|
# Cache directories in Jackify Data Directory
|
||||||
jackify_data_dir = get_jackify_data_dir()
|
jackify_data_dir = get_jackify_data_dir()
|
||||||
self.CACHE_DIR = jackify_data_dir / "modlist-cache" / "metadata"
|
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_cache: Optional[set] = None
|
||||||
self._allowed_tags_lookup: Optional[Dict[str, str]] = 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):
|
def _ensure_cache_dirs(self):
|
||||||
"""Create cache directories if they don't exist"""
|
"""Create cache directories if they don't exist"""
|
||||||
self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
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
|
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||||
clean_env = get_clean_subprocess_env()
|
clean_env = get_clean_subprocess_env()
|
||||||
|
|
||||||
result = subprocess.run(
|
self._engine_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
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:
|
if returncode != 0:
|
||||||
raise RuntimeError(f"jackify-engine failed: {result.stderr}")
|
raise RuntimeError(f"jackify-engine failed: {stderr_data}")
|
||||||
|
|
||||||
# Parse JSON response - skip progress messages and extract JSON
|
# Parse JSON response - skip progress messages and extract JSON
|
||||||
# jackify-engine prints progress to stdout before the 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)
|
# Find the start of JSON (first '{' on its own line)
|
||||||
lines = stdout.split('\n')
|
lines = stdout.split('\n')
|
||||||
|
|||||||
@@ -152,31 +152,20 @@ class ModlistServiceInstallationMixin:
|
|||||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||||
|
|
||||||
writeback_path = str(auth_service.get_token_writeback_path())
|
writeback_path = str(auth_service.get_token_writeback_path())
|
||||||
original_env_values = {
|
env_overrides = {
|
||||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
'JACKIFY_TOKEN_WRITEBACK': writeback_path,
|
||||||
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
|
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': "1",
|
||||||
'JACKIFY_TOKEN_WRITEBACK': os.environ.get('JACKIFY_TOKEN_WRITEBACK'),
|
|
||||||
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
|
|
||||||
}
|
}
|
||||||
|
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:
|
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])
|
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
|
||||||
if output_callback:
|
if output_callback:
|
||||||
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
|
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
|
||||||
@@ -192,7 +181,7 @@ class ModlistServiceInstallationMixin:
|
|||||||
else:
|
else:
|
||||||
output_callback(f"File descriptor limit warning: {message}")
|
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(
|
proc = subprocess.Popen(
|
||||||
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
text=False, env=clean_env, cwd=engine_dir
|
text=False, env=clean_env, cwd=engine_dir
|
||||||
@@ -322,12 +311,12 @@ class ModlistServiceInstallationMixin:
|
|||||||
output_callback("Installation completed successfully")
|
output_callback("Installation completed successfully")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
finally:
|
except Exception as e:
|
||||||
for key, original_value in original_env_values.items():
|
error_msg = f"Error running Jackify Install Engine: {e}"
|
||||||
if original_value is not None:
|
logger.error(error_msg)
|
||||||
os.environ[key] = original_value
|
if output_callback:
|
||||||
elif key in os.environ:
|
output_callback(error_msg)
|
||||||
del os.environ[key]
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error running Jackify Install Engine: {e}"
|
error_msg = f"Error running Jackify Install Engine: {e}"
|
||||||
|
|||||||
@@ -147,6 +147,23 @@ class NexusDownloadService:
|
|||||||
output_path.unlink()
|
output_path.unlink()
|
||||||
return False
|
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(
|
def download_latest_file(
|
||||||
self,
|
self,
|
||||||
game_domain: str,
|
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.
|
Apply tool compatibility settings to the Wine prefix.
|
||||||
|
|
||||||
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK, which is
|
install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK and flips the
|
||||||
required for Synthesis. Intentionally opt-in - the download is ~220MB and
|
prefix to Windows 11, which are required for Synthesis. Intentionally opt-in -
|
||||||
only appropriate when the user explicitly runs Configure Tool Compatibility
|
the download is ~220MB and the win11 flip has not been verified against NSF/CSF
|
||||||
from Additional Tasks.
|
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
|
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
|
||||||
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
|
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
|
||||||
@@ -383,9 +385,14 @@ def apply_tool_config(
|
|||||||
if install_dotnet9_sdk:
|
if install_dotnet9_sdk:
|
||||||
_install_dotnet9_sdk(prefix_path, wine_bin, _log)
|
_install_dotnet9_sdk(prefix_path, wine_bin, _log)
|
||||||
_install_dotnet10_desktop_runtime(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)
|
_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.
|
# Remove legacy global *mscoree=native from DllOverrides if present.
|
||||||
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
|
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
|
||||||
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
|
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ class ToolDefinition:
|
|||||||
tool_id: str
|
tool_id: str
|
||||||
display_name: str
|
display_name: str
|
||||||
description: str
|
description: str
|
||||||
github_repo: str # e.g. "SulfurNitride/CLF3"
|
|
||||||
asset_patterns: List[str] # ordered list of regex patterns to match release asset filename
|
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
|
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)
|
executable_names: List[str] = field(default_factory=list)
|
||||||
pinned_version: Optional[str] = None # None = always use latest
|
pinned_version: Optional[str] = None # None = always use latest
|
||||||
can_uninstall: bool = True # False for tools Jackify hard-depends on
|
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
|
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
|
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
|
@dataclass
|
||||||
class ToolStatus:
|
class ToolStatus:
|
||||||
@@ -107,13 +115,12 @@ TOOL_DEFINITIONS: List[ToolDefinition] = [
|
|||||||
tool_id="radium",
|
tool_id="radium",
|
||||||
display_name="Radium Textures",
|
display_name="Radium Textures",
|
||||||
description="Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.",
|
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"],
|
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,
|
tier=2,
|
||||||
can_launch=True,
|
can_launch=True,
|
||||||
nexus_mod_id=1660,
|
nexus_mod_id=1660,
|
||||||
nexus_file_filter="linux",
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -145,10 +152,40 @@ def set_active_engine_id(tool_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# -- remote manifest ---------------------------------------------------------
|
# -- 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"
|
_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]]:
|
def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]:
|
||||||
definitions = []
|
definitions = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -157,7 +194,7 @@ def _parse_manifest_entries(entries: list) -> Optional[List[ToolDefinition]]:
|
|||||||
tool_id=entry["tool_id"],
|
tool_id=entry["tool_id"],
|
||||||
display_name=entry["display_name"],
|
display_name=entry["display_name"],
|
||||||
description=entry["description"],
|
description=entry["description"],
|
||||||
github_repo=entry["github_repo"],
|
github_repo=entry.get("github_repo"),
|
||||||
asset_patterns=entry["asset_patterns"],
|
asset_patterns=entry["asset_patterns"],
|
||||||
tier=entry.get("tier", 2),
|
tier=entry.get("tier", 2),
|
||||||
executable_names=entry.get("executable_names", []),
|
executable_names=entry.get("executable_names", []),
|
||||||
@@ -199,15 +236,22 @@ def fetch_remote_manifest() -> Optional[List[ToolDefinition]]:
|
|||||||
entries = resp.json()
|
entries = resp.json()
|
||||||
if not isinstance(entries, list):
|
if not isinstance(entries, list):
|
||||||
return None
|
return None
|
||||||
|
_save_disk_cache(entries)
|
||||||
return _parse_manifest_entries(entries)
|
return _parse_manifest_entries(entries)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Tool manifest fetch failed: %s", e)
|
logger.debug("Tool manifest fetch failed: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_effective_definitions() -> List[ToolDefinition]:
|
def get_effective_definitions() -> List[ToolDefinition]:
|
||||||
"""Remote manifest definitions if fetched this session, else baked-in TOOL_DEFINITIONS."""
|
"""Remote manifest definitions if fetched this session, else disk cache, else bundled."""
|
||||||
source = _manifest_cache if _manifest_cache is not None else TOOL_DEFINITIONS
|
if _manifest_cache is not None:
|
||||||
return [d for d in source if not d.hidden]
|
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:
|
def apply_remote_manifest(definitions: List[ToolDefinition]) -> None:
|
||||||
"""Store fetched manifest as session cache and rebuild the tool map."""
|
"""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()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except Exception as e:
|
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 []
|
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}"
|
return False, f"SHA256 verification error: {e}"
|
||||||
|
|
||||||
|
|
||||||
def _extract_archive(file_path: Path, target_dir: Path) -> Tuple[bool, str]:
|
def _find_7z_binary() -> Optional[str]:
|
||||||
"""Extract an archive or chmod an AppImage in place. Removes the archive on success."""
|
"""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()
|
name_lower = file_path.name.lower()
|
||||||
is_archive = False
|
extracted = False
|
||||||
try:
|
try:
|
||||||
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
if name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
||||||
is_archive = True
|
|
||||||
with tarfile.open(file_path, "r:gz") as tf:
|
with tarfile.open(file_path, "r:gz") as tf:
|
||||||
tf.extractall(path=target_dir)
|
tf.extractall(path=target_dir)
|
||||||
|
extracted = True
|
||||||
elif name_lower.endswith(".zip"):
|
elif name_lower.endswith(".zip"):
|
||||||
is_archive = True
|
|
||||||
with zipfile.ZipFile(file_path, "r") as zf:
|
with zipfile.ZipFile(file_path, "r") as zf:
|
||||||
zf.extractall(path=target_dir)
|
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"):
|
elif name_lower.endswith(".appimage"):
|
||||||
file_path.chmod(0o755)
|
file_path.chmod(0o755)
|
||||||
else:
|
else:
|
||||||
return False, f"Unsupported format: {file_path.name}"
|
return False, f"Unsupported format: {file_path.name}"
|
||||||
finally:
|
finally:
|
||||||
if is_archive:
|
if extracted and delete_archive:
|
||||||
try:
|
try:
|
||||||
file_path.unlink(missing_ok=True)
|
file_path.unlink(missing_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if is_archive:
|
if extracted:
|
||||||
_chmod_elf_binaries(target_dir)
|
_chmod_elf_binaries(target_dir)
|
||||||
return True, ""
|
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:
|
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'
|
ELF_MAGIC = b'\x7fELF'
|
||||||
for f in directory.iterdir():
|
for f in directory.rglob("*"):
|
||||||
if not f.is_file():
|
if not f.is_file():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -422,10 +509,20 @@ def _download_and_extract(
|
|||||||
return _extract_archive(temp_path, target_dir)
|
return _extract_archive(temp_path, target_dir)
|
||||||
|
|
||||||
|
|
||||||
def _try_nexus_download(defn: ToolDefinition, target_dir: Path) -> Tuple[bool, Optional[Path], str]:
|
_NEXUS_NOT_ELIGIBLE = "NEXUS_NOT_ELIGIBLE"
|
||||||
"""Attempt Nexus CDN download for premium users. Returns (success, file_path, message)."""
|
|
||||||
|
|
||||||
|
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:
|
if not defn.nexus_mod_id:
|
||||||
return False, None, "No Nexus mod configured"
|
return False, None, _NEXUS_NOT_ELIGIBLE, None
|
||||||
try:
|
try:
|
||||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||||
from jackify.backend.services.nexus_premium_service import NexusPremiumService
|
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()
|
auth = NexusAuthService()
|
||||||
token = auth.get_auth_token()
|
token = auth.get_auth_token()
|
||||||
if not 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_oauth = auth.get_auth_method() == "oauth"
|
||||||
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
|
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
|
||||||
if not is_premium:
|
if not is_premium:
|
||||||
return False, None, "Not Nexus Premium"
|
return False, None, _NEXUS_NOT_ELIGIBLE, None
|
||||||
ok, path, msg = NexusDownloadService(token).download_latest_file(
|
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,
|
defn.nexus_game_domain, defn.nexus_mod_id, target_dir,
|
||||||
file_name_filter=defn.nexus_file_filter,
|
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:
|
except Exception as e:
|
||||||
logger.debug("Nexus download attempt failed for %s: %s", defn.tool_id, e)
|
logger.warning("Nexus download failed for %s: %s", defn.tool_id, e)
|
||||||
return False, None, str(e)
|
return False, None, str(e), None
|
||||||
|
|
||||||
|
|
||||||
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
|
def _find_executable(tool_def: ToolDefinition, search_dir: Path) -> Optional[Path]:
|
||||||
@@ -474,12 +576,32 @@ class ToolRegistry:
|
|||||||
def get_all_statuses(self) -> List[ToolStatus]:
|
def get_all_statuses(self) -> List[ToolStatus]:
|
||||||
return [self._build_status(d) for d in get_effective_definitions()]
|
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]:
|
def check_latest_version(self, tool_id: str) -> Optional[str]:
|
||||||
defn = _TOOL_MAP.get(tool_id)
|
defn = _TOOL_MAP.get(tool_id)
|
||||||
if defn is None:
|
if defn is None:
|
||||||
return None
|
return None
|
||||||
if defn.pinned_version:
|
if defn.pinned_version:
|
||||||
return 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:
|
if defn.include_prereleases:
|
||||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
releases = fetch_release_list(defn.github_repo, max_count=5)
|
||||||
if releases:
|
if releases:
|
||||||
@@ -504,10 +626,20 @@ class ToolRegistry:
|
|||||||
install_dir.mkdir(parents=True, exist_ok=True)
|
install_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
pin = version or defn.pinned_version
|
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:
|
if nexus_ok and nexus_path:
|
||||||
ok, err = _extract_archive(nexus_path, install_dir)
|
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:
|
else:
|
||||||
if defn.include_prereleases and not pin:
|
if defn.include_prereleases and not pin:
|
||||||
releases = fetch_release_list(defn.github_repo, max_count=5)
|
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)
|
logger.info("Installed %s %s", defn.display_name, tag)
|
||||||
return True, f"{defn.display_name} {tag} installed"
|
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]:
|
def update(self, tool_id: str) -> Tuple[bool, str]:
|
||||||
defn = _TOOL_MAP.get(tool_id)
|
defn = _TOOL_MAP.get(tool_id)
|
||||||
if defn is None:
|
if defn is None:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"tool_id": "jackify-engine",
|
"tool_id": "jackify-engine",
|
||||||
"display_name": "jackify-engine",
|
"display_name": "jackify-engine",
|
||||||
"description": "Native Wabbajack-matched file handler. The proven, stable engine for modlist installs.",
|
"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"],
|
"asset_patterns": ["jackify-engine.*linux.*x64.*\\.tar\\.gz", "jackify-engine.*\\.tar\\.gz", "jackify-engine.*\\.zip"],
|
||||||
"executable_names": ["jackify-engine"],
|
"executable_names": ["jackify-engine"],
|
||||||
"tier": 1,
|
"tier": 1,
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"tool_id": "radium",
|
"tool_id": "radium",
|
||||||
"display_name": "Radium Textures",
|
"display_name": "Radium Textures",
|
||||||
"description": "Rust alternative to VRAMr for Skyrim and Fallout 4 texture optimisation. Run directly against mod files.",
|
"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"],
|
"asset_patterns": ["radium.*linux.*x86_64", "radium.*\\.tar\\.gz", "radium.*\\.zip"],
|
||||||
"executable_names": ["radium", "radium-textures"],
|
"executable_names": ["radium", "radium-textures"],
|
||||||
"tier": 2,
|
"tier": 2,
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"can_launch": true,
|
"can_launch": true,
|
||||||
"nexus_mod_id": 1660,
|
"nexus_mod_id": 1660,
|
||||||
"nexus_game_domain": "site",
|
"nexus_game_domain": "site",
|
||||||
"nexus_file_filter": "linux",
|
"nexus_file_filter": null,
|
||||||
"hidden": true
|
"hidden": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Update-vs-new installation detection service.
|
||||||
|
|
||||||
|
Free functions used by both GUI (install_modlist_workflow.py) and CLI
|
||||||
|
(modlist_operations.py) to avoid duplicated logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_version_token(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Return a normalised version token for equality checks."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
token = str(value).strip()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return token.lstrip("vV").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_modlist_name(value: Optional[str]) -> str:
|
||||||
|
"""Return a case/whitespace-normalised modlist name for comparison."""
|
||||||
|
return " ".join((value or "").strip().lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_update_candidate(
|
||||||
|
modlist_name: str,
|
||||||
|
install_dir: str,
|
||||||
|
existing_appid: Optional[str],
|
||||||
|
requested_version: Optional[str] = None,
|
||||||
|
) -> tuple[bool, dict]:
|
||||||
|
"""Decide whether update-mode should be offered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
modlist_name: Name of the modlist being installed.
|
||||||
|
install_dir: Resolved installation directory path.
|
||||||
|
existing_appid: Steam AppID from an existing shortcut (None if not found).
|
||||||
|
requested_version: Pre-computed normalised version from the selected modlist
|
||||||
|
(pass None when unavailable, e.g. offline/file-based installs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(eligible, result_dict) where eligible is True when update mode is safe to offer.
|
||||||
|
"""
|
||||||
|
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"eligible": False,
|
||||||
|
"reason": "unknown",
|
||||||
|
"requested_version": None,
|
||||||
|
"installed_version": None,
|
||||||
|
"version_relation": "unknown",
|
||||||
|
"installed_name": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not existing_appid:
|
||||||
|
result["reason"] = "missing_shortcut_appid"
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
meta = read_modlist_meta(install_dir)
|
||||||
|
if not meta:
|
||||||
|
result["reason"] = "missing_meta"
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
installed_name = (meta.get("modlist_name") or "").strip()
|
||||||
|
result["installed_name"] = installed_name
|
||||||
|
|
||||||
|
if normalize_modlist_name(installed_name) != normalize_modlist_name(modlist_name):
|
||||||
|
result["reason"] = "modlist_name_mismatch"
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
installed_version = normalize_version_token(meta.get("modlist_version"))
|
||||||
|
result["requested_version"] = requested_version
|
||||||
|
result["installed_version"] = installed_version
|
||||||
|
|
||||||
|
if requested_version and installed_version:
|
||||||
|
result["version_relation"] = (
|
||||||
|
"same" if requested_version == installed_version else "different"
|
||||||
|
)
|
||||||
|
|
||||||
|
result["eligible"] = True
|
||||||
|
result["reason"] = "eligible"
|
||||||
|
return True, result
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_shortcut_appid(modlist_name: str, install_dir: str) -> Optional[str]:
|
||||||
|
"""Return the Steam AppID of an existing shortcut for this install, or None."""
|
||||||
|
try:
|
||||||
|
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||||
|
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||||
|
|
||||||
|
platform_service = PlatformDetectionService.get_instance()
|
||||||
|
shortcut_handler = ShortcutHandler(
|
||||||
|
steamdeck=platform_service.is_steamdeck, verbose=False
|
||||||
|
)
|
||||||
|
|
||||||
|
install_real = os.path.realpath(install_dir)
|
||||||
|
candidate_exes = [
|
||||||
|
os.path.join(install_real, "ModOrganizer.exe"),
|
||||||
|
os.path.join(install_real, "files", "ModOrganizer.exe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for exe_path in candidate_exes:
|
||||||
|
if not os.path.exists(exe_path):
|
||||||
|
continue
|
||||||
|
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
||||||
|
if appid:
|
||||||
|
return appid
|
||||||
|
|
||||||
|
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||||
|
if (
|
||||||
|
(shortcut.get("AppName", "").strip() == modlist_name.strip())
|
||||||
|
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
|
||||||
|
):
|
||||||
|
raw_appid = shortcut.get("appid")
|
||||||
|
if raw_appid is not None:
|
||||||
|
return str(int(raw_appid) & 0xFFFFFFFF)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Update detection: failed shortcut lookup: %s", e)
|
||||||
|
return None
|
||||||
@@ -12,6 +12,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
import requests
|
import requests
|
||||||
@@ -52,25 +53,61 @@ class UpdateService:
|
|||||||
self.github_api_base = "https://api.github.com"
|
self.github_api_base = "https://api.github.com"
|
||||||
self.update_check_timeout = 10 # seconds
|
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]:
|
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.
|
last_check = self._get_last_check_timestamp()
|
||||||
|
if last_check is not None:
|
||||||
Returns:
|
age_hours = (datetime.now(timezone.utc) - last_check).total_seconds() / 3600
|
||||||
UpdateInfo if update available, None otherwise
|
if age_hours < self._UPDATE_CHECK_CACHE_HOURS:
|
||||||
"""
|
logger.debug("Skipping update check - last check was %.1f hours ago", age_hours)
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
url = f"{self.github_api_base}/repos/{self.github_repo}/releases/latest"
|
url = f"{self.github_api_base}/repos/{self.github_repo}/releases/latest"
|
||||||
headers = {
|
headers = {
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
'User-Agent': f'Jackify/{self.current_version}'
|
'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}")
|
logger.debug(f"Checking for updates at {url}")
|
||||||
response = requests.get(url, headers=headers, timeout=self.update_check_timeout)
|
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()
|
response.raise_for_status()
|
||||||
|
|
||||||
release_data = response.json()
|
release_data = response.json()
|
||||||
|
self._save_last_check_timestamp()
|
||||||
latest_version = release_data['tag_name'].lstrip('v')
|
latest_version = release_data['tag_name'].lstrip('v')
|
||||||
|
|
||||||
if self._is_newer_version(latest_version):
|
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 ..handlers.subprocess_utils import get_clean_subprocess_env
|
||||||
from .nexus_download_service import NexusDownloadService
|
from .nexus_download_service import NexusDownloadService
|
||||||
from .nexus_auth_service import NexusAuthService
|
from .nexus_auth_service import NexusAuthService
|
||||||
|
from .tool_registry import _find_7z_binary
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -140,8 +141,11 @@ class VNVPostInstallService:
|
|||||||
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
|
||||||
zip_ref.extractall(extract_dir)
|
zip_ref.extractall(extract_dir)
|
||||||
elif suffix == ".7z":
|
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(
|
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,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Machine-tied AES-GCM encrypt/decrypt helpers.
|
||||||
|
|
||||||
|
Key derivation: sha256(hostname:username:machine-id:jackify) -> urlsafe_b64encode.
|
||||||
|
decrypt() returns None on ANY failure - never returns garbage or plaintext leakage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_key() -> bytes:
|
||||||
|
"""Return the machine-tied 32-byte AES key (base64-encoded for AES-GCM use)."""
|
||||||
|
import socket
|
||||||
|
import getpass
|
||||||
|
try:
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
username = getpass.getuser()
|
||||||
|
machine_id = None
|
||||||
|
for id_path in ('/etc/machine-id', '/var/lib/dbus/machine-id'):
|
||||||
|
try:
|
||||||
|
with open(id_path, 'r') as f:
|
||||||
|
machine_id = f.read().strip()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
key_material = (
|
||||||
|
f"{hostname}:{username}:{machine_id}:jackify"
|
||||||
|
if machine_id
|
||||||
|
else f"{hostname}:{username}:jackify"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to get machine info for encryption key: %s", e)
|
||||||
|
key_material = "jackify:default:key"
|
||||||
|
return base64.urlsafe_b64encode(hashlib.sha256(key_material.encode('utf-8')).digest())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext: str) -> str:
|
||||||
|
"""Encrypt plaintext with AES-GCM. Returns "" if pycryptodomex is unavailable."""
|
||||||
|
try:
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
from Cryptodome.Random import get_random_bytes
|
||||||
|
key = base64.urlsafe_b64decode(get_machine_key())
|
||||||
|
nonce = get_random_bytes(12)
|
||||||
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||||
|
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
|
||||||
|
combined = nonce + ciphertext + tag
|
||||||
|
return base64.b64encode(combined).decode('utf-8')
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pycryptodomex not available - encryption disabled")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Encryption failed: %s", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(ciphertext: str) -> Optional[str]:
|
||||||
|
"""Decrypt ciphertext produced by encrypt(). Returns None on ANY failure."""
|
||||||
|
try:
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
key = base64.urlsafe_b64decode(get_machine_key())
|
||||||
|
combined = base64.b64decode(ciphertext.encode('utf-8'))
|
||||||
|
if len(combined) < 28:
|
||||||
|
return None
|
||||||
|
nonce = combined[:12]
|
||||||
|
tag = combined[-16:]
|
||||||
|
data = combined[12:-16]
|
||||||
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||||
|
plaintext = cipher.decrypt_and_verify(data, tag)
|
||||||
|
return plaintext.decode('utf-8')
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pycryptodomex not available - cannot decrypt")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -38,7 +38,7 @@ class AdditionalMenuHandler:
|
|||||||
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2")
|
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2")
|
||||||
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
|
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
|
||||||
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
|
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
|
||||||
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton — not needed for standard modlist installs{COLOR_RESET}")
|
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton - not needed for standard modlist installs{COLOR_RESET}")
|
||||||
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle")
|
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle")
|
||||||
print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}")
|
print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}")
|
||||||
print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization")
|
print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization")
|
||||||
|
|||||||
@@ -339,8 +339,9 @@ class ManualDownloadDialog(QDialog):
|
|||||||
colour = _STATUS_COLOURS.get(item.status, '#808080')
|
colour = _STATUS_COLOURS.get(item.status, '#808080')
|
||||||
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
|
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
|
||||||
status_cell.setForeground(QColor(colour))
|
status_cell.setForeground(QColor(colour))
|
||||||
if item.error_message:
|
tooltip_parts = [p for p in (item.download_reason, item.error_message) if p]
|
||||||
status_cell.setToolTip(item.error_message)
|
if tooltip_parts:
|
||||||
|
status_cell.setToolTip("\n".join(tooltip_parts))
|
||||||
self._table.setItem(row, _COL_STATUS, status_cell)
|
self._table.setItem(row, _COL_STATUS, status_cell)
|
||||||
|
|
||||||
def _update_row(self, row: int, item: DownloadItem) -> None:
|
def _update_row(self, row: int, item: DownloadItem) -> None:
|
||||||
@@ -349,7 +350,8 @@ class ManualDownloadDialog(QDialog):
|
|||||||
if status_cell:
|
if status_cell:
|
||||||
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
|
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
|
||||||
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
|
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
|
||||||
status_cell.setToolTip(item.error_message or "")
|
tooltip_parts = [p for p in (item.download_reason, item.error_message) if p]
|
||||||
|
status_cell.setToolTip("\n".join(tooltip_parts))
|
||||||
|
|
||||||
def _rebuild_row_map(self) -> None:
|
def _rebuild_row_map(self) -> None:
|
||||||
self._row_map.clear()
|
self._row_map.clear()
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Guided dialog for installing a Nexus-only tool via manual browser download."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
|
QLineEdit, QFileDialog, QFrame,
|
||||||
|
)
|
||||||
|
|
||||||
|
from jackify.frontends.gui.services.message_service import open_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NexusManualInstallDialog(QDialog):
|
||||||
|
"""
|
||||||
|
Guides the user through manually downloading a Nexus-only tool and handing
|
||||||
|
the archive to Jackify for extraction and installation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tool_id: str, display_name: str, nexus_url: str, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._tool_id = tool_id
|
||||||
|
self._nexus_url = nexus_url
|
||||||
|
self._archive_path: Optional[Path] = None
|
||||||
|
|
||||||
|
self.setWindowTitle(f"Install {display_name}")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMinimumWidth(480)
|
||||||
|
self.setStyleSheet("QDialog { background: #181818; color: #fff; }")
|
||||||
|
self._build_ui(display_name)
|
||||||
|
self.adjustSize()
|
||||||
|
|
||||||
|
def _build_ui(self, display_name: str) -> None:
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
main_layout.setSpacing(0)
|
||||||
|
main_layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
|
||||||
|
card = QFrame(self)
|
||||||
|
card.setObjectName("dialogCard")
|
||||||
|
card.setFrameShape(QFrame.StyledPanel)
|
||||||
|
card.setFrameShadow(QFrame.Raised)
|
||||||
|
card.setStyleSheet(
|
||||||
|
"QFrame#dialogCard { "
|
||||||
|
" background: #2d2d2d; "
|
||||||
|
" border-radius: 12px; "
|
||||||
|
" border: 1px solid #555; "
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
card_layout = QVBoxLayout(card)
|
||||||
|
card_layout.setSpacing(16)
|
||||||
|
card_layout.setContentsMargins(28, 28, 28, 28)
|
||||||
|
|
||||||
|
title_label = QLabel(f"Manual download required: {display_name}")
|
||||||
|
title_label.setStyleSheet("color: #3fd0ea; font-size: 14px; font-weight: 600;")
|
||||||
|
title_label.setWordWrap(True)
|
||||||
|
card_layout.addWidget(title_label)
|
||||||
|
|
||||||
|
body_label = QLabel(
|
||||||
|
f"{display_name} is only available on Nexus Mods. As you do not have Nexus "
|
||||||
|
"Premium, please perform the following steps manually:\n\n"
|
||||||
|
f"1. Click 'Open Nexus Page' below and click Manual Download on the Nexus page "
|
||||||
|
f"to download {display_name}\n"
|
||||||
|
"2. Once the download is complete, click 'Browse...' below and select the "
|
||||||
|
"downloaded archive\n"
|
||||||
|
"3. Click Install to complete the installation."
|
||||||
|
)
|
||||||
|
body_label.setWordWrap(True)
|
||||||
|
card_layout.addWidget(body_label)
|
||||||
|
|
||||||
|
nexus_btn = QPushButton("Open Nexus Page")
|
||||||
|
nexus_btn.clicked.connect(self._open_nexus)
|
||||||
|
card_layout.addWidget(nexus_btn)
|
||||||
|
|
||||||
|
file_row = QHBoxLayout()
|
||||||
|
self._file_edit = QLineEdit()
|
||||||
|
self._file_edit.setPlaceholderText("No file selected...")
|
||||||
|
self._file_edit.setReadOnly(True)
|
||||||
|
self._file_edit.setStyleSheet(
|
||||||
|
"QLineEdit { "
|
||||||
|
" background: #1a1a1a; "
|
||||||
|
" color: #fff; "
|
||||||
|
" border: 1px solid #555; "
|
||||||
|
" border-radius: 4px; "
|
||||||
|
" padding: 8px; "
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
file_row.addWidget(self._file_edit)
|
||||||
|
|
||||||
|
browse_btn = QPushButton("Browse...")
|
||||||
|
browse_btn.setMinimumWidth(90)
|
||||||
|
browse_btn.clicked.connect(self._browse)
|
||||||
|
file_row.addWidget(browse_btn)
|
||||||
|
card_layout.addLayout(file_row)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch()
|
||||||
|
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
cancel_btn.setMinimumWidth(100)
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_row.addWidget(cancel_btn)
|
||||||
|
|
||||||
|
self._install_btn = QPushButton("Install")
|
||||||
|
self._install_btn.setDefault(True)
|
||||||
|
self._install_btn.setMinimumWidth(100)
|
||||||
|
self._install_btn.setEnabled(False)
|
||||||
|
self._install_btn.clicked.connect(self.accept)
|
||||||
|
btn_row.addWidget(self._install_btn)
|
||||||
|
|
||||||
|
card_layout.addLayout(btn_row)
|
||||||
|
main_layout.addWidget(card)
|
||||||
|
|
||||||
|
def _open_nexus(self) -> None:
|
||||||
|
if self._nexus_url:
|
||||||
|
open_url(self._nexus_url)
|
||||||
|
|
||||||
|
def _browse(self) -> None:
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Select downloaded archive",
|
||||||
|
str(Path.home() / "Downloads"),
|
||||||
|
"Archives (*.zip *.tar.gz *.tar.xz *.7z);;All files (*)",
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self._archive_path = Path(path)
|
||||||
|
self._file_edit.setText(path)
|
||||||
|
self._install_btn.setEnabled(True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_archive(self) -> Optional[Path]:
|
||||||
|
return self._archive_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tool_id(self) -> str:
|
||||||
|
return self._tool_id
|
||||||
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from jackify.backend.services.nxm_url import NxmUrl
|
from jackify.backend.services.nxm_url import NxmUrl
|
||||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||||
|
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||||
import jackify.backend.services.nxm_session as nxm_session
|
import jackify.backend.services.nxm_session as nxm_session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -117,7 +118,7 @@ class _DownloadThread(QThread):
|
|||||||
self.progress.emit(downloaded, total)
|
self.progress.emit(downloaded, total)
|
||||||
|
|
||||||
|
|
||||||
class NxmDownloadDialog(QDialog):
|
class NxmDownloadDialog(ThreadLifecycleMixin, QDialog):
|
||||||
"""Modlist picker and download runner for incoming nxm:// links.
|
"""Modlist picker and download runner for incoming nxm:// links.
|
||||||
|
|
||||||
When auto_start_modlist is provided the picker is hidden and the download
|
When auto_start_modlist is provided the picker is hidden and the download
|
||||||
|
|||||||
@@ -72,9 +72,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
|||||||
main_layout.addLayout(btn_layout)
|
main_layout.addLayout(btn_layout)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] Exception in SettingsDialog.__init__: {e}")
|
logger.error(f"Exception in SettingsDialog.__init__: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
def _toggle_api_key_visibility(self, checked):
|
def _toggle_api_key_visibility(self, checked):
|
||||||
eye_icon = QIcon.fromTheme("view-visible")
|
eye_icon = QIcon.fromTheme("view-visible")
|
||||||
@@ -402,7 +400,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
|||||||
screen.refresh_paths()
|
screen.refresh_paths()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not refresh GUI paths: {e}")
|
logger.warning(f"Could not refresh GUI paths: {e}")
|
||||||
|
|
||||||
def _bold_label(self, text):
|
def _bold_label(self, text):
|
||||||
label = QLabel(text)
|
label = QLabel(text)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from PySide6.QtWidgets import (
|
|||||||
from PySide6.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from PySide6.QtGui import QPixmap, QIcon, QFont
|
from PySide6.QtGui import QPixmap, QIcon, QFont
|
||||||
|
|
||||||
|
from jackify.frontends.gui.services.message_service import open_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ class SuccessDialog(QDialog):
|
|||||||
time_taken: str,
|
time_taken: str,
|
||||||
game_name: str = None,
|
game_name: str = None,
|
||||||
verification_results=None,
|
verification_results=None,
|
||||||
|
disabled_problem_mods=None,
|
||||||
|
readme_url: str = None,
|
||||||
parent=None,
|
parent=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -46,6 +50,8 @@ class SuccessDialog(QDialog):
|
|||||||
self.time_taken = time_taken
|
self.time_taken = time_taken
|
||||||
self.game_name = game_name
|
self.game_name = game_name
|
||||||
self.verification_results = verification_results
|
self.verification_results = verification_results
|
||||||
|
self.disabled_problem_mods = disabled_problem_mods or []
|
||||||
|
self.readme_url = readme_url
|
||||||
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
|
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
|
||||||
self.setWindowModality(Qt.NonModal)
|
self.setWindowModality(Qt.NonModal)
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||||
@@ -180,6 +186,25 @@ class SuccessDialog(QDialog):
|
|||||||
if self.verification_results is not None:
|
if self.verification_results is not None:
|
||||||
self._add_verification_section(card_layout)
|
self._add_verification_section(card_layout)
|
||||||
|
|
||||||
|
# Problem mods that were auto-disabled
|
||||||
|
if self.disabled_problem_mods:
|
||||||
|
self._add_problem_mods_section(card_layout)
|
||||||
|
|
||||||
|
# Readme link (install workflow only)
|
||||||
|
if self.readme_url:
|
||||||
|
readme_label = QLabel(
|
||||||
|
f'<a href="{self.readme_url}" style="color:#3fd0ea; text-decoration:none;">'
|
||||||
|
"Open modlist readme"
|
||||||
|
"</a>"
|
||||||
|
)
|
||||||
|
readme_label.setAlignment(Qt.AlignCenter)
|
||||||
|
readme_label.setStyleSheet(
|
||||||
|
"QLabel { color: #3fd0ea; font-size: 11px; margin-top: 4px; padding: 4px; background-color: transparent; }"
|
||||||
|
)
|
||||||
|
readme_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
|
readme_label.linkActivated.connect(open_url)
|
||||||
|
card_layout.addWidget(readme_label)
|
||||||
|
|
||||||
# Subtle Ko-Fi support link
|
# Subtle Ko-Fi support link
|
||||||
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#3fd0ea; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
|
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#3fd0ea; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
|
||||||
kofi_label.setAlignment(Qt.AlignCenter)
|
kofi_label.setAlignment(Qt.AlignCenter)
|
||||||
@@ -193,7 +218,7 @@ class SuccessDialog(QDialog):
|
|||||||
"}"
|
"}"
|
||||||
)
|
)
|
||||||
kofi_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
kofi_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
kofi_label.setOpenExternalLinks(True)
|
kofi_label.linkActivated.connect(open_url)
|
||||||
card_layout.addWidget(kofi_label)
|
card_layout.addWidget(kofi_label)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
@@ -390,6 +415,29 @@ class SuccessDialog(QDialog):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Could not open verification dialog: %s", exc)
|
logger.error("Could not open verification dialog: %s", exc)
|
||||||
|
|
||||||
|
def _add_problem_mods_section(self, card_layout):
|
||||||
|
"""Add an auto-disabled problem mods section to the card layout."""
|
||||||
|
from PySide6.QtWidgets import QFrame
|
||||||
|
|
||||||
|
sep = QFrame()
|
||||||
|
sep.setFrameShape(QFrame.HLine)
|
||||||
|
sep.setStyleSheet("color: #444;")
|
||||||
|
card_layout.addWidget(sep)
|
||||||
|
|
||||||
|
header = QLabel("Compatibility Notice")
|
||||||
|
header.setStyleSheet("font-size: 12px; font-weight: bold; color: #c8a050; margin-top: 4px;")
|
||||||
|
card_layout.addWidget(header)
|
||||||
|
|
||||||
|
msg_text = (
|
||||||
|
"Due to known compatibility issues with Proton, the following mods were "
|
||||||
|
"automatically disabled:\n\n"
|
||||||
|
+ "\n".join(f" - {name}" for name in self.disabled_problem_mods)
|
||||||
|
)
|
||||||
|
msg_label = QLabel(msg_text)
|
||||||
|
msg_label.setWordWrap(True)
|
||||||
|
msg_label.setStyleSheet("font-size: 11px; color: #bbb; margin-bottom: 4px;")
|
||||||
|
card_layout.addWidget(msg_label)
|
||||||
|
|
||||||
def _update_countdown(self):
|
def _update_countdown(self):
|
||||||
if self._countdown > 0:
|
if self._countdown > 0:
|
||||||
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
|
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
|
||||||
|
|||||||
@@ -310,7 +310,12 @@ def main(initial_nxm_url: str = ""):
|
|||||||
|
|
||||||
# Global cleanup function for signal handling
|
# Global cleanup function for signal handling
|
||||||
def emergency_cleanup():
|
def emergency_cleanup():
|
||||||
logger.debug("Cleanup: terminating jackify-engine processes")
|
logger.debug("Cleanup: draining QThreads and terminating jackify-engine processes")
|
||||||
|
try:
|
||||||
|
from jackify.frontends.gui.mixins.thread_registry import drain_all_threads
|
||||||
|
drain_all_threads(timeout_ms=5000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
import subprocess
|
import subprocess
|
||||||
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
||||||
@@ -389,6 +394,7 @@ def main(initial_nxm_url: str = ""):
|
|||||||
# Start background update check after window is shown
|
# Start background update check after window is shown
|
||||||
window._check_for_updates_on_startup()
|
window._check_for_updates_on_startup()
|
||||||
window._check_tool_updates_on_startup()
|
window._check_tool_updates_on_startup()
|
||||||
|
window._prefetch_manifests_on_startup()
|
||||||
|
|
||||||
if initial_nxm_url:
|
if initial_nxm_url:
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
@@ -397,7 +403,12 @@ def main(initial_nxm_url: str = ""):
|
|||||||
# Ensure cleanup on exit
|
# Ensure cleanup on exit
|
||||||
import atexit
|
import atexit
|
||||||
atexit.register(emergency_cleanup)
|
atexit.register(emergency_cleanup)
|
||||||
|
try:
|
||||||
|
from jackify.frontends.gui.mixins.thread_registry import drain_all_threads
|
||||||
|
app.aboutToQuit.connect(lambda: drain_all_threads(timeout_ms=8000))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ class MainWindowBackendMixin:
|
|||||||
if status['target_achieved']:
|
if status['target_achieved']:
|
||||||
logger.debug(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
|
logger.debug(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
|
||||||
else:
|
else:
|
||||||
print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
|
logger.info(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
|
||||||
else:
|
else:
|
||||||
status = resource_manager.get_limit_status()
|
status = resource_manager.get_limit_status()
|
||||||
print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
|
logger.warning(f"Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
|
||||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||||
config_handler = ConfigHandler()
|
config_handler = ConfigHandler()
|
||||||
if config_handler.get('debug_mode', False):
|
if config_handler.get('debug_mode', False):
|
||||||
instructions = resource_manager.get_manual_increase_instructions()
|
instructions = resource_manager.get_manual_increase_instructions()
|
||||||
print(f"Manual increase instructions available for {instructions['distribution']}")
|
logger.debug(f"Manual increase instructions available for {instructions['distribution']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Error applying resource limits: {e}")
|
logger.warning(f"Error applying resource limits: {e}")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Settings, About, open URL, cleanup_processes, closeEvent.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import warnings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,15 +26,19 @@ class MainWindowDialogsMixin:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Disconnect all signals before stopping to prevent callbacks to a dying widget.
|
# Disconnect all signals before stopping to prevent callbacks to a dying widget.
|
||||||
try:
|
# disconnect() with no receivers connected raises via Python's warnings module
|
||||||
thread.finished.disconnect()
|
# (not as a catchable exception), so it must be suppressed rather than try/excepted.
|
||||||
except Exception:
|
with warnings.catch_warnings():
|
||||||
pass
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"):
|
|
||||||
try:
|
try:
|
||||||
getattr(thread, _sig).disconnect()
|
thread.finished.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
for _sig in ("update_available", "no_update", "check_failed", "cache_ready", "progress_update"):
|
||||||
|
try:
|
||||||
|
getattr(thread, _sig).disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
thread.requestInterruption()
|
thread.requestInterruption()
|
||||||
@@ -78,9 +83,7 @@ class MainWindowDialogsMixin:
|
|||||||
dlg.finished.connect(on_dialog_finished)
|
dlg.finished.connect(on_dialog_finished)
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] Exception in open_settings_dialog: {e}")
|
logger.error(f"Exception in open_settings_dialog: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
self._settings_dialog = None
|
self._settings_dialog = None
|
||||||
|
|
||||||
def open_about_dialog(self):
|
def open_about_dialog(self):
|
||||||
@@ -104,9 +107,7 @@ class MainWindowDialogsMixin:
|
|||||||
dlg.finished.connect(on_dialog_finished)
|
dlg.finished.connect(on_dialog_finished)
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] Exception in open_about_dialog: {e}")
|
logger.error(f"Exception in open_about_dialog: {e}", exc_info=True)
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
self._about_dialog = None
|
self._about_dialog = None
|
||||||
|
|
||||||
def _open_url(self, url: str):
|
def _open_url(self, url: str):
|
||||||
@@ -186,15 +187,12 @@ class MainWindowDialogsMixin:
|
|||||||
screen.cleanup_processes()
|
screen.cleanup_processes()
|
||||||
elif hasattr(screen, 'cleanup'):
|
elif hasattr(screen, 'cleanup'):
|
||||||
screen.cleanup()
|
screen.cleanup()
|
||||||
elif hasattr(screen, 'worker'):
|
|
||||||
worker = getattr(screen, 'worker', None)
|
|
||||||
setattr(screen, 'worker', self._stop_qthread(worker, f"{screen.__class__.__name__}.worker"))
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during cleanup: {e}")
|
logger.error(f"Error during cleanup: {e}")
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self._save_geometry_on_quit()
|
self._save_geometry_on_quit()
|
||||||
|
|||||||
@@ -53,17 +53,17 @@ class MainWindowStartupMixin:
|
|||||||
return
|
return
|
||||||
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
|
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
|
||||||
if not is_installed:
|
if not is_installed:
|
||||||
print(f"Protontricks not found: {details}")
|
logger.warning(f"Protontricks not found: {details}")
|
||||||
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
|
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
|
||||||
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
|
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
|
||||||
result = dialog.exec()
|
result = dialog.exec()
|
||||||
if result == QDialog.Rejected:
|
if result == QDialog.Rejected:
|
||||||
print("User chose to exit due to missing protontricks")
|
logger.info("User chose to exit due to missing protontricks")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Protontricks detected: {details}")
|
logger.debug(f"Protontricks detected: {details}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking protontricks: {e}")
|
logger.error(f"Error checking protontricks: {e}")
|
||||||
|
|
||||||
def _check_tool_updates_on_startup(self):
|
def _check_tool_updates_on_startup(self):
|
||||||
class _ToolUpdateCheckThread(QThread):
|
class _ToolUpdateCheckThread(QThread):
|
||||||
@@ -106,6 +106,40 @@ class MainWindowStartupMixin:
|
|||||||
self._tool_update_check_thread.updates_found.connect(on_result)
|
self._tool_update_check_thread.updates_found.connect(on_result)
|
||||||
self._tool_update_check_thread.start()
|
self._tool_update_check_thread.start()
|
||||||
|
|
||||||
|
def _prefetch_manifests_on_startup(self):
|
||||||
|
class _ManifestPrefetchThread(QThread):
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
from jackify.backend.services.tool_registry import (
|
||||||
|
fetch_remote_manifest as fetch_tools,
|
||||||
|
apply_remote_manifest as apply_tools,
|
||||||
|
)
|
||||||
|
tools = fetch_tools()
|
||||||
|
if tools:
|
||||||
|
apply_tools(tools)
|
||||||
|
logger.info("Tools manifest refreshed at startup (%d tools)", len(tools))
|
||||||
|
else:
|
||||||
|
logger.info("Tools manifest prefetch returned no data (bundled manifest in use)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Tools manifest prefetch failed: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jackify.backend.services.problem_mods_service import (
|
||||||
|
fetch_remote_manifest as fetch_problems,
|
||||||
|
apply_remote_manifest as apply_problems,
|
||||||
|
)
|
||||||
|
problems = fetch_problems()
|
||||||
|
if problems:
|
||||||
|
apply_problems(problems)
|
||||||
|
logger.info("Problem mods manifest refreshed at startup")
|
||||||
|
else:
|
||||||
|
logger.info("Problem mods manifest prefetch returned no data (bundled manifest in use)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("Problem mods manifest prefetch failed: %s", e)
|
||||||
|
|
||||||
|
self._manifest_prefetch_thread = _ManifestPrefetchThread()
|
||||||
|
self._manifest_prefetch_thread.start()
|
||||||
|
|
||||||
def _check_for_updates_on_startup(self):
|
def _check_for_updates_on_startup(self):
|
||||||
try:
|
try:
|
||||||
logger.debug("Checking for updates on startup...")
|
logger.debug("Checking for updates on startup...")
|
||||||
|
|||||||
@@ -72,30 +72,43 @@ class MainWindowUIMixin:
|
|||||||
bottom_bar_style += " border: 2px solid lime;"
|
bottom_bar_style += " border: 2px solid lime;"
|
||||||
bottom_bar.setStyleSheet(bottom_bar_style)
|
bottom_bar.setStyleSheet(bottom_bar_style)
|
||||||
|
|
||||||
|
# Three-zone layout (left / center / right) with equal stretch factors on the
|
||||||
|
# outer zones, so the center zone (Ko-fi) stays visually centered on the bar
|
||||||
|
# regardless of how wide the version label or Settings/About block are.
|
||||||
|
left_zone = QWidget()
|
||||||
|
left_zone_layout = QHBoxLayout(left_zone)
|
||||||
|
left_zone_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
version_label = QLabel(f"Jackify v{__version__}")
|
version_label = QLabel(f"Jackify v{__version__}")
|
||||||
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
|
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
|
||||||
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
|
left_zone_layout.addWidget(version_label, alignment=Qt.AlignLeft)
|
||||||
bottom_bar_layout.addStretch(1)
|
left_zone_layout.addStretch(1)
|
||||||
|
bottom_bar_layout.addWidget(left_zone, 1)
|
||||||
|
|
||||||
kofi_link = QLabel('<a href="#" style="color:#3fd0ea; text-decoration:none;">Support on Ko-fi</a>')
|
kofi_link = QLabel('<a href="#" style="color:#3fd0ea; text-decoration:none;">Support on Ko-fi</a>')
|
||||||
kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;")
|
kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;")
|
||||||
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
kofi_link.setOpenExternalLinks(False)
|
kofi_link.setOpenExternalLinks(False)
|
||||||
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
|
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
|
||||||
kofi_link.setToolTip("Support Jackify development")
|
kofi_link.setToolTip("Support Jackify development")
|
||||||
bottom_bar_layout.addWidget(kofi_link)
|
bottom_bar_layout.addWidget(kofi_link, 0, alignment=Qt.AlignCenter)
|
||||||
bottom_bar_layout.addStretch(1)
|
|
||||||
|
right_zone = QWidget()
|
||||||
|
right_zone_layout = QHBoxLayout(right_zone)
|
||||||
|
right_zone_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
right_zone_layout.addStretch(1)
|
||||||
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
|
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
|
||||||
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
||||||
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
settings_btn.setOpenExternalLinks(False)
|
settings_btn.setOpenExternalLinks(False)
|
||||||
settings_btn.linkActivated.connect(self.open_settings_dialog)
|
settings_btn.linkActivated.connect(self.open_settings_dialog)
|
||||||
bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
|
right_zone_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
|
||||||
about_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">About</a>')
|
about_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">About</a>')
|
||||||
about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
||||||
about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
about_btn.setOpenExternalLinks(False)
|
about_btn.setOpenExternalLinks(False)
|
||||||
about_btn.linkActivated.connect(self.open_about_dialog)
|
about_btn.linkActivated.connect(self.open_about_dialog)
|
||||||
bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight)
|
right_zone_layout.addWidget(about_btn, alignment=Qt.AlignRight)
|
||||||
|
bottom_bar_layout.addWidget(right_zone, 1)
|
||||||
|
|
||||||
central_widget = QWidget()
|
central_widget = QWidget()
|
||||||
main_layout = QVBoxLayout()
|
main_layout = QVBoxLayout()
|
||||||
|
|||||||
@@ -22,13 +22,9 @@ import logging
|
|||||||
import warnings
|
import warnings
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from jackify.frontends.gui.mixins.thread_registry import register_managed_thread
|
||||||
|
|
||||||
# Module-level registry keeps references to parked threads alive independent
|
logger = logging.getLogger(__name__)
|
||||||
# of screen widget lifetime. Screens are destroyed on navigation; without this,
|
|
||||||
# _parked_threads on self evaporates and the GC destroys still-running threads,
|
|
||||||
# triggering Qt's "QThread: Destroyed while thread is still running" abort.
|
|
||||||
_PARKED_THREAD_REGISTRY: set = set()
|
|
||||||
|
|
||||||
|
|
||||||
class ThreadLifecycleMixin:
|
class ThreadLifecycleMixin:
|
||||||
@@ -38,8 +34,8 @@ class ThreadLifecycleMixin:
|
|||||||
"""Disconnect a thread from this screen and let it finish on its own.
|
"""Disconnect a thread from this screen and let it finish on its own.
|
||||||
|
|
||||||
Disconnects the named signals so no callbacks fire on this (potentially
|
Disconnects the named signals so no callbacks fire on this (potentially
|
||||||
dying) widget. Keeps a reference in _parked_threads so the thread is
|
dying) widget. Keeps a reference alive via the global registry until the
|
||||||
not garbage-collected before it finishes.
|
thread finishes.
|
||||||
|
|
||||||
Returns None so callers can do: self.thread = self._park_thread(self.thread, [...])
|
Returns None so callers can do: self.thread = self._park_thread(self.thread, [...])
|
||||||
"""
|
"""
|
||||||
@@ -54,13 +50,9 @@ class ThreadLifecycleMixin:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Register in the module-level set so the reference survives screen destruction.
|
# Hand the thread to the global registry so it survives screen destruction
|
||||||
# Remove from registry when the thread finishes so it can be GC'd cleanly.
|
# and is drained cleanly on app exit.
|
||||||
_PARKED_THREAD_REGISTRY.add(thread)
|
register_managed_thread(thread)
|
||||||
try:
|
|
||||||
thread.finished.connect(lambda t=thread: _PARKED_THREAD_REGISTRY.discard(t))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def hideEvent(self, event):
|
def hideEvent(self, event):
|
||||||
@@ -71,6 +63,14 @@ class ThreadLifecycleMixin:
|
|||||||
pass
|
pass
|
||||||
self._park_all_threads()
|
self._park_all_threads()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
"""Park all running threads when the widget is closed."""
|
||||||
|
self._park_all_threads()
|
||||||
|
try:
|
||||||
|
super().closeEvent(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
|
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
|
||||||
"""Kill wine/winetricks subprocesses on user-initiated cancel.
|
"""Kill wine/winetricks subprocesses on user-initiated cancel.
|
||||||
|
|
||||||
@@ -105,19 +105,11 @@ class ThreadLifecycleMixin:
|
|||||||
"""Park every running QThread attribute found on this instance.
|
"""Park every running QThread attribute found on this instance.
|
||||||
|
|
||||||
Inspects instance variables, disconnects common signal names from any
|
Inspects instance variables, disconnects common signal names from any
|
||||||
running QThread, and parks them. Used in cleanup_processes() / closeEvent().
|
running QThread, and registers them globally. Used in cleanup_processes()
|
||||||
|
/ closeEvent() / hideEvent().
|
||||||
"""
|
"""
|
||||||
from PySide6.QtCore import QThread
|
from PySide6.QtCore import QThread
|
||||||
|
from jackify.frontends.gui.mixins.thread_registry import _COMMON_SIGNAL_NAMES
|
||||||
_common_signals = (
|
|
||||||
"finished_signal",
|
|
||||||
"progress_update",
|
|
||||||
"workflow_complete",
|
|
||||||
"configuration_complete",
|
|
||||||
"error_occurred",
|
|
||||||
"status_update",
|
|
||||||
"finished",
|
|
||||||
)
|
|
||||||
|
|
||||||
for attr_name, value in list(vars(self).items()):
|
for attr_name, value in list(vars(self).items()):
|
||||||
try:
|
try:
|
||||||
@@ -125,7 +117,7 @@ class ThreadLifecycleMixin:
|
|||||||
continue
|
continue
|
||||||
if not value.isRunning():
|
if not value.isRunning():
|
||||||
continue
|
continue
|
||||||
signal_names = [s for s in _common_signals if hasattr(value, s)]
|
signal_names = [s for s in _COMMON_SIGNAL_NAMES if hasattr(value, s)]
|
||||||
setattr(self, attr_name, self._park_thread(value, signal_names))
|
setattr(self, attr_name, self._park_thread(value, signal_names))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Application-wide QThread registry.
|
||||||
|
|
||||||
|
All managed threads are registered here. On app exit `drain_all_threads` disconnects
|
||||||
|
signals, requests cancellation, and waits for each thread so no QThread outlives its
|
||||||
|
Python wrapper or fires signals into destroyed widgets.
|
||||||
|
|
||||||
|
Usage in threads that are not owned by a ThreadLifecycleMixin widget (e.g. orphaned
|
||||||
|
workers) call `register_managed_thread` directly. ThreadLifecycleMixin calls it
|
||||||
|
automatically inside `_park_thread`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Central set of live managed threads. Python objects are kept alive here until their
|
||||||
|
# QThread.finished signal fires, preventing GC from destroying running threads.
|
||||||
|
_MANAGED_THREADS: set = set()
|
||||||
|
|
||||||
|
# Common signal names to disconnect during drain / park.
|
||||||
|
_COMMON_SIGNAL_NAMES = (
|
||||||
|
"finished",
|
||||||
|
"finished_signal",
|
||||||
|
"progress_update",
|
||||||
|
"workflow_complete",
|
||||||
|
"configuration_complete",
|
||||||
|
"error_occurred",
|
||||||
|
"status_update",
|
||||||
|
"output_received",
|
||||||
|
"progress_received",
|
||||||
|
"installation_finished",
|
||||||
|
"cache_ready",
|
||||||
|
"update_available",
|
||||||
|
"no_update",
|
||||||
|
"check_failed",
|
||||||
|
"completed",
|
||||||
|
"done",
|
||||||
|
"name_ready",
|
||||||
|
"progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_managed_thread(thread) -> None:
|
||||||
|
"""Add a QThread to the global registry and auto-remove it when it finishes.
|
||||||
|
|
||||||
|
Safe to call multiple times on the same thread.
|
||||||
|
"""
|
||||||
|
if thread is None:
|
||||||
|
return
|
||||||
|
_MANAGED_THREADS.add(thread)
|
||||||
|
try:
|
||||||
|
thread.finished.connect(lambda t=thread: _MANAGED_THREADS.discard(t))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def drain_all_threads(timeout_ms: int = 8000) -> None:
|
||||||
|
"""Disconnect signals, request cancellation, and wait for every registered thread.
|
||||||
|
|
||||||
|
Called on `QApplication.aboutToQuit` and from the emergency cleanup handler.
|
||||||
|
Does not call terminate() - threads are given `timeout_ms` to finish gracefully.
|
||||||
|
If a thread does not exit in time, a warning is logged and we move on.
|
||||||
|
"""
|
||||||
|
snapshot = list(_MANAGED_THREADS)
|
||||||
|
if not snapshot:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Draining %d managed thread(s)", len(snapshot))
|
||||||
|
for thread in snapshot:
|
||||||
|
try:
|
||||||
|
if not thread.isRunning():
|
||||||
|
_MANAGED_THREADS.discard(thread)
|
||||||
|
continue
|
||||||
|
except RuntimeError:
|
||||||
|
_MANAGED_THREADS.discard(thread)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Disconnect all known signals so no callbacks fire into destroyed widgets.
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
|
for name in _COMMON_SIGNAL_NAMES:
|
||||||
|
try:
|
||||||
|
getattr(thread, name).disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Signal cancellation where supported.
|
||||||
|
if hasattr(thread, "cancel"):
|
||||||
|
try:
|
||||||
|
thread.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
thread.requestInterruption()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
thread.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not thread.wait(timeout_ms):
|
||||||
|
logger.warning(
|
||||||
|
"Thread %s did not stop within %dms during drain",
|
||||||
|
thread.__class__.__name__,
|
||||||
|
timeout_ms,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
thread.deleteLater()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_MANAGED_THREADS.discard(thread)
|
||||||
|
|
||||||
|
logger.debug("Thread drain complete")
|
||||||
@@ -17,11 +17,12 @@ from PySide6.QtGui import QFont
|
|||||||
from jackify.backend.models.configuration import SystemInfo
|
from jackify.backend.models.configuration import SystemInfo
|
||||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||||
from ..utils import set_responsive_minimum
|
from ..utils import set_responsive_minimum
|
||||||
|
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AdditionalTasksScreen(QWidget):
|
class AdditionalTasksScreen(ThreadLifecycleMixin, QWidget):
|
||||||
"""Additional Tasks screen for automation and standalone tools."""
|
"""Additional Tasks screen for automation and standalone tools."""
|
||||||
|
|
||||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
|
# Copy of ConfigureNewModlistScreen, adapted for existing modlists
|
||||||
|
import warnings
|
||||||
from PySide6.QtWidgets import *
|
from PySide6.QtWidgets import *
|
||||||
from PySide6.QtCore import *
|
from PySide6.QtCore import *
|
||||||
from PySide6.QtGui import *
|
from PySide6.QtGui import *
|
||||||
@@ -58,7 +59,6 @@ class ConfigureExistingModlistScreen(
|
|||||||
super().hideEvent(event)
|
super().hideEvent(event)
|
||||||
|
|
||||||
def cleanup_processes(self):
|
def cleanup_processes(self):
|
||||||
"""Clean up any running processes when the window closes or is cancelled"""
|
|
||||||
if getattr(self, '_vnv_controller', None) is not None:
|
if getattr(self, '_vnv_controller', None) is not None:
|
||||||
try:
|
try:
|
||||||
self._vnv_controller.cleanup()
|
self._vnv_controller.cleanup()
|
||||||
@@ -67,7 +67,6 @@ class ConfigureExistingModlistScreen(
|
|||||||
pass
|
pass
|
||||||
if hasattr(self, 'file_progress_list'):
|
if hasattr(self, 'file_progress_list'):
|
||||||
self.file_progress_list.stop_cpu_tracking()
|
self.file_progress_list.stop_cpu_tracking()
|
||||||
self._park_all_threads()
|
|
||||||
|
|
||||||
def cancel_and_cleanup(self):
|
def cancel_and_cleanup(self):
|
||||||
"""Handle Cancel button - clean up processes and go back"""
|
"""Handle Cancel button - clean up processes and go back"""
|
||||||
@@ -91,7 +90,7 @@ class ConfigureExistingModlistScreen(
|
|||||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to set initial collapsed state: {e}")
|
logger.warning(f"Failed to set initial collapsed state: {e}")
|
||||||
|
|
||||||
# Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list()
|
# Shortcut loading is handled by reset_screen_to_defaults() → refresh_modlist_list()
|
||||||
# which fires via _debug_screen_change on every navigation to this screen.
|
# which fires via _debug_screen_change on every navigation to this screen.
|
||||||
@@ -183,11 +182,13 @@ class ConfigureExistingModlistScreen(
|
|||||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
with warnings.catch_warnings():
|
||||||
try:
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
getattr(self.config_thread, sig_name).disconnect()
|
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||||
except (RuntimeError, TypeError, AttributeError):
|
try:
|
||||||
pass
|
getattr(self.config_thread, sig_name).disconnect()
|
||||||
|
except (RuntimeError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
if self.config_thread.isRunning():
|
if self.config_thread.isRunning():
|
||||||
self.config_thread.quit()
|
self.config_thread.quit()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
|
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
|
||||||
from PySide6.QtCore import QThread, Signal, QObject
|
from PySide6.QtCore import QThread, Signal, QObject
|
||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
class ConfigureExistingModlistShortcutsMixin:
|
class ConfigureExistingModlistShortcutsMixin:
|
||||||
@@ -75,14 +76,16 @@ class ConfigureExistingModlistShortcutsMixin:
|
|||||||
if hasattr(self, '_park_thread'):
|
if hasattr(self, '_park_thread'):
|
||||||
self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
|
self._park_thread(self._shortcut_loader, ["finished_signal", "error_signal"])
|
||||||
else:
|
else:
|
||||||
try:
|
with warnings.catch_warnings():
|
||||||
self._shortcut_loader.finished_signal.disconnect()
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
except Exception:
|
try:
|
||||||
pass
|
self._shortcut_loader.finished_signal.disconnect()
|
||||||
try:
|
except Exception:
|
||||||
self._shortcut_loader.error_signal.disconnect()
|
pass
|
||||||
except Exception:
|
try:
|
||||||
pass
|
self._shortcut_loader.error_signal.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if not hasattr(self, '_old_loaders'):
|
if not hasattr(self, '_old_loaders'):
|
||||||
self._old_loaders = []
|
self._old_loaders = []
|
||||||
self._old_loaders.append(self._shortcut_loader)
|
self._old_loaders.append(self._shortcut_loader)
|
||||||
@@ -101,14 +104,13 @@ class ConfigureExistingModlistShortcutsMixin:
|
|||||||
def _on_shortcuts_loaded(self, shortcuts):
|
def _on_shortcuts_loaded(self, shortcuts):
|
||||||
"""Update UI when shortcuts are loaded"""
|
"""Update UI when shortcuts are loaded"""
|
||||||
self.mo2_shortcuts = shortcuts
|
self.mo2_shortcuts = shortcuts
|
||||||
|
|
||||||
# Update the dropdown
|
|
||||||
if hasattr(self, 'shortcut_combo'):
|
if hasattr(self, 'shortcut_combo'):
|
||||||
self.shortcut_combo.clear()
|
self.shortcut_combo.clear()
|
||||||
self.shortcut_combo.setEnabled(True)
|
self.shortcut_combo.setEnabled(True)
|
||||||
self.shortcut_combo.addItem("Please Select...")
|
self.shortcut_combo.addItem("Please Select...")
|
||||||
self.shortcut_map.clear()
|
self.shortcut_map.clear()
|
||||||
|
|
||||||
for shortcut in self.mo2_shortcuts:
|
for shortcut in self.mo2_shortcuts:
|
||||||
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||||
self.shortcut_combo.addItem(display)
|
self.shortcut_combo.addItem(display)
|
||||||
|
|||||||
@@ -15,13 +15,33 @@ class ConfigureExistingModlistWorkflowMixin:
|
|||||||
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
|
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
|
||||||
|
|
||||||
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
|
||||||
"""Detect special game type using the canonical ModlistHandler detection."""
|
"""Detect game type for the verifier from ModOrganizer.ini."""
|
||||||
try:
|
try:
|
||||||
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
from jackify.backend.handlers.modlist_handler import ModlistHandler
|
||||||
return ModlistHandler().detect_special_game_type(install_dir) or 'skyrim'
|
special = ModlistHandler().detect_special_game_type(install_dir)
|
||||||
|
if special:
|
||||||
|
return special
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Game type detection failed, defaulting to skyrim: %s", e)
|
logger.warning("Special game type detection failed: %s", e)
|
||||||
return 'skyrim'
|
|
||||||
|
# detect_special_game_type only covers non-default games; read gameName= directly
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
|
||||||
|
if mo2_ini.exists():
|
||||||
|
for raw_line in mo2_ini.read_text(errors='ignore').splitlines():
|
||||||
|
line = raw_line.strip().lower()
|
||||||
|
if line.startswith("gamename="):
|
||||||
|
val = line[len("gamename="):]
|
||||||
|
if "fallout 4" in val:
|
||||||
|
return "fallout4"
|
||||||
|
if "skyrim" in val:
|
||||||
|
return "skyrim"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("ModOrganizer.ini gameName read failed: %s", e)
|
||||||
|
|
||||||
|
logger.warning("Could not determine game type for %s, verifier will run generic checks only", install_dir)
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
def validate_and_start_configure(self):
|
def validate_and_start_configure(self):
|
||||||
# Reload config to pick up any settings changes made in Settings dialog
|
# Reload config to pick up any settings changes made in Settings dialog
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
ConfigureNewModlistScreen for Jackify GUI
|
ConfigureNewModlistScreen for Jackify GUI
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
|
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
|
||||||
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
|
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
|
||||||
from PySide6.QtGui import QPixmap, QTextCursor
|
from PySide6.QtGui import QPixmap, QTextCursor
|
||||||
@@ -126,12 +127,14 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
|||||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
with warnings.catch_warnings():
|
||||||
self.config_thread.progress_update.disconnect()
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
self.config_thread.configuration_complete.disconnect()
|
try:
|
||||||
self.config_thread.error_occurred.disconnect()
|
self.config_thread.progress_update.disconnect()
|
||||||
except (RuntimeError, TypeError):
|
self.config_thread.configuration_complete.disconnect()
|
||||||
pass
|
self.config_thread.error_occurred.disconnect()
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
if self.config_thread.isRunning():
|
if self.config_thread.isRunning():
|
||||||
self.config_thread.quit()
|
self.config_thread.quit()
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ class ConfigureNewModlistDialogsMixin:
|
|||||||
super().hideEvent(event)
|
super().hideEvent(event)
|
||||||
|
|
||||||
def cleanup_processes(self):
|
def cleanup_processes(self):
|
||||||
"""Clean up any running processes when the window closes or is cancelled"""
|
|
||||||
if getattr(self, '_vnv_controller', None) is not None:
|
if getattr(self, '_vnv_controller', None) is not None:
|
||||||
try:
|
try:
|
||||||
self._vnv_controller.cleanup()
|
self._vnv_controller.cleanup()
|
||||||
@@ -103,7 +102,6 @@ class ConfigureNewModlistDialogsMixin:
|
|||||||
self._stop_focus_reclaim()
|
self._stop_focus_reclaim()
|
||||||
if hasattr(self, 'file_progress_list'):
|
if hasattr(self, 'file_progress_list'):
|
||||||
self.file_progress_list.stop_cpu_tracking()
|
self.file_progress_list.stop_cpu_tracking()
|
||||||
self._park_all_threads()
|
|
||||||
|
|
||||||
def show_shortcut_conflict_dialog(self, conflicts):
|
def show_shortcut_conflict_dialog(self, conflicts):
|
||||||
"""Show dialog to reuse an existing shortcut or choose a new name."""
|
"""Show dialog to reuse an existing shortcut or choose a new name."""
|
||||||
|
|||||||
@@ -601,9 +601,9 @@ class ConfigureNewModlistUISetupMixin:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking protontricks: {e}")
|
logger.error(f"Error checking protontricks: {e}")
|
||||||
from jackify.frontends.gui.services.message_service import MessageService
|
from jackify.frontends.gui.services.message_service import MessageService
|
||||||
MessageService.warning(self, "Protontricks Check Failed",
|
MessageService.warning(self, "Protontricks Check Failed",
|
||||||
f"Unable to verify protontricks installation: {e}\n\n"
|
f"Unable to verify protontricks installation: {e}\n\n"
|
||||||
"Continuing anyway, but some features may not work correctly.")
|
"Continuing anyway, but some features may not work correctly.")
|
||||||
return True # Continue anyway
|
return True # Continue anyway
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from PySide6.QtCore import QThread, Signal
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||||
from jackify.shared.errors import configuration_failed
|
from jackify.shared.errors import configuration_failed
|
||||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||||
@@ -262,12 +263,14 @@ class ConfigureNewModlistWorkflowMixin:
|
|||||||
"""Safely release the automated prefix thread after it has finished."""
|
"""Safely release the automated prefix thread after it has finished."""
|
||||||
if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None:
|
if not hasattr(self, 'automated_prefix_thread') or self.automated_prefix_thread is None:
|
||||||
return
|
return
|
||||||
try:
|
with warnings.catch_warnings():
|
||||||
self.automated_prefix_thread.progress_update.disconnect()
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
self.automated_prefix_thread.workflow_complete.disconnect()
|
try:
|
||||||
self.automated_prefix_thread.error_occurred.disconnect()
|
self.automated_prefix_thread.progress_update.disconnect()
|
||||||
except (RuntimeError, TypeError):
|
self.automated_prefix_thread.workflow_complete.disconnect()
|
||||||
pass
|
self.automated_prefix_thread.error_occurred.disconnect()
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
pass
|
||||||
if self.automated_prefix_thread.isRunning():
|
if self.automated_prefix_thread.isRunning():
|
||||||
self.automated_prefix_thread.quit()
|
self.automated_prefix_thread.quit()
|
||||||
self.automated_prefix_thread.wait(5000)
|
self.automated_prefix_thread.wait(5000)
|
||||||
|
|||||||
@@ -438,5 +438,3 @@ class ConfigureToolConfigScreen(ThreadLifecycleMixin, QWidget):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")
|
||||||
|
|
||||||
def cleanup_processes(self):
|
|
||||||
self._park_all_threads()
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
|||||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||||
from ..widgets.file_progress_list import FileProgressList
|
from ..widgets.file_progress_list import FileProgressList
|
||||||
from .screen_back_mixin import ScreenBackMixin
|
from .screen_back_mixin import ScreenBackMixin
|
||||||
|
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ class MO2SetupWorker(QThread):
|
|||||||
self.setup_complete.emit(False, None, str(e))
|
self.setup_complete.emit(False, None, str(e))
|
||||||
|
|
||||||
|
|
||||||
class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
class InstallMO2Screen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||||
"""Standalone MO2 setup screen"""
|
"""Standalone MO2 setup screen"""
|
||||||
|
|
||||||
resize_request = Signal(str)
|
resize_request = Signal(str)
|
||||||
@@ -498,23 +499,12 @@ class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
|||||||
self.go_back()
|
self.go_back()
|
||||||
|
|
||||||
def cleanup_processes(self):
|
def cleanup_processes(self):
|
||||||
"""Stop active MO2 worker and CPU tracking before screen/app shutdown."""
|
|
||||||
self._stop_focus_reclaim()
|
self._stop_focus_reclaim()
|
||||||
try:
|
try:
|
||||||
self.file_progress_list.stop_cpu_tracking()
|
self.file_progress_list.stop_cpu_tracking()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if self.worker is not None:
|
|
||||||
try:
|
|
||||||
if self.worker.isRunning():
|
|
||||||
self.worker.requestInterruption()
|
|
||||||
self.worker.wait(10000)
|
|
||||||
self.worker.deleteLater()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.worker = None
|
|
||||||
|
|
||||||
def reset_screen_to_defaults(self):
|
def reset_screen_to_defaults(self):
|
||||||
self.file_progress_list.clear()
|
self.file_progress_list.clear()
|
||||||
self.console.clear()
|
self.console.clear()
|
||||||
|
|||||||
@@ -198,8 +198,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
|||||||
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height)
|
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self.debug:
|
if self.debug:
|
||||||
print(f"DEBUG: Error calculating upper section height: {e}")
|
logger.debug(f"Error calculating upper section height: {e}")
|
||||||
pass
|
|
||||||
|
|
||||||
# Calculate heights immediately after forcing layout update
|
# Calculate heights immediately after forcing layout update
|
||||||
# Prevents visible layout shift
|
# Prevents visible layout shift
|
||||||
@@ -356,7 +355,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking protontricks: {e}")
|
logger.error(f"Error checking protontricks: {e}")
|
||||||
MessageService.warning(self, "Protontricks Check Failed",
|
MessageService.warning(self, "Protontricks Check Failed",
|
||||||
f"Unable to verify protontricks installation: {e}\n\n"
|
f"Unable to verify protontricks installation: {e}\n\n"
|
||||||
"Continuing anyway, but some features may not work correctly.")
|
"Continuing anyway, but some features may not work correctly.")
|
||||||
@@ -424,7 +423,6 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
|||||||
super().hideEvent(event)
|
super().hideEvent(event)
|
||||||
|
|
||||||
def cleanup_processes(self):
|
def cleanup_processes(self):
|
||||||
"""Clean up any running processes when the window closes or is cancelled"""
|
|
||||||
self._stop_clf3_decompress_pulse()
|
self._stop_clf3_decompress_pulse()
|
||||||
|
|
||||||
fpl = getattr(self, 'file_progress_list', None)
|
fpl = getattr(self, 'file_progress_list', None)
|
||||||
@@ -440,74 +438,23 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifie
|
|||||||
|
|
||||||
self._stop_focus_reclaim()
|
self._stop_focus_reclaim()
|
||||||
|
|
||||||
# Disconnect all thread signals before any stopping - prevents callbacks to
|
# install_thread needs cancel() to kill the child subprocess, not just
|
||||||
# a dying widget if threads emit between now and actual termination.
|
# signal disconnection. The extended wait gives the engine time to flush.
|
||||||
self._park_all_threads()
|
thread = getattr(self, 'install_thread', None)
|
||||||
|
if thread is not None:
|
||||||
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
|
|
||||||
thread = getattr(self, attr_name, None)
|
|
||||||
if thread is None:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
running = thread.isRunning()
|
running = thread.isRunning()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
setattr(self, attr_name, None)
|
running = False
|
||||||
return
|
if running:
|
||||||
|
|
||||||
if not running:
|
|
||||||
setattr(self, attr_name, None)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug(f"Stopping {attr_name}")
|
|
||||||
|
|
||||||
if cancel_method and hasattr(thread, cancel_method):
|
|
||||||
try:
|
try:
|
||||||
getattr(thread, cancel_method)()
|
thread.cancel()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
if not thread.wait(15000):
|
||||||
try:
|
logger.warning("install_thread did not stop in 15s")
|
||||||
thread.requestInterruption()
|
thread.wait(10000)
|
||||||
except Exception:
|
self.install_thread = None
|
||||||
pass
|
|
||||||
try:
|
|
||||||
thread.quit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
if thread.wait(cooperative_ms):
|
|
||||||
setattr(self, attr_name, None)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.warning(f"WARNING: {attr_name} did not stop in {cooperative_ms}ms, waiting for forced shutdown window")
|
|
||||||
try:
|
|
||||||
if cancel_method and hasattr(thread, cancel_method):
|
|
||||||
getattr(thread, cancel_method)()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
if not thread.wait(force_ms):
|
|
||||||
logger.error(f"ERROR: {attr_name} still running after forced shutdown window")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
setattr(self, attr_name, None)
|
|
||||||
|
|
||||||
# Always stop installer thread first; it needs cancel() not terminate().
|
|
||||||
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
|
|
||||||
|
|
||||||
# Stop any remaining QThread instances on this object, regardless of attribute name.
|
|
||||||
from PySide6.QtCore import QThread
|
|
||||||
for attr_name, value in list(vars(self).items()):
|
|
||||||
if attr_name == 'install_thread':
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if isinstance(value, QThread):
|
|
||||||
_stop_thread(attr_name)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cancel_installation(self):
|
def cancel_installation(self):
|
||||||
"""Cancel the currently running installation"""
|
"""Cancel the currently running installation"""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
|
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
|
||||||
|
import warnings
|
||||||
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
||||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||||
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||||
@@ -178,6 +179,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
|||||||
'time_taken': time_str,
|
'time_taken': time_str,
|
||||||
'game_name': game_name,
|
'game_name': game_name,
|
||||||
'enb_detected': enb_detected,
|
'enb_detected': enb_detected,
|
||||||
|
'readme_url': getattr(self, '_readme_url', None),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||||
@@ -233,12 +235,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
|||||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
with warnings.catch_warnings():
|
||||||
self.config_thread.progress_update.disconnect()
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
self.config_thread.configuration_complete.disconnect()
|
try:
|
||||||
self.config_thread.error_occurred.disconnect()
|
self.config_thread.progress_update.disconnect()
|
||||||
except (RuntimeError, TypeError):
|
self.config_thread.configuration_complete.disconnect()
|
||||||
pass
|
self.config_thread.error_occurred.disconnect()
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
if self.config_thread.isRunning():
|
if self.config_thread.isRunning():
|
||||||
self.config_thread.quit()
|
self.config_thread.quit()
|
||||||
@@ -525,12 +529,14 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
|||||||
# Clean up old thread if exists and wait for it to finish
|
# Clean up old thread if exists and wait for it to finish
|
||||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||||
try:
|
with warnings.catch_warnings():
|
||||||
self.config_thread.progress_update.disconnect()
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
self.config_thread.configuration_complete.disconnect()
|
try:
|
||||||
self.config_thread.error_occurred.disconnect()
|
self.config_thread.progress_update.disconnect()
|
||||||
except (RuntimeError, TypeError):
|
self.config_thread.configuration_complete.disconnect()
|
||||||
pass # Ignore errors if already disconnected
|
self.config_thread.error_occurred.disconnect()
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
pass # Ignore errors if already disconnected
|
||||||
if self.config_thread.isRunning():
|
if self.config_thread.isRunning():
|
||||||
self.config_thread.quit()
|
self.config_thread.quit()
|
||||||
self.config_thread.wait(5000) # Wait up to 5 seconds
|
self.config_thread.wait(5000) # Wait up to 5 seconds
|
||||||
@@ -618,7 +624,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
|
error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}"
|
||||||
self.progress_update.emit(f"DEBUG: {error_details}")
|
self.progress_update.emit(f"ERROR: {error_details}")
|
||||||
self.error_occurred.emit(str(e))
|
self.error_occurred.emit(str(e))
|
||||||
|
|
||||||
return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self)
|
return ConfigThread(context, is_steamdeck, detect_game_type_func, parent=self)
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ class InstallerThread(QThread):
|
|||||||
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
|
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
|
||||||
|
|
||||||
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
|
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
|
||||||
of continued silence — so extended waits remain visible to the user.
|
of continued silence - so extended waits remain visible to the user.
|
||||||
Samples /proc/<pid>/io to show write throughput, giving the user concrete
|
Samples /proc/<pid>/io to show write throughput, giving the user concrete
|
||||||
evidence that extraction is progressing even when CLF3 emits no output.
|
evidence that extraction is progressing even when CLF3 emits no output.
|
||||||
Resets when real output arrives so a new silence period can trigger it again.
|
Resets when real output arrives so a new silence period can trigger it again.
|
||||||
@@ -360,7 +360,7 @@ class InstallerThread(QThread):
|
|||||||
JSON progress events (keyed on "type") and plain human-readable text both arrive
|
JSON progress events (keyed on "type") and plain human-readable text both arrive
|
||||||
on stdout. Manual download events (keyed on "event") also appear on stdout.
|
on stdout. Manual download events (keyed on "event") also appear on stdout.
|
||||||
|
|
||||||
Extraction dispatch counters ("Extracting: N/M") are dropped — named per-archive
|
Extraction dispatch counters ("Extracting: N/M") are dropped - named per-archive
|
||||||
completion lines already cover this information.
|
completion lines already cover this information.
|
||||||
Directive counters ("Processing: N/M") are buffered; only the final value is
|
Directive counters ("Processing: N/M") are buffered; only the final value is
|
||||||
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
|
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
|
||||||
@@ -391,7 +391,7 @@ class InstallerThread(QThread):
|
|||||||
self.progress_updated.emit(state)
|
self.progress_updated.emit(state)
|
||||||
msg = state.message
|
msg = state.message
|
||||||
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
|
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
|
||||||
# Relabel as "Queuing" — the N/M counter tracks archive dispatch
|
# Relabel as "Queuing" - the N/M counter tracks archive dispatch
|
||||||
# to worker threads, not completion of decompression.
|
# to worker threads, not completion of decompression.
|
||||||
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
|
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
|
from PySide6.QtWidgets import QMessageBox, QApplication, QDialog
|
||||||
from jackify.frontends.gui.utils import browse_directory, browse_file
|
from jackify.frontends.gui.utils import browse_directory, browse_file
|
||||||
from PySide6.QtCore import QTimer, Qt
|
from PySide6.QtCore import QTimer, Qt
|
||||||
|
from PySide6.QtGui import QFontMetrics
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -160,7 +161,11 @@ class ModlistSelectionMixin:
|
|||||||
|
|
||||||
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
|
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
|
||||||
metadata = self._gallery_dlg.selected_metadata
|
metadata = self._gallery_dlg.selected_metadata
|
||||||
self.modlist_btn.setText(metadata.title)
|
metrics = QFontMetrics(self.modlist_btn.font())
|
||||||
|
available_width = self.modlist_btn.width() - 24 # padding allowance
|
||||||
|
elided_title = metrics.elidedText(metadata.title, Qt.ElideRight, available_width)
|
||||||
|
self.modlist_btn.setText(elided_title)
|
||||||
|
self.modlist_btn.setToolTip(metadata.title)
|
||||||
self.selected_modlist_info = {
|
self.selected_modlist_info = {
|
||||||
'machine_url': metadata.namespacedName,
|
'machine_url': metadata.namespacedName,
|
||||||
'title': metadata.title,
|
'title': metadata.title,
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ class InstallModlistUISetupMixin:
|
|||||||
# --- Modlist Selection ---
|
# --- Modlist Selection ---
|
||||||
self.modlist_btn = QPushButton("Select Modlist")
|
self.modlist_btn = QPushButton("Select Modlist")
|
||||||
self.modlist_btn.setMinimumWidth(300)
|
self.modlist_btn.setMinimumWidth(300)
|
||||||
|
self.modlist_btn.setMaximumWidth(300)
|
||||||
self.modlist_btn.clicked.connect(self.open_modlist_dialog)
|
self.modlist_btn.clicked.connect(self.open_modlist_dialog)
|
||||||
self.modlist_btn.setEnabled(False)
|
self.modlist_btn.setEnabled(False)
|
||||||
online_layout.addWidget(QLabel("Game Type:"))
|
online_layout.addWidget(QLabel("Game Type:"))
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||||
|
from jackify.backend.services.update_detection import (
|
||||||
|
evaluate_update_candidate as _svc_evaluate,
|
||||||
|
find_existing_shortcut_appid as _svc_find_appid,
|
||||||
|
normalize_version_token,
|
||||||
|
normalize_modlist_name,
|
||||||
|
)
|
||||||
from .install_modlist_output_mixin import InstallModlistOutputMixin
|
from .install_modlist_output_mixin import InstallModlistOutputMixin
|
||||||
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
|
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
|
||||||
|
|
||||||
@@ -15,27 +21,16 @@ logger = logging.getLogger(__name__)
|
|||||||
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
|
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
|
||||||
"""Mixin providing installation workflow methods for InstallModlistScreen."""
|
"""Mixin providing installation workflow methods for InstallModlistScreen."""
|
||||||
|
|
||||||
@staticmethod
|
# normalize_version_token and normalize_modlist_name are imported from update_detection service
|
||||||
def _normalize_version_token(value: str | None) -> str | None:
|
_normalize_version_token = staticmethod(normalize_version_token)
|
||||||
"""Return a normalized version token for lightweight equality checks."""
|
_normalize_modlist_name = staticmethod(normalize_modlist_name)
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
token = str(value).strip()
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
token = token.lstrip("vV")
|
|
||||||
return token.lower()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_modlist_name(value: str | None) -> str:
|
|
||||||
return " ".join((value or "").strip().lower().split())
|
|
||||||
|
|
||||||
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
|
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
|
||||||
"""Return selected modlist version from gallery metadata when available."""
|
"""Return selected modlist version from gallery metadata when available."""
|
||||||
if install_mode != "online":
|
if install_mode != "online":
|
||||||
return None
|
return None
|
||||||
info = getattr(self, "selected_modlist_info", None) or {}
|
info = getattr(self, "selected_modlist_info", None) or {}
|
||||||
return self._normalize_version_token(info.get("version"))
|
return normalize_version_token(info.get("version"))
|
||||||
|
|
||||||
def _evaluate_update_candidate(
|
def _evaluate_update_candidate(
|
||||||
self,
|
self,
|
||||||
@@ -44,52 +39,8 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
|
|||||||
install_mode: str,
|
install_mode: str,
|
||||||
existing_appid: str | None,
|
existing_appid: str | None,
|
||||||
) -> tuple[bool, dict]:
|
) -> tuple[bool, dict]:
|
||||||
"""
|
|
||||||
Decide whether update-mode prompt should be shown.
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- Require existing shortcut AppID and jackify_meta.json.
|
|
||||||
- Require modlist identity match (requested name == installed meta name).
|
|
||||||
- Version relation is informational:
|
|
||||||
- `different` when both requested/installed versions are available and differ.
|
|
||||||
- `same` when both are available and equal.
|
|
||||||
- `unknown` when either side is missing.
|
|
||||||
"""
|
|
||||||
from jackify.backend.utils.modlist_meta import read_modlist_meta
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"eligible": False,
|
|
||||||
"reason": "unknown",
|
|
||||||
"requested_version": None,
|
|
||||||
"installed_version": None,
|
|
||||||
"version_relation": "unknown",
|
|
||||||
"installed_name": None,
|
|
||||||
}
|
|
||||||
if not existing_appid:
|
|
||||||
result["reason"] = "missing_shortcut_appid"
|
|
||||||
return False, result
|
|
||||||
|
|
||||||
meta = read_modlist_meta(install_dir)
|
|
||||||
if not meta:
|
|
||||||
result["reason"] = "missing_meta"
|
|
||||||
return False, result
|
|
||||||
|
|
||||||
installed_name = (meta.get("modlist_name") or "").strip()
|
|
||||||
result["installed_name"] = installed_name
|
|
||||||
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
|
|
||||||
result["reason"] = "modlist_name_mismatch"
|
|
||||||
return False, result
|
|
||||||
|
|
||||||
requested_version = self._get_requested_modlist_version(install_mode)
|
requested_version = self._get_requested_modlist_version(install_mode)
|
||||||
installed_version = self._normalize_version_token(meta.get("modlist_version"))
|
return _svc_evaluate(modlist_name, install_dir, existing_appid, requested_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 _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
|
def _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
|
||||||
"""Return ModOrganizer.ini path for standard/special layouts."""
|
"""Return ModOrganizer.ini path for standard/special layouts."""
|
||||||
@@ -276,38 +227,7 @@ class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMi
|
|||||||
|
|
||||||
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
|
||||||
"""Return existing Steam shortcut AppID for this install dir/name when present."""
|
"""Return existing Steam shortcut AppID for this install dir/name when present."""
|
||||||
try:
|
return _svc_find_appid(modlist_name, install_dir)
|
||||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
|
||||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
|
||||||
|
|
||||||
platform_service = PlatformDetectionService.get_instance()
|
|
||||||
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
|
||||||
|
|
||||||
install_real = os.path.realpath(install_dir)
|
|
||||||
candidate_exes = [
|
|
||||||
os.path.join(install_real, "ModOrganizer.exe"),
|
|
||||||
os.path.join(install_real, "files", "ModOrganizer.exe"), # Somnium layout
|
|
||||||
]
|
|
||||||
|
|
||||||
for exe_path in candidate_exes:
|
|
||||||
if not os.path.exists(exe_path):
|
|
||||||
continue
|
|
||||||
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
|
|
||||||
if appid:
|
|
||||||
return appid
|
|
||||||
|
|
||||||
# Fallback: match by name + start dir from shortcuts.vdf even if exe moved
|
|
||||||
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
|
|
||||||
if (
|
|
||||||
(shortcut.get("AppName", "").strip() == modlist_name.strip())
|
|
||||||
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
|
|
||||||
):
|
|
||||||
raw_appid = shortcut.get("appid")
|
|
||||||
if raw_appid is not None:
|
|
||||||
return str(int(raw_appid) & 0xFFFFFFFF)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Update detection: failed shortcut lookup: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _prompt_update_or_new_install(
|
def _prompt_update_or_new_install(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
|
|
||||||
from .install_modlist_installer_thread import InstallerThread
|
from .install_modlist_installer_thread import InstallerThread
|
||||||
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
|
||||||
|
from jackify.backend.models.game_types import GAME_DISPLAY_NAMES, GAME_NAME_TO_TYPE
|
||||||
from jackify.shared.errors import install_dir_create_failed
|
from jackify.shared.errors import install_dir_create_failed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -307,57 +308,20 @@ class InstallWorkflowExecutionMixin:
|
|||||||
if result:
|
if result:
|
||||||
if isinstance(result, tuple):
|
if isinstance(result, tuple):
|
||||||
game_type, raw_game_type = result
|
game_type, raw_game_type = result
|
||||||
# Get display name for the game
|
|
||||||
display_names = {
|
|
||||||
'skyrim': 'Skyrim',
|
|
||||||
'fallout4': 'Fallout 4',
|
|
||||||
'falloutnv': 'Fallout New Vegas',
|
|
||||||
'oblivion': 'Oblivion',
|
|
||||||
'starfield': 'Starfield',
|
|
||||||
'oblivion_remastered': 'Oblivion Remastered',
|
|
||||||
'enderal': 'Enderal'
|
|
||||||
}
|
|
||||||
if game_type == 'unknown' and raw_game_type:
|
if game_type == 'unknown' and raw_game_type:
|
||||||
game_name = raw_game_type
|
game_name = raw_game_type
|
||||||
else:
|
else:
|
||||||
game_name = display_names.get(game_type, game_type)
|
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
|
||||||
else:
|
else:
|
||||||
game_type = result
|
game_type = result
|
||||||
display_names = {
|
game_name = GAME_DISPLAY_NAMES.get(game_type, game_type)
|
||||||
'skyrim': 'Skyrim',
|
|
||||||
'fallout4': 'Fallout 4',
|
|
||||||
'falloutnv': 'Fallout New Vegas',
|
|
||||||
'oblivion': 'Oblivion',
|
|
||||||
'starfield': 'Starfield',
|
|
||||||
'oblivion_remastered': 'Oblivion Remastered',
|
|
||||||
'enderal': 'Enderal'
|
|
||||||
}
|
|
||||||
game_name = display_names.get(game_type, game_type)
|
|
||||||
else:
|
else:
|
||||||
# For online modlists, try to get game type from selected modlist
|
# For online modlists, try to get game type from selected modlist
|
||||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||||
readme_url = self.selected_modlist_info.get('readme_url')
|
readme_url = self.selected_modlist_info.get('readme_url')
|
||||||
game_name = self.selected_modlist_info.get('game', '')
|
game_name = self.selected_modlist_info.get('game', '')
|
||||||
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
|
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
|
||||||
|
game_type = GAME_NAME_TO_TYPE.get(game_name.lower())
|
||||||
# Map game name to game type
|
|
||||||
game_mapping = {
|
|
||||||
'skyrim special edition': 'skyrim',
|
|
||||||
'skyrim': 'skyrim',
|
|
||||||
'fallout 4': 'fallout4',
|
|
||||||
'fallout new vegas': 'falloutnv',
|
|
||||||
'oblivion': 'oblivion',
|
|
||||||
'starfield': 'starfield',
|
|
||||||
'oblivion_remastered': 'oblivion_remastered',
|
|
||||||
'oblivion remastered': 'oblivion_remastered',
|
|
||||||
'enderal': 'enderal',
|
|
||||||
'enderal special edition': 'enderal',
|
|
||||||
'skyrim vr': 'skyrimvr',
|
|
||||||
'fallout 4 vr': 'fallout4vr',
|
|
||||||
'cyberpunk 2077': 'cp2077',
|
|
||||||
"baldur's gate 3": 'bg3',
|
|
||||||
}
|
|
||||||
game_type = game_mapping.get(game_name.lower())
|
|
||||||
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||||
if not game_type:
|
if not game_type:
|
||||||
game_type = 'unknown'
|
game_type = 'unknown'
|
||||||
@@ -504,7 +468,7 @@ class InstallWorkflowExecutionMixin:
|
|||||||
readme_url = readme_url.replace("raw.githubusercontent.com", "github.com")
|
readme_url = readme_url.replace("raw.githubusercontent.com", "github.com")
|
||||||
readme_url = readme_url.replace("/main/", "/blob/main/")
|
readme_url = readme_url.replace("/main/", "/blob/main/")
|
||||||
readme_url = readme_url.replace("/master/", "/blob/master/")
|
readme_url = readme_url.replace("/master/", "/blob/master/")
|
||||||
logger.info(f"Opening modlist readme: {readme_url}")
|
logger.info("Opening modlist readme: %s", readme_url)
|
||||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||||
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
|
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
|
||||||
@@ -512,6 +476,7 @@ class InstallWorkflowExecutionMixin:
|
|||||||
"Modlist readme opened in your browser. "
|
"Modlist readme opened in your browser. "
|
||||||
"Check it for any manual post-install steps before launching the game."
|
"Check it for any manual post-install steps before launching the game."
|
||||||
)
|
)
|
||||||
|
self._readme_url = readme_url or None
|
||||||
|
|
||||||
logger.debug(f"Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}")
|
logger.debug(f"Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}")
|
||||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||||
@@ -620,8 +585,9 @@ class InstallWorkflowExecutionMixin:
|
|||||||
concurrent_limit = max(1, min(5, concurrent_limit))
|
concurrent_limit = max(1, min(5, concurrent_limit))
|
||||||
|
|
||||||
self._safe_append_text(
|
self._safe_append_text(
|
||||||
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
|
f"\n[Manual Download Required] {count} file(s) need manual download "
|
||||||
f"Opening download dialog - check your taskbar if it does not appear in front.\n"
|
f"(rate limit, access error, or non-premium).\n"
|
||||||
|
f"Opening download dialog - it will appear in front momentarily.\n"
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[MDL-1006] Manual download protocol initialized | count={count} "
|
f"[MDL-1006] Manual download protocol initialized | count={count} "
|
||||||
@@ -663,3 +629,5 @@ class InstallWorkflowExecutionMixin:
|
|||||||
|
|
||||||
if not self._manual_dl_dialog.isVisible():
|
if not self._manual_dl_dialog.isVisible():
|
||||||
self._manual_dl_dialog.show()
|
self._manual_dl_dialog.show()
|
||||||
|
self._manual_dl_dialog.raise_()
|
||||||
|
self._manual_dl_dialog.activateWindow()
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error checking protontricks: {e}")
|
logger.error(f"Error checking protontricks: {e}")
|
||||||
MessageService.warning(self, "Protontricks Check Failed",
|
MessageService.warning(self, "Protontricks Check Failed",
|
||||||
f"Unable to verify protontricks installation: {e}\n\n"
|
f"Unable to verify protontricks installation: {e}\n\n"
|
||||||
"Continuing anyway, but some features may not work correctly.")
|
"Continuing anyway, but some features may not work correctly.")
|
||||||
return True # Continue anyway
|
return True # Continue anyway
|
||||||
@@ -305,14 +305,10 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
|||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
def cleanup_processes(self):
|
def cleanup_processes(self):
|
||||||
"""Clean up any running processes when the window closes or is cancelled"""
|
if hasattr(self, 'install_thread') and self.install_thread:
|
||||||
# Disconnect all signals first - prevents callbacks to a dying widget.
|
|
||||||
self._park_all_threads()
|
|
||||||
|
|
||||||
# install_thread gets a cooperative cancel signal on top of the park.
|
|
||||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
|
||||||
try:
|
try:
|
||||||
self.install_thread.cancel()
|
if self.install_thread.isRunning():
|
||||||
|
self.install_thread.cancel()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEd
|
|||||||
from PySide6.QtCore import Qt, QTimer, QSize
|
from PySide6.QtCore import Qt, QTimer, QSize
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont
|
||||||
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
|
||||||
|
from jackify.frontends.gui.services.message_service import open_url
|
||||||
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
|
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
|
||||||
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
|
||||||
|
|
||||||
@@ -110,7 +111,8 @@ class TTWUISetupMixin:
|
|||||||
)
|
)
|
||||||
instruction_text.setWordWrap(True)
|
instruction_text.setWordWrap(True)
|
||||||
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;")
|
||||||
instruction_text.setOpenExternalLinks(True)
|
instruction_text.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
|
instruction_text.linkActivated.connect(open_url)
|
||||||
user_config_vbox.addWidget(instruction_text)
|
user_config_vbox.addWidget(instruction_text)
|
||||||
|
|
||||||
# --- Compact Form Grid for inputs (align with other screens) ---
|
# --- Compact Form Grid for inputs (align with other screens) ---
|
||||||
|
|||||||
@@ -91,6 +91,52 @@ class InstallVerifierMixin:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("JContainers fix check failed (non-fatal): %s", e)
|
logger.warning("JContainers fix check failed (non-fatal): %s", e)
|
||||||
|
|
||||||
|
def _apply_problem_mods_disable(
|
||||||
|
self, install_dir: str, game_type: str, success_params: dict, appid: str = ""
|
||||||
|
) -> None:
|
||||||
|
"""Disable known-problematic mods and apply prefix fixes across all profiles."""
|
||||||
|
try:
|
||||||
|
from jackify.backend.services.problem_mods_service import (
|
||||||
|
disable_problem_mods,
|
||||||
|
create_prefix_dirs,
|
||||||
|
get_enabled_mods,
|
||||||
|
)
|
||||||
|
|
||||||
|
install_path = Path(install_dir)
|
||||||
|
all_disabled: list = []
|
||||||
|
all_enabled_mods: set = set()
|
||||||
|
for modlist_txt in install_path.glob("profiles/*/modlist.txt"):
|
||||||
|
disabled = disable_problem_mods(modlist_txt, game_type)
|
||||||
|
for name in disabled:
|
||||||
|
if name not in all_disabled:
|
||||||
|
all_disabled.append(name)
|
||||||
|
all_enabled_mods |= get_enabled_mods(modlist_txt)
|
||||||
|
|
||||||
|
if all_disabled:
|
||||||
|
logger.info(
|
||||||
|
"Disabled %d problem mod(s) for %s (%s): %s",
|
||||||
|
len(all_disabled),
|
||||||
|
success_params.get("modlist_name", ""),
|
||||||
|
game_type,
|
||||||
|
", ".join(all_disabled),
|
||||||
|
)
|
||||||
|
success_params["disabled_problem_mods"] = all_disabled
|
||||||
|
|
||||||
|
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
|
||||||
|
pfx = _resolve_pfx_for_appid(resolved_appid) if resolved_appid else None
|
||||||
|
if pfx and all_enabled_mods:
|
||||||
|
created = create_prefix_dirs(pfx, game_type, all_enabled_mods)
|
||||||
|
if created:
|
||||||
|
logger.info(
|
||||||
|
"Created %d prefix dir(s) for %s (%s): %s",
|
||||||
|
len(created),
|
||||||
|
success_params.get("modlist_name", ""),
|
||||||
|
game_type,
|
||||||
|
", ".join(created),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Problem mods fix check failed (non-fatal): %s", e)
|
||||||
|
|
||||||
def _run_verifier_then_show_success(
|
def _run_verifier_then_show_success(
|
||||||
self,
|
self,
|
||||||
install_dir: str,
|
install_dir: str,
|
||||||
@@ -105,6 +151,7 @@ class InstallVerifierMixin:
|
|||||||
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
|
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
|
||||||
"""
|
"""
|
||||||
self._maybe_apply_jcontainers_fix(install_dir, game_type)
|
self._maybe_apply_jcontainers_fix(install_dir, game_type)
|
||||||
|
self._apply_problem_mods_disable(install_dir, game_type, success_params, appid)
|
||||||
if hasattr(self, "progress_indicator"):
|
if hasattr(self, "progress_indicator"):
|
||||||
self.progress_indicator.set_status("Verifying installation...", 100)
|
self.progress_indicator.set_status("Verifying installation...", 100)
|
||||||
if hasattr(self, "file_progress_list"):
|
if hasattr(self, "file_progress_list"):
|
||||||
@@ -170,6 +217,8 @@ class InstallVerifierMixin:
|
|||||||
time_taken=params["time_taken"],
|
time_taken=params["time_taken"],
|
||||||
game_name=params.get("game_name"),
|
game_name=params.get("game_name"),
|
||||||
verification_results=verification_results,
|
verification_results=verification_results,
|
||||||
|
disabled_problem_mods=params.get("disabled_problem_mods"),
|
||||||
|
readme_url=params.get("readme_url"),
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
dlg.show()
|
dlg.show()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Enhanced Modlist Gallery Screen for Jackify GUI.
|
|||||||
Provides visual browsing, filtering, and selection of modlists using
|
Provides visual browsing, filtering, and selection of modlists using
|
||||||
rich metadata from jackify-engine.
|
rich metadata from jackify-engine.
|
||||||
"""
|
"""
|
||||||
|
import warnings
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout,
|
QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout,
|
||||||
@@ -255,23 +256,27 @@ class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixi
|
|||||||
if timer is not None:
|
if timer is not None:
|
||||||
timer.stop()
|
timer.stop()
|
||||||
|
|
||||||
|
# Kill any in-progress engine subprocess so the loader thread exits
|
||||||
|
# naturally and releases the engine call lock.
|
||||||
|
if hasattr(self, 'gallery_service'):
|
||||||
|
self.gallery_service.cancel()
|
||||||
|
|
||||||
for attr in ('_loader_thread', '_validation_thread'):
|
for attr in ('_loader_thread', '_validation_thread'):
|
||||||
thread = getattr(self, attr, None)
|
thread = getattr(self, attr, None)
|
||||||
if thread is None:
|
if thread is None:
|
||||||
continue
|
continue
|
||||||
# Disconnect all signals before terminating - prevents callbacks into
|
with warnings.catch_warnings():
|
||||||
# a partially-destroyed dialog
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
try:
|
try:
|
||||||
thread.disconnect()
|
thread.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if thread.isRunning():
|
if thread.isRunning():
|
||||||
# terminate() is required here: these threads run plain Python code
|
# Give the thread time to exit naturally after cancel().
|
||||||
# with no Qt event loop, so quit() is a no-op and wait() alone
|
# Only fall back to terminate() if it is still stuck after that.
|
||||||
# would time out, leaving the thread running when the C++ QThread
|
if not thread.wait(2000):
|
||||||
# object is destroyed (which aborts the process).
|
thread.terminate()
|
||||||
thread.terminate()
|
thread.wait(1000)
|
||||||
thread.wait(3000)
|
|
||||||
|
|
||||||
# Abort any pending image network requests
|
# Abort any pending image network requests
|
||||||
if hasattr(self, 'image_manager'):
|
if hasattr(self, 'image_manager'):
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
|
|||||||
from jackify.backend.models.configuration import SystemInfo
|
from jackify.backend.models.configuration import SystemInfo
|
||||||
from ..shared_theme import JACKIFY_COLOR_BLUE
|
from ..shared_theme import JACKIFY_COLOR_BLUE
|
||||||
from ..utils import set_responsive_minimum
|
from ..utils import set_responsive_minimum
|
||||||
|
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
DEBUG_BORDERS = False
|
DEBUG_BORDERS = False
|
||||||
@@ -35,7 +36,7 @@ DEBUG_BORDERS = False
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ModlistTasksScreen(QWidget):
|
class ModlistTasksScreen(ThreadLifecycleMixin, QWidget):
|
||||||
"""
|
"""
|
||||||
Migrated Modlist Tasks screen that uses backend services directly.
|
Migrated Modlist Tasks screen that uses backend services directly.
|
||||||
|
|
||||||
@@ -233,5 +234,4 @@ class ModlistTasksScreen(QWidget):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources when the screen is closed"""
|
self._park_all_threads()
|
||||||
pass
|
|
||||||
@@ -8,6 +8,7 @@ the cards are rebuilt and version checks restart.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QThread, Signal
|
from PySide6.QtCore import Qt, QThread, Signal
|
||||||
@@ -74,6 +75,22 @@ class _ToolActionThread(QThread):
|
|||||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||||
|
|
||||||
|
|
||||||
|
class _ArchiveInstallThread(QThread):
|
||||||
|
finished_signal = Signal(str, bool, str) # tool_id, success, message
|
||||||
|
|
||||||
|
def __init__(self, tool_id: str, archive_path: Path):
|
||||||
|
super().__init__()
|
||||||
|
self._tool_id = tool_id
|
||||||
|
self._archive_path = archive_path
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
ok, msg = ToolRegistry().install_from_archive(self._tool_id, self._archive_path)
|
||||||
|
except Exception as e:
|
||||||
|
ok, msg = False, str(e)
|
||||||
|
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||||
|
|
||||||
|
|
||||||
class _ManifestFetchThread(QThread):
|
class _ManifestFetchThread(QThread):
|
||||||
manifest_ready = Signal(list) # List[ToolDefinition]
|
manifest_ready = Signal(list) # List[ToolDefinition]
|
||||||
|
|
||||||
@@ -258,7 +275,7 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
|||||||
|
|
||||||
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
|
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
|
||||||
current_ids = set(self._cards.keys())
|
current_ids = set(self._cards.keys())
|
||||||
new_ids = {d.tool_id for d in definitions}
|
new_ids = {d.tool_id for d in definitions if not d.hidden}
|
||||||
apply_remote_manifest(definitions)
|
apply_remote_manifest(definitions)
|
||||||
if current_ids != new_ids:
|
if current_ids != new_ids:
|
||||||
if self._version_thread and self._version_thread.isRunning():
|
if self._version_thread and self._version_thread.isRunning():
|
||||||
@@ -336,6 +353,13 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
|||||||
def _on_action_finished(self, tool_id: str, success: bool, message: str):
|
def _on_action_finished(self, tool_id: str, success: bool, message: str):
|
||||||
self._action_thread = None
|
self._action_thread = None
|
||||||
card = self._cards.get(tool_id)
|
card = self._cards.get(tool_id)
|
||||||
|
|
||||||
|
if not success and message.startswith("NEXUS_MANUAL_REQUIRED:"):
|
||||||
|
if card:
|
||||||
|
card.set_busy(False)
|
||||||
|
self._start_nexus_manual_install(tool_id, message[len("NEXUS_MANUAL_REQUIRED:"):])
|
||||||
|
return
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
status = ToolRegistry().get_status(tool_id)
|
status = ToolRegistry().get_status(tool_id)
|
||||||
if status and status.installed and card:
|
if status and status.installed and card:
|
||||||
@@ -351,6 +375,23 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
|||||||
card.set_busy(False)
|
card.set_busy(False)
|
||||||
MessageService.warning(self, "Failed", message)
|
MessageService.warning(self, "Failed", message)
|
||||||
|
|
||||||
|
def _start_nexus_manual_install(self, tool_id: str, nexus_url: str) -> None:
|
||||||
|
from jackify.frontends.gui.dialogs.nexus_manual_install_dialog import NexusManualInstallDialog
|
||||||
|
defn = next((d for d in get_effective_definitions() if d.tool_id == tool_id), None)
|
||||||
|
display_name = defn.display_name if defn else tool_id
|
||||||
|
dlg = NexusManualInstallDialog(tool_id, display_name, nexus_url, parent=self)
|
||||||
|
if dlg.exec() != NexusManualInstallDialog.Accepted:
|
||||||
|
return
|
||||||
|
archive = dlg.selected_archive
|
||||||
|
if not archive:
|
||||||
|
return
|
||||||
|
card = self._cards.get(tool_id)
|
||||||
|
if card:
|
||||||
|
card.set_busy(True, "Installing...")
|
||||||
|
self._action_thread = _ArchiveInstallThread(tool_id, archive)
|
||||||
|
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||||
|
self._action_thread.start()
|
||||||
|
|
||||||
def _on_update_all(self):
|
def _on_update_all(self):
|
||||||
updates = [tid for tid, card in self._cards.items()
|
updates = [tid for tid, card in self._cards.items()
|
||||||
if card._status.installed and card._status.update_available]
|
if card._status.installed and card._status.update_available]
|
||||||
@@ -389,6 +430,9 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
|||||||
status = ToolRegistry().get_status(tool_id)
|
status = ToolRegistry().get_status(tool_id)
|
||||||
if not status:
|
if not status:
|
||||||
return
|
return
|
||||||
|
if not status.definition.github_repo:
|
||||||
|
MessageService.warning(self, "Change Version", "Version selection is only available for GitHub-hosted tools.")
|
||||||
|
return
|
||||||
if card:
|
if card:
|
||||||
card.set_busy(True, "Fetching releases...")
|
card.set_busy(True, "Fetching releases...")
|
||||||
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
|
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
|
||||||
@@ -465,5 +509,3 @@ class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
|||||||
if self.stacked_widget:
|
if self.stacked_widget:
|
||||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||||
|
|
||||||
def cleanup_processes(self):
|
|
||||||
self._park_all_threads()
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Engines show Set Active / Active badge; tools with can_launch show Launch.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer, Signal
|
from PySide6.QtCore import Qt, QTimer, Signal
|
||||||
@@ -16,7 +15,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id
|
from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id
|
||||||
from jackify.frontends.gui.services.message_service import MessageService
|
from jackify.frontends.gui.services.message_service import MessageService, open_url
|
||||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,6 +27,12 @@ _C_SET_ACTIVE = "#4a5568"
|
|||||||
_C_BACK = "#4a5568"
|
_C_BACK = "#4a5568"
|
||||||
_C_DISABLED = "#333"
|
_C_DISABLED = "#333"
|
||||||
|
|
||||||
|
_STYLE_BTN_INVISIBLE = (
|
||||||
|
"QPushButton { background: transparent; border: none; color: transparent; "
|
||||||
|
"font-size: 11px; font-weight: bold; padding: 4px 8px; min-width: 90px; }"
|
||||||
|
"QPushButton:hover { background: transparent; }"
|
||||||
|
)
|
||||||
|
|
||||||
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
|
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
|
||||||
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
|
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
|
||||||
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
||||||
@@ -78,7 +83,18 @@ class ToolCard(QFrame):
|
|||||||
|
|
||||||
info_col = QVBoxLayout()
|
info_col = QVBoxLayout()
|
||||||
info_col.setSpacing(2)
|
info_col.setSpacing(2)
|
||||||
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
|
url = status.definition.upstream_url
|
||||||
|
if url:
|
||||||
|
name_html = (
|
||||||
|
f'<a href="{url}" style="color: #e0e0e0; text-decoration: none; font-weight: bold;">'
|
||||||
|
f'{status.definition.display_name}</a>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
name_html = f"<b>{status.definition.display_name}</b>"
|
||||||
|
self._name_label = QLabel(name_html)
|
||||||
|
self._name_label.setTextFormat(Qt.RichText)
|
||||||
|
self._name_label.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
|
self._name_label.linkActivated.connect(self._open_url)
|
||||||
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||||
info_col.addWidget(self._name_label)
|
info_col.addWidget(self._name_label)
|
||||||
desc_label = QLabel(status.definition.description)
|
desc_label = QLabel(status.definition.description)
|
||||||
@@ -118,7 +134,6 @@ class ToolCard(QFrame):
|
|||||||
btn_col.addWidget(self._btn_primary)
|
btn_col.addWidget(self._btn_primary)
|
||||||
self._btn_update = QPushButton("Update")
|
self._btn_update = QPushButton("Update")
|
||||||
self._btn_update.setFixedWidth(100)
|
self._btn_update.setFixedWidth(100)
|
||||||
self._btn_update.setVisible(False)
|
|
||||||
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
|
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
|
||||||
btn_col.addWidget(self._btn_update)
|
btn_col.addWidget(self._btn_update)
|
||||||
self._btn_more = QPushButton("...")
|
self._btn_more = QPushButton("...")
|
||||||
@@ -150,7 +165,10 @@ class ToolCard(QFrame):
|
|||||||
self._btn_primary.setText(self._busy_label or "Working...")
|
self._btn_primary.setText(self._busy_label or "Working...")
|
||||||
self._btn_primary.setEnabled(False)
|
self._btn_primary.setEnabled(False)
|
||||||
self._btn_primary.setVisible(True)
|
self._btn_primary.setVisible(True)
|
||||||
self._btn_update.setVisible(False)
|
self._btn_update.setStyleSheet(
|
||||||
|
_STYLE_BTN_INVISIBLE
|
||||||
|
)
|
||||||
|
self._btn_update.setEnabled(False)
|
||||||
self._btn_more.setEnabled(False)
|
self._btn_more.setEnabled(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -170,9 +188,7 @@ class ToolCard(QFrame):
|
|||||||
|
|
||||||
iv = self._status.installed_version or "-"
|
iv = self._status.installed_version or "-"
|
||||||
lv = self._status.latest_version or "checking..."
|
lv = self._status.latest_version or "checking..."
|
||||||
self._version_label.setText(
|
self._version_label.setText(f"Installed: {iv}\nLatest: {lv}")
|
||||||
f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not installed:
|
if not installed:
|
||||||
self._btn_primary.setText("Install")
|
self._btn_primary.setText("Install")
|
||||||
@@ -197,9 +213,14 @@ class ToolCard(QFrame):
|
|||||||
else:
|
else:
|
||||||
self._btn_primary.setVisible(False)
|
self._btn_primary.setVisible(False)
|
||||||
|
|
||||||
self._btn_update.setVisible(installed and update_avail and not self._busy)
|
if installed and update_avail and not self._busy:
|
||||||
if installed and update_avail:
|
|
||||||
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
|
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
|
||||||
|
self._btn_update.setEnabled(True)
|
||||||
|
else:
|
||||||
|
self._btn_update.setStyleSheet(
|
||||||
|
_STYLE_BTN_INVISIBLE
|
||||||
|
)
|
||||||
|
self._btn_update.setEnabled(False)
|
||||||
self._btn_more.setEnabled(not self._busy)
|
self._btn_more.setEnabled(not self._busy)
|
||||||
|
|
||||||
def set_latest_version(self, tag: str) -> bool:
|
def set_latest_version(self, tag: str) -> bool:
|
||||||
@@ -289,6 +310,9 @@ class ToolCard(QFrame):
|
|||||||
else:
|
else:
|
||||||
self._launch()
|
self._launch()
|
||||||
|
|
||||||
|
def _open_url(self, url: str):
|
||||||
|
open_url(url)
|
||||||
|
|
||||||
def _launch(self):
|
def _launch(self):
|
||||||
binary = ToolRegistry().get_binary_path(self._tool_id)
|
binary = ToolRegistry().get_binary_path(self._tool_id)
|
||||||
if not binary:
|
if not binary:
|
||||||
@@ -313,13 +337,18 @@ class ToolCard(QFrame):
|
|||||||
"QMenu::item:disabled { color: #555; }"
|
"QMenu::item:disabled { color: #555; }"
|
||||||
)
|
)
|
||||||
defn = self._status.definition
|
defn = self._status.definition
|
||||||
|
upstream_action = menu.addAction("Open Website")
|
||||||
|
upstream_action.setEnabled(bool(defn.upstream_url))
|
||||||
|
menu.addSeparator()
|
||||||
downgrade_action = menu.addAction("Change Version")
|
downgrade_action = menu.addAction("Change Version")
|
||||||
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
|
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
|
||||||
uninstall_action = menu.addAction("Uninstall")
|
uninstall_action = menu.addAction("Uninstall")
|
||||||
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
|
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
|
||||||
|
|
||||||
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
|
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
|
||||||
if chosen == downgrade_action and downgrade_action.isEnabled():
|
if chosen == upstream_action and defn.upstream_url:
|
||||||
|
self._open_url(defn.upstream_url)
|
||||||
|
elif chosen == downgrade_action and downgrade_action.isEnabled():
|
||||||
self.action_requested.emit(self._tool_id, "downgrade")
|
self.action_requested.emit(self._tool_id, "downgrade")
|
||||||
elif chosen == uninstall_action and uninstall_action.isEnabled():
|
elif chosen == uninstall_action and uninstall_action.isEnabled():
|
||||||
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
|
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from ..utils import set_responsive_minimum, browse_directory
|
|||||||
from ..widgets.file_progress_list import FileProgressList
|
from ..widgets.file_progress_list import FileProgressList
|
||||||
from ..widgets.progress_indicator import OverallProgressIndicator
|
from ..widgets.progress_indicator import OverallProgressIndicator
|
||||||
from .screen_back_mixin import ScreenBackMixin
|
from .screen_back_mixin import ScreenBackMixin
|
||||||
|
from ..mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ class WabbajackInstallerWorker(QThread):
|
|||||||
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
|
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
|
||||||
|
|
||||||
|
|
||||||
class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
class WabbajackInstallerScreen(ThreadLifecycleMixin, ScreenBackMixin, FocusReclaimMixin, QWidget):
|
||||||
"""Wabbajack installer GUI screen following standard Jackify layout"""
|
"""Wabbajack installer GUI screen following standard Jackify layout"""
|
||||||
|
|
||||||
resize_request = Signal(str)
|
resize_request = Signal(str)
|
||||||
@@ -389,14 +390,13 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
|||||||
# Get shortcut name
|
# Get shortcut name
|
||||||
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
|
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
|
||||||
|
|
||||||
# Confirm with user (standard dialog - no safety countdown needed for this operation)
|
|
||||||
confirm = MessageService.question(
|
confirm = MessageService.question(
|
||||||
self,
|
self,
|
||||||
"Confirm Installation",
|
"Confirm Installation",
|
||||||
f"Install Wabbajack to:\n{self.install_folder}\n\n"
|
f"Install Wabbajack to:\n{self.install_folder}\n\n"
|
||||||
"This will download Wabbajack, add to Steam, install WebView2,\n"
|
"This will download Wabbajack, add to Steam, install WebView2,\n"
|
||||||
"and configure the Wine prefix automatically.\n\n"
|
"and configure the Wine prefix automatically.\n\n"
|
||||||
"Steam will be restarted during installation.\n\n"
|
"Warning: Steam will be restarted during installation - this will close any running game.\n\n"
|
||||||
"Continue?",
|
"Continue?",
|
||||||
safety_level="medium",
|
safety_level="medium",
|
||||||
)
|
)
|
||||||
@@ -620,15 +620,6 @@ class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
|
|||||||
|
|
||||||
def cleanup_processes(self):
|
def cleanup_processes(self):
|
||||||
self._stop_focus_reclaim()
|
self._stop_focus_reclaim()
|
||||||
if self.worker is not None:
|
|
||||||
try:
|
|
||||||
if self.worker.isRunning():
|
|
||||||
self.worker.requestInterruption()
|
|
||||||
self.worker.wait(5000)
|
|
||||||
self.worker.deleteLater()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.worker = None
|
|
||||||
|
|
||||||
def showEvent(self, event):
|
def showEvent(self, event):
|
||||||
"""Called when widget becomes visible"""
|
"""Called when widget becomes visible"""
|
||||||
|
|||||||
@@ -3,9 +3,31 @@ Non-Focus-Stealing Message Service for Jackify
|
|||||||
Provides message boxes that don't steal focus from the current application
|
Provides message boxes that don't steal focus from the current application
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
import subprocess
|
||||||
|
import warnings
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def open_url(url: str) -> None:
|
||||||
|
"""Open a URL in the system browser, safe to call from within an AppImage."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
if "APPIMAGE" in env or "APPDIR" in env:
|
||||||
|
for var in ("LD_LIBRARY_PATH", "PYTHONPATH", "PYTHONHOME", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH"):
|
||||||
|
env.pop(var, None)
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["xdg-open", url], env=env,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to open URL %s: %s", url, e)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout,
|
QMessageBox, QWidget, QLineEdit, QLabel, QVBoxLayout, QHBoxLayout,
|
||||||
QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy,
|
QCheckBox, QTextEdit, QPushButton, QDialog, QDialogButtonBox, QSizePolicy,
|
||||||
@@ -61,14 +83,16 @@ class SafeMessageBox(NonFocusMessageBox):
|
|||||||
self._setup_low_safety(danger_action, safe_action)
|
self._setup_low_safety(danger_action, safe_action)
|
||||||
# --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons ---
|
# --- Fix: For question dialogs, set proceed/cancel button return values, but do NOT call setStandardButtons ---
|
||||||
if is_question and hasattr(self, 'proceed_btn'):
|
if is_question and hasattr(self, 'proceed_btn'):
|
||||||
self.proceed_btn.setText(danger_action)
|
with warnings.catch_warnings():
|
||||||
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
self.proceed_btn.clicked.disconnect()
|
self.proceed_btn.setText(danger_action)
|
||||||
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
|
self.proceed_btn.setProperty('role', QMessageBox.YesRole)
|
||||||
self.cancel_btn.setText(safe_action)
|
self.proceed_btn.clicked.disconnect()
|
||||||
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
|
self.proceed_btn.clicked.connect(lambda: self.done(QMessageBox.Yes))
|
||||||
self.cancel_btn.clicked.disconnect()
|
self.cancel_btn.setText(safe_action)
|
||||||
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
|
self.cancel_btn.setProperty('role', QMessageBox.NoRole)
|
||||||
|
self.cancel_btn.clicked.disconnect()
|
||||||
|
self.cancel_btn.clicked.connect(lambda: self.done(QMessageBox.No))
|
||||||
|
|
||||||
def _setup_high_safety(self, danger_action: str, safe_action: str):
|
def _setup_high_safety(self, danger_action: str, safe_action: str):
|
||||||
"""High safety: requires typing confirmation code"""
|
"""High safety: requires typing confirmation code"""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ worker thread management, and completion callbacks.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
@@ -301,6 +302,8 @@ class VNVAutomationController(QObject):
|
|||||||
dialog.load_items(manager.items)
|
dialog.load_items(manager.items)
|
||||||
dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state))
|
dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state))
|
||||||
dialog.show()
|
dialog.show()
|
||||||
|
dialog.raise_()
|
||||||
|
dialog.activateWindow()
|
||||||
|
|
||||||
def _cancel_manual_download_flow(self, on_complete, state: dict) -> None:
|
def _cancel_manual_download_flow(self, on_complete, state: dict) -> None:
|
||||||
if state["done"]:
|
if state["done"]:
|
||||||
@@ -342,10 +345,12 @@ class VNVAutomationController(QObject):
|
|||||||
self._manual_dialog = None
|
self._manual_dialog = None
|
||||||
self._manual_manager = None
|
self._manual_manager = None
|
||||||
if dialog is not None:
|
if dialog is not None:
|
||||||
try:
|
with warnings.catch_warnings():
|
||||||
dialog.finished.disconnect()
|
warnings.simplefilter("ignore", RuntimeWarning)
|
||||||
except Exception:
|
try:
|
||||||
pass
|
dialog.finished.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
dialog.close()
|
dialog.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ R&D NOTE: This is experimental code for investigation purposes.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import logging
|
||||||
import shiboken6
|
import shiboken6
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||||
QHBoxLayout, QSizePolicy
|
QHBoxLayout, QSizePolicy
|
||||||
@@ -113,7 +116,7 @@ class _CpuWorker(QThread):
|
|||||||
def _debug_log(message):
|
def _debug_log(message):
|
||||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||||
if ConfigHandler().get('debug_mode', False):
|
if ConfigHandler().get('debug_mode', False):
|
||||||
print(message)
|
logger.debug(message)
|
||||||
|
|
||||||
|
|
||||||
class FileProgressList(QWidget):
|
class FileProgressList(QWidget):
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -667,7 +667,7 @@ _LEGACY_GAME_TYPES = {"falloutnv", "fallout3", "oblivion"}
|
|||||||
def _is_nsf_prefix(pfx: Path) -> bool:
|
def _is_nsf_prefix(pfx: Path) -> bool:
|
||||||
"""Return True if this prefix has global mscoree=native in Wine DllOverrides.
|
"""Return True if this prefix has global mscoree=native in Wine DllOverrides.
|
||||||
|
|
||||||
All Skyrim prefixes get a per-exe AppDefaults\\SkyrimSE.exe mscoree override —
|
All Skyrim prefixes get a per-exe AppDefaults\\SkyrimSE.exe mscoree override -
|
||||||
that does not qualify. NSF/CSF prefixes additionally have it in the global
|
that does not qualify. NSF/CSF prefixes additionally have it in the global
|
||||||
DllOverrides section, which is what this checks.
|
DllOverrides section, which is what this checks.
|
||||||
"""
|
"""
|
||||||
@@ -686,7 +686,7 @@ def _is_nsf_prefix(pfx: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def check_native_dotnet(pfx: Path, r: Results, game_type: str = ""):
|
def check_native_dotnet(pfx: Path, r: Results, game_type: str = ""):
|
||||||
"""Check native dotnet40/48 installation — only meaningful for NSF/CSF prefixes.
|
"""Check native dotnet40/48 installation - only meaningful for NSF/CSF prefixes.
|
||||||
|
|
||||||
Non-NSF prefixes always have phantom NDP keys written by Wine Mono; checking
|
Non-NSF prefixes always have phantom NDP keys written by Wine Mono; checking
|
||||||
them produces false positives. Skip entirely unless global mscoree=native is set.
|
them produces false positives. Skip entirely unless global mscoree=native is set.
|
||||||
@@ -1079,7 +1079,7 @@ def check_ttw_installation(modlist_dir: Path, game_type: str, r: Results, modlis
|
|||||||
|
|
||||||
def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
|
||||||
"""Check whether Tool Compatibility Config has been applied to this prefix."""
|
"""Check whether Tool Compatibility Config has been applied to this prefix."""
|
||||||
# Tool compat is not applied for these game types — nothing to check
|
# Tool compat is not applied for these game types - nothing to check
|
||||||
_no_tool_compat = ("falloutnv", "fallout3", "enderal", "cp2077", "bg3", "skyrimvr", "fallout4vr")
|
_no_tool_compat = ("falloutnv", "fallout3", "enderal", "cp2077", "bg3", "skyrimvr", "fallout4vr")
|
||||||
if game_type in _no_tool_compat:
|
if game_type in _no_tool_compat:
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ requests>=2.25.0
|
|||||||
tqdm>=4.65.0
|
tqdm>=4.65.0
|
||||||
|
|
||||||
# OAuth token encryption (pure-Python compatible)
|
# OAuth token encryption (pure-Python compatible)
|
||||||
pycryptodome>=3.19.0
|
pycryptodomex>=3.19.0
|
||||||
|
|
||||||
# Configuration file handling
|
# Configuration file handling
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
|
|||||||
Reference in New Issue
Block a user