Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
@@ -1,164 +1,269 @@
"""
Configure Modlist Command
"""Configure Modlist Command: CLI command for configuring a modlist post-install."""
CLI command for configuring a modlist post-install.
Extracted from the original jackify-cli.py.
"""
import os
import logging
import os
import threading
from pathlib import Path
from typing import Optional
# Import the backend services we'll need
from jackify.backend.models.configuration import ConfigurationContext
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
from jackify.shared.colors import (
COLOR_INFO,
COLOR_ERROR,
COLOR_WARNING,
COLOR_PROMPT,
COLOR_RESET,
COLOR_SUCCESS,
)
logger = logging.getLogger(__name__)
class ConfigureModlistCommand:
"""Handler for the configure-modlist CLI command."""
def __init__(self, backend_services):
"""Initialize with backend services.
Args:
backend_services: Dictionary of backend service instances
"""
self.backend_services = backend_services
self.test_mode = False # TODO: Get from global config
def add_parser(self, subparsers):
"""Add the configure-modlist subcommand parser.
Args:
subparsers: The ArgumentParser subparsers object
"""
configure_modlist_parser = subparsers.add_parser(
"configure-modlist",
help="Configure a modlist post-install (for GUI integration)"
)
configure_modlist_parser.add_argument(
"--modlist-name",
type=str,
required=True,
help="Name of the modlist to configure (Steam shortcut name)"
)
configure_modlist_parser.add_argument(
"--install-dir",
type=str,
required=True,
help="Install directory of the modlist"
)
configure_modlist_parser.add_argument(
"--download-dir",
type=str,
help="Downloads directory (optional)"
)
configure_modlist_parser.add_argument(
"--nexus-api-key",
type=str,
help="Nexus API key (optional)"
)
configure_modlist_parser.add_argument(
"--mo2-exe-path",
type=str,
help="Path to ModOrganizer.exe (for AppID lookup)"
)
configure_modlist_parser.add_argument(
"--resolution",
type=str,
help="Resolution to set (optional)"
)
configure_modlist_parser.add_argument(
"--skip-confirmation",
action='store_true',
help="Skip confirmation prompts"
)
return configure_modlist_parser
def execute(self, args) -> int:
"""Execute the configure-modlist command.
Args:
args: Parsed command-line arguments
Returns:
Exit code (0 for success, 1 for failure)
"""
logger.info("Starting non-interactive modlist configuration (CLI mode)")
try:
# Build configuration context from args
context = self._build_context_from_args(args)
# Use legacy implementation for now - will migrate to backend services later
def __init__(self, backend_services):
self.backend_services = backend_services
self.test_mode = False
def add_parser(self, subparsers):
p = subparsers.add_parser(
"configure-modlist",
help="Configure a modlist post-install (for GUI integration)",
)
p.add_argument("--modlist-name", type=str, required=True,
help="Name of the modlist to configure (Steam shortcut name)")
p.add_argument("--install-dir", type=str, required=True,
help="Install directory of the modlist")
p.add_argument("--download-dir", type=str, help="Downloads directory (optional)")
p.add_argument("--nexus-api-key", type=str, help="Nexus API key (optional)")
p.add_argument("--mo2-exe-path", type=str,
help="Path to ModOrganizer.exe (for AppID lookup)")
p.add_argument("--resolution", type=str, help="Resolution to set (optional)")
p.add_argument("--skip-confirmation", action="store_true",
help="Skip confirmation prompts")
return p
def execute(self, args) -> int:
logger.info("Starting non-interactive modlist configuration (CLI mode)")
try:
context = self._build_context_from_args(args)
result = self._execute_legacy_configuration(context)
if result is not True:
logger.info("Finished non-interactive modlist configuration")
return 1
logger.info("Finished non-interactive modlist configuration")
if not getattr(args, 'skip_confirmation', False) and context.get('install_dir'):
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
prompt_ttw_if_eligible(context['install_dir'], context.get('modlist_name') or '')
install_dir = context.get("install_dir", "") or ""
modlist_name = context.get("modlist_name", "") or ""
skip_confirm = bool(context.get("skip_confirmation", False))
if not install_dir:
return 0
game_type = self._detect_game_type(install_dir)
app_id = self._lookup_app_id(install_dir)
self._run_post_configure_hooks(
install_dir, modlist_name, game_type, app_id, skip_confirm
)
return 0
return 0 if result is True else 1
except Exception as e:
logger.error(f"Failed to configure modlist: {e}")
logger.error("Failed to configure modlist: %s", e)
print(f"{COLOR_ERROR}Configuration failed: {e}{COLOR_RESET}")
return 1
# ------------------------------------------------------------------
# Post-configure hooks (mirrors configuration_phase in install path)
# ------------------------------------------------------------------
def _run_post_configure_hooks(
self,
install_dir: str,
modlist_name: str,
game_type: str,
app_id: Optional[str],
skip_confirm: bool,
) -> None:
# TTW
try:
from jackify.backend.handlers.modlist_install_cli_ttw import prompt_ttw_if_eligible
prompt_ttw_if_eligible(install_dir, modlist_name)
except Exception as e:
logger.error("TTW post-install prompt failed: %s", e, exc_info=True)
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
# VNV
try:
from jackify.backend.services.vnv_integration_helper import (
run_vnv_automation_if_applicable,
should_offer_vnv_automation,
)
if should_offer_vnv_automation(modlist_name, Path(install_dir)):
def _confirm_vnv(description: str) -> bool:
print(f"\n{description}\n")
try:
ans = input(f"{COLOR_PROMPT}Run VNV post-install automation now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
return ans in ("", "y", "yes")
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=Path(install_dir),
game_root=None,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=print,
manual_file_callback=None,
confirmation_callback=_confirm_vnv,
)
if automation_ran and not vnv_error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if vnv_error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {vnv_error}{COLOR_RESET}")
except Exception as e:
logger.error("VNV post-install automation failed: %s", e, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
# JContainers
try:
from jackify.backend.handlers.modlist_fixup_handler import (
check_jcontainers_needs_fix,
apply_jcontainers_fix,
)
if check_jcontainers_needs_fix(Path(install_dir), game_type):
print(f"\n{COLOR_WARNING}JContainers Compatibility Fix{COLOR_RESET}")
print(f"{COLOR_INFO}The mod JContainers has been detected. The Nexusmods version is known to cause crashes on Linux/Proton.{COLOR_RESET}")
print(f"{COLOR_INFO}A fixed version is available from the mod's GitHub page. The original DLL will be backed up first.{COLOR_RESET}")
try:
user_input = input(f"{COLOR_PROMPT}Apply JContainers fix now? (Y/n): {COLOR_RESET}").strip().lower()
except (EOFError, KeyboardInterrupt):
user_input = "n"
if user_input in ("", "y", "yes"):
apply_jcontainers_fix(Path(install_dir), game_type)
print(f"{COLOR_INFO}JContainers fix applied.{COLOR_RESET}")
else:
print(f"{COLOR_INFO}JContainers fix skipped.{COLOR_RESET}")
except Exception as e:
logger.warning("JContainers fix check failed (non-fatal): %s", e)
# Steam artwork
if app_id:
try:
from jackify.backend.handlers.modlist_handler import ModlistHandler
ModlistHandler().set_steam_grid_images(str(app_id), install_dir, game_type=game_type)
logger.info("Steam artwork applied for app_id %s", app_id)
except Exception as e:
logger.warning("Steam artwork failed: %s", e)
# Install verification
try:
from jackify.backend.services.install_verifier_service import (
run_install_verification,
resolve_pfx_for_appid,
)
from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus
_pfx = resolve_pfx_for_appid(str(app_id)) if app_id else None
if _pfx and _pfx.is_dir():
_verif_result: list = [None]
_spinner = CliIndeterminateStatus()
_spinner.set("Running install verification...")
def _verif_worker() -> None:
_verif_result[0] = run_install_verification(
_pfx,
Path(install_dir),
game_type or "",
str(app_id) if app_id else "",
modlist_name,
)
t = threading.Thread(target=_verif_worker, daemon=True)
t.start()
t.join()
_spinner.stop()
r = _verif_result[0]
if r is not None:
n_pass = len(r.passes) if hasattr(r, "passes") else 0
n_warn = len(r.warnings) if hasattr(r, "warnings") else 0
n_fail = len(r.failures) if hasattr(r, "failures") else 0
print(f"{COLOR_INFO}Install verification: {n_pass} passed, {n_warn} warnings, {n_fail} failed{COLOR_RESET}")
if hasattr(r, "failures"):
for msg in r.failures:
print(f" {COLOR_WARNING}[FAIL] {msg}{COLOR_RESET}")
if hasattr(r, "warnings"):
for msg in r.warnings:
print(f" {COLOR_INFO}[WARN] {msg}{COLOR_RESET}")
except Exception as e:
logger.warning("Install verification failed: %s", e)
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _detect_game_type(self, install_dir: str) -> str:
try:
ini = os.path.join(install_dir, "ModOrganizer.ini")
if os.path.isfile(ini):
from jackify.backend.handlers.modlist_handler import ModlistHandler
handler = ModlistHandler({})
handler.modlist_ini = ini
handler.modlist_dir = install_dir
if handler._detect_game_variables():
return handler.game_var_full or ""
except Exception as e:
logger.debug("Game type detection failed: %s", e)
return ""
def _lookup_app_id(self, install_dir: str) -> Optional[str]:
try:
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
sh = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
for sc in sh.find_shortcuts_by_exe("ModOrganizer.exe"):
if os.path.realpath(sc.get("StartDir", "")) == os.path.realpath(install_dir):
raw = sc.get("appid")
if raw is not None:
return str(int(raw) & 0xFFFFFFFF)
except Exception as e:
logger.debug("AppID lookup failed: %s", e)
return None
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _build_context_from_args(self, args) -> dict:
"""Build context dictionary from command arguments.
Args:
args: Parsed command-line arguments
Returns:
Context dictionary
"""
return {
'modlist_name': getattr(args, 'modlist_name', None),
'install_dir': getattr(args, 'install_dir', None),
'download_dir': getattr(args, 'download_dir', None),
'nexus_api_key': getattr(args, 'nexus_api_key', os.environ.get('NEXUS_API_KEY')),
'mo2_exe_path': getattr(args, 'mo2_exe_path', None),
'resolution': getattr(args, 'resolution', None),
'skip_confirmation': getattr(args, 'skip_confirmation', False),
'modlist_value': getattr(args, 'modlist_value', None),
'modlist_source': getattr(args, 'modlist_source', None),
"modlist_name": getattr(args, "modlist_name", None),
"install_dir": getattr(args, "install_dir", None),
"download_dir": getattr(args, "download_dir", None),
"nexus_api_key": getattr(args, "nexus_api_key", os.environ.get("NEXUS_API_KEY")),
"mo2_exe_path": getattr(args, "mo2_exe_path", None),
"resolution": getattr(args, "resolution", None),
"skip_confirmation": getattr(args, "skip_confirmation", False),
"modlist_value": getattr(args, "modlist_value", None),
"modlist_source": getattr(args, "modlist_source", None),
}
def _execute_legacy_configuration(self, context: dict):
"""Execute configuration using legacy implementation.
This is a temporary bridge - will be replaced with backend service calls.
Args:
context: Configuration context dictionary
Returns:
Result from legacy configuration
"""
# Import backend services
from jackify.backend.handlers.menu_handler import ModlistMenuHandler
from jackify.backend.handlers.config_handler import ConfigHandler
# Create legacy handler instances
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(
config_handler=config_handler,
test_mode=self.test_mode
test_mode=self.test_mode,
)
# Execute legacy configuration workflow
# The _configure_new_modlist method already handles Steam restart, manual steps, and configuration
result = modlist_menu._configure_new_modlist(
default_modlist_dir=context['install_dir'],
default_modlist_name=context['modlist_name']
return modlist_menu._configure_new_modlist(
default_modlist_dir=context["install_dir"],
default_modlist_name=context["modlist_name"],
)
# The _configure_new_modlist method already calls run_modlist_configuration_phase internally
# So we don't need to call it again here
return result
@@ -0,0 +1,139 @@
"""List installed Wine components for a configured modlist prefix."""
import json
from pathlib import Path
from typing import Dict, List, Optional
from jackify.shared.colors import (
COLOR_ACTION,
COLOR_ERROR,
COLOR_INFO,
COLOR_PROMPT,
COLOR_RESET,
COLOR_SELECTION,
COLOR_WARNING,
)
from jackify.shared.ui_utils import clear_screen, print_jackify_banner, print_section_header
class ListInstalledCommand:
"""Show deduplicated component records for a modlist prefix."""
def run(self, appid: Optional[str] = None) -> None:
clear_screen()
print_jackify_banner()
print_section_header("Installed Wine Components")
if appid:
self._show_for_appid(appid, modlist_name=None)
else:
self._run_interactive()
def _run_interactive(self) -> None:
from jackify.backend.handlers.modlist_handler import ModlistHandler
print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}")
try:
handler = ModlistHandler()
discovered = handler.discover_executable_shortcuts("ModOrganizer.exe")
shortcuts = [
{"name": m.get("name", "Unknown"), "appid": str(m.get("appid", ""))}
for m in discovered
if m.get("appid")
]
except Exception as exc:
print(f"{COLOR_ERROR}Failed to discover modlists: {exc}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
if not shortcuts:
print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print()
for i, s in enumerate(shortcuts, 1):
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {s['name']}")
print(f" {COLOR_ACTION}AppID: {s['appid']}{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel")
selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(shortcuts)}): {COLOR_RESET}").strip()
if selection == "0" or not selection:
return
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(shortcuts):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
chosen = shortcuts[idx]
self._show_for_appid(chosen["appid"], modlist_name=chosen["name"])
def _show_for_appid(self, appid: str, modlist_name: Optional[str]) -> None:
from jackify.backend.handlers.path_handler import PathHandler
compat = PathHandler.find_compat_data(appid)
if not compat:
print(f"{COLOR_ERROR}Prefix not found for AppID {appid}.{COLOR_RESET}")
input("Press Enter to continue...")
return
pfx = compat / "pfx"
wt_log = pfx / "winetricks.log"
wt_entries: List[str] = []
if wt_log.is_file():
seen: set = set()
for line in wt_log.read_text(errors="replace").splitlines():
entry = line.strip()
if entry and entry not in seen:
seen.add(entry)
wt_entries.append(entry)
jc_path = pfx / "jackify_components.json"
jc_data: Dict[str, dict] = {}
if jc_path.is_file():
try:
jc_data = json.loads(jc_path.read_text(encoding="utf-8"))
except Exception:
pass
label = modlist_name or appid
print(f"\n{COLOR_INFO}Installed components: {label}{COLOR_RESET}")
if modlist_name:
print(f"{COLOR_INFO}AppID: {appid}{COLOR_RESET}")
print(f"{COLOR_INFO}Prefix: {pfx}{COLOR_RESET}\n")
if not wt_entries and not jc_data:
print(f"{COLOR_WARNING}No component records found.{COLOR_RESET}")
print(f"{COLOR_INFO}winetricks.log: {wt_log}{COLOR_RESET}")
input("\nPress Enter to continue...")
return
all_components: List[str] = list(wt_entries)
for comp in jc_data:
if comp not in all_components:
all_components.append(comp)
col_w = max((len(c) for c in all_components), default=20)
col_w = max(col_w, 20)
header = f"{'Component':<{col_w}} {'Method':<12} Timestamp"
print(f"{COLOR_SELECTION}{header}{COLOR_RESET}")
print("-" * (col_w + 30))
for comp in all_components:
if comp in jc_data:
method = jc_data[comp].get("method", "native")
ts = jc_data[comp].get("timestamp", "-")
else:
method = "winetricks"
ts = "-"
print(f"{comp:<{col_w}} {method:<12} {ts}")
wt_note = "[OK]" if wt_log.is_file() else "[MISSING]"
jc_note = "[OK]" if jc_path.is_file() else "[MISSING]"
print(f"\n{COLOR_INFO}Sources: {wt_note} winetricks.log {jc_note} jackify_components.json{COLOR_RESET}")
input("\nPress Enter to continue...")
+7 -8
View File
@@ -370,7 +370,7 @@ class JackifyCLI:
# Now that we have args, configure logging properly
self._configure_logging_final()
logger.debug('Initializing Jackify CLI Frontend')
logger.debug('Initialising Jackify CLI Frontend')
logger.debug('JackifyCLI.run() called')
logger.debug(f'Parsed args: {self.args}')
@@ -438,13 +438,10 @@ class JackifyCLI:
print(" Then select: Additional Tasks > Install Wabbajack")
return 0
elif command == "install-mo2":
print("MO2 installation not yet implemented")
print("This functionality is coming soon!")
return 1
elif command == "configure-nxm":
print("NXM configuration not yet implemented")
print("This functionality is coming soon!")
return 1
from jackify.frontends.cli.commands.setup_mo2 import SetupMO2Command
SetupMO2Command().run()
return 0
elif command == "recovery":
return self._handle_legacy_recovery(args)
elif command == "test-protontricks":
@@ -468,6 +465,8 @@ class JackifyCLI:
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
elif choice == "additional":
self.menus['additional'].show_additional_tasks_menu(self)
elif choice == "tools_hub":
self.menus['additional']._execute_tools_hub(self)
else:
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")
+145 -22
View File
@@ -29,33 +29,37 @@ class AdditionalMenuHandler:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Tasks & Tools")
print(f"{COLOR_INFO}Nexus Authentication, TTW Install & more{COLOR_RESET}\n")
print(f"{COLOR_INFO}Modlist tools, diagnostics, and more{COLOR_RESET}\n")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Nexus Mods Authorization")
print(f" {COLOR_ACTION}Authorize with Nexus using OAuth or manage API key{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Wabbajack Application")
print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Configure Tool Compatibility")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Run Install Verifier")
print(f" {COLOR_ACTION}Check an installed modlist for common configuration problems{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure Tool Compatibility")
print(f" {COLOR_ACTION}→ Apply Wine registry settings for xEdit, Synthesis, Pandora, Nemesis{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton — not needed for standard modlist installs{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle")
print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}")
print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization")
print(f" {COLOR_ACTION}→ Authorise with Nexus using OAuth or manage API key{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip()
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-6): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_nexus_authorization(cli_instance)
self._execute_run_verifier()
elif selection == "2":
self._execute_ttw_install(cli_instance)
elif selection == "3":
self._execute_install_wabbajack(cli_instance)
elif selection == "4":
self._execute_setup_mo2(cli_instance)
elif selection == "5":
self._execute_configure_tool_compat(cli_instance)
elif selection == "3":
self._execute_setup_mo2(cli_instance)
elif selection == "4":
self._execute_install_wabbajack(cli_instance)
elif selection == "5":
self._execute_diagnostic_bundle()
elif selection == "6":
self._execute_nexus_authorization(cli_instance)
elif selection == "0":
break
else:
@@ -271,17 +275,17 @@ class AdditionalMenuHandler:
if authenticated:
if method == 'oauth':
print(f"\n{COLOR_SUCCESS}Status: Authorized via OAuth{COLOR_RESET}")
print(f"\n{COLOR_SUCCESS}Status: Authorised via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print(f"\n{COLOR_WARNING}Status: Using API Key (Legacy){COLOR_RESET}")
print(f"{COLOR_INFO}Consider switching to OAuth for better security{COLOR_RESET}")
else:
print(f"\n{COLOR_WARNING}Status: Not Authorized{COLOR_RESET}")
print(f"\n{COLOR_WARNING}Status: Not Authorised{COLOR_RESET}")
print(f"{COLOR_INFO}You need to authorize to download mods from Nexus{COLOR_RESET}")
print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorize with Nexus (OAuth)")
print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorise with Nexus (OAuth)")
print(f" {COLOR_ACTION}→ Opens browser for secure authorization{COLOR_RESET}")
if method == 'oauth':
@@ -320,7 +324,7 @@ class AdditionalMenuHandler:
# Get username
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
print(f"{COLOR_INFO}Authorised as: {username}{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
print(f"{COLOR_INFO}You can try again or use API key as fallback.{COLOR_RESET}")
@@ -474,3 +478,122 @@ class AdditionalMenuHandler:
print(f"\n{COLOR_ERROR}Tool compatibility configuration failed. Check logs for details.{COLOR_RESET}")
input("\nPress Enter to return to menu...")
def _execute_diagnostic_bundle(self) -> None:
from jackify.backend.services.diagnostic_service import build_bundle
from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS
self._clear_screen()
print_jackify_banner()
print_section_header("Create Diagnostic Bundle")
print(f"{COLOR_INFO}Collecting logs, system info, and prefix records...{COLOR_RESET}\n")
try:
bundle_path = build_bundle()
except Exception as exc:
print(f"{COLOR_ERROR}Failed to create bundle: {exc}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print(f"{COLOR_SUCCESS}Bundle created:{COLOR_RESET} {bundle_path}")
print(f"\n{COLOR_INFO}Attach this file when reporting an issue.{COLOR_RESET}")
input("\nPress Enter to return to menu...")
def _execute_run_verifier(self) -> None:
from jackify.backend.services.install_verifier_service import (
_load_verifier, run_install_verification, resolve_pfx_for_appid,
)
from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING
import threading
self._clear_screen()
print_jackify_banner()
print_section_header("Run Install Verifier")
print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}")
try:
vmod = _load_verifier()
modlists = vmod.discover_installed_modlists()
except Exception as e:
print(f"{COLOR_ERROR}Failed to discover modlists: {e}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
if not modlists:
print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}")
print(f"{COLOR_INFO}Install and configure a modlist first.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print()
for i, m in enumerate(modlists, 1):
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {m.get('name', 'Unknown')}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel")
selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(modlists)}): {COLOR_RESET}").strip()
if selection == "0" or not selection:
return
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(modlists):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
chosen = modlists[idx]
appid = chosen.get("appid", "")
modlist_dir = chosen.get("modlist_dir")
game_type = chosen.get("game_type", "unknown")
name = chosen.get("name", "Unknown")
pfx = resolve_pfx_for_appid(appid) if appid else None
if not pfx or not pfx.is_dir():
print(f"{COLOR_WARNING}No Wine prefix found for {name}. Cannot run verifier.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print(f"\n{COLOR_INFO}Running install verification for: {name}{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a moment...{COLOR_RESET}\n")
try:
result_holder = [None]
def _worker():
result_holder[0] = run_install_verification(pfx, modlist_dir, game_type, appid, name)
t = threading.Thread(target=_worker, daemon=True)
t.start()
t.join()
r = result_holder[0]
if r is None:
print(f"{COLOR_WARNING}Verifier returned no results.{COLOR_RESET}")
else:
passes = r.passes if hasattr(r, 'passes') else []
warnings = r.warnings if hasattr(r, 'warnings') else []
failures = r.failures if hasattr(r, 'failures') else []
total = len(passes) + len(warnings) + len(failures)
print(f"--- Install Verification: {name} ---")
print(f" {len(passes)} passed, {len(warnings)} warnings, {len(failures)} failed (of {total} checks)\n")
for msg in failures:
print(f"{COLOR_ERROR} [FAIL] {msg}{COLOR_RESET}")
for msg in warnings:
print(f"{COLOR_WARNING} [WARN] {msg}{COLOR_RESET}")
if not failures and not warnings:
print(f"{COLOR_SUCCESS} All checks passed.{COLOR_RESET}")
if passes:
show_all = input(f"\n{COLOR_PROMPT}Show all {len(passes)} passed checks? (y/N): {COLOR_RESET}").strip().lower()
if show_all in ('y', 'yes'):
print()
for msg in passes:
print(f"{COLOR_DISABLED} [OK] {msg}{COLOR_RESET}")
except Exception as e:
print(f"{COLOR_ERROR}Verifier error: {e}{COLOR_RESET}")
input("\nPress Enter to return to menu...")
def _execute_tools_hub(self, cli_instance) -> None:
from jackify.frontends.cli.menus.tools_hub_menu import ToolsHubMenuHandler
ToolsHubMenuHandler().show_tools_hub_menu(cli_instance)
+7 -3
View File
@@ -43,16 +43,20 @@ class MainMenuHandler:
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools")
print(f" {COLOR_ACTION}Nexus OAuth, TTW Installation, Install Wabbajack{COLOR_RESET}")
print(f" {COLOR_ACTION}Verifier, Diagnostics, Nexus OAuth & more{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Tools Hub")
print(f" {COLOR_ACTION}→ Install, update, or switch engines and tools{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if choice.lower() == 'q': # Allow 'q' to re-display menu
continue
if choice == "1":
return "wabbajack"
elif choice == "2":
return "additional"
elif choice == "3":
return "tools_hub"
elif choice == "0":
return "exit"
else:
@@ -0,0 +1,249 @@
"""
Tools Hub Menu Handler for Jackify CLI Frontend
"""
import logging
import threading
import time
from typing import Optional, Tuple
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT,
COLOR_INFO, COLOR_DISABLED, COLOR_WARNING, COLOR_ERROR, COLOR_SUCCESS
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen
from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus
logger = logging.getLogger(__name__)
class ToolsHubMenuHandler:
"""CLI menu for managing third-party tools via ToolRegistry."""
def show_tools_hub_menu(self, cli_instance) -> None:
from jackify.backend.services.tool_registry import (
ToolRegistry, get_active_engine_id
)
registry = ToolRegistry()
while True:
clear_screen()
print_jackify_banner()
print_section_header("Third Party Tools Hub")
statuses = [s for s in registry.get_all_statuses() if not s.definition.hidden]
active_engine_id = get_active_engine_id()
print(f"{COLOR_INFO}Active engine: {active_engine_id}{COLOR_RESET}\n")
for i, status in enumerate(statuses, 1):
defn = status.definition
if status.installed:
ver = status.installed_version or "unknown"
active_tag = (
f" {COLOR_ACTION}[ACTIVE]{COLOR_RESET}"
if defn.is_engine and defn.tool_id == active_engine_id
else ""
)
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {defn.display_name} "
f"{COLOR_INFO}v{ver}{COLOR_RESET}{active_tag}")
else:
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} "
f"{COLOR_DISABLED}{defn.display_name} [not installed]{COLOR_RESET}")
print(f" {COLOR_ACTION}{defn.description}{COLOR_RESET}")
print(f"\n{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(
f"\n{COLOR_PROMPT}Select tool (0-{len(statuses)}): {COLOR_RESET}"
).strip()
if selection == "0":
break
if selection.lower() == "q":
continue
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(statuses):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
time.sleep(1)
continue
self._show_tool_detail(registry, statuses[idx], active_engine_id, cli_instance)
def _show_tool_detail(self, registry, status, active_engine_id: str, cli_instance=None) -> None:
from jackify.backend.services.tool_registry import (
get_active_engine_id, set_active_engine_id
)
defn = status.definition
while True:
clear_screen()
print_jackify_banner()
print_section_header(defn.display_name)
print(f"{COLOR_INFO}{defn.description}{COLOR_RESET}\n")
if status.installed:
ver = status.installed_version or "unknown"
print(f"Installed: {COLOR_INFO}{ver}{COLOR_RESET}")
if status.previous_version:
print(f"Previous: {COLOR_DISABLED}{status.previous_version}{COLOR_RESET}")
if defn.is_engine and defn.tool_id == active_engine_id:
print(f"Engine: {COLOR_ACTION}[ACTIVE]{COLOR_RESET}")
else:
print(f"Status: {COLOR_DISABLED}Not installed{COLOR_RESET}")
latest = self._fetch_latest_with_spinner(registry, defn.tool_id)
if latest:
latest_clean = latest.lstrip("v")
current_clean = (status.installed_version or "").lstrip("v")
update_available = status.installed and latest_clean != current_clean
update_tag = (
f" {COLOR_WARNING}[update available]{COLOR_RESET}" if update_available
else f" {COLOR_ACTION}[up to date]{COLOR_RESET}" if status.installed
else ""
)
print(f"Latest: {COLOR_INFO}{latest}{COLOR_RESET}{update_tag}")
else:
update_available = False
print(f"Latest: {COLOR_DISABLED}(could not check){COLOR_RESET}")
options: dict = {}
opt_num = 1
print()
if not status.installed:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Install")
options[str(opt_num)] = "install"
opt_num += 1
else:
if latest and update_available:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Update to {latest}")
options[str(opt_num)] = "update"
opt_num += 1
if status.previous_version and defn.can_uninstall:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} "
f"Downgrade to {status.previous_version}")
options[str(opt_num)] = "downgrade"
opt_num += 1
if defn.can_uninstall:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Uninstall")
options[str(opt_num)] = "uninstall"
opt_num += 1
if defn.is_engine and defn.tool_id != active_engine_id:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Set as active engine")
options[str(opt_num)] = "set_active"
opt_num += 1
if defn.tool_id == "ttw_installer":
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Run TTW Installation")
options[str(opt_num)] = "run_ttw"
opt_num += 1
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Back")
if not options:
input(f"\n{COLOR_PROMPT}Press Enter to go back...{COLOR_RESET}")
break
selection = input(f"\n{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip()
if selection == "0":
break
action = options.get(selection)
if not action:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
time.sleep(1)
continue
if action == "run_ttw":
from jackify.frontends.cli.menus.additional_menu import AdditionalMenuHandler
AdditionalMenuHandler()._execute_ttw_install(cli_instance)
status = registry.get_status(defn.tool_id) or status
continue
if action == "set_active":
try:
set_active_engine_id(defn.tool_id)
active_engine_id = defn.tool_id
print(f"\n{COLOR_SUCCESS}Active engine set to {defn.display_name}.{COLOR_RESET}")
except Exception as e:
print(f"\n{COLOR_ERROR}Failed to set active engine: {e}{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
status = registry.get_status(defn.tool_id) or status
continue
if action == "uninstall":
confirm = input(
f"\n{COLOR_WARNING}Uninstall {defn.display_name}? (y/N): {COLOR_RESET}"
).strip().lower()
if confirm not in ("y", "yes"):
print(f"{COLOR_INFO}Cancelled.{COLOR_RESET}")
time.sleep(0.8)
status = registry.get_status(defn.tool_id) or status
continue
ok, msg = self._run_with_spinner(registry, action, defn.tool_id, defn.display_name)
if ok:
print(f"\n{COLOR_SUCCESS}{msg}{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}{msg}{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
status = registry.get_status(defn.tool_id) or status
active_engine_id = get_active_engine_id()
def _fetch_latest_with_spinner(self, registry, tool_id: str) -> Optional[str]:
result: list = [None]
spinner = CliIndeterminateStatus()
spinner.set("Checking latest version...")
def _worker():
try:
result[0] = registry.check_latest_version(tool_id)
except Exception:
result[0] = None
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join()
spinner.stop()
return result[0]
def _run_with_spinner(
self, registry, action: str, tool_id: str, display_name: str
) -> Tuple[bool, str]:
labels = {
"install": f"Installing {display_name}",
"update": f"Updating {display_name}",
"downgrade": f"Downgrading {display_name}",
"uninstall": f"Uninstalling {display_name}",
}
label = labels.get(action, f"Working on {display_name}")
result: list = [False, ""]
spinner = CliIndeterminateStatus()
spinner.set(label)
def _worker():
try:
if action == "install":
result[0], result[1] = registry.install(tool_id)
elif action == "update":
result[0], result[1] = registry.update(tool_id)
elif action == "downgrade":
result[0], result[1] = registry.downgrade(tool_id)
elif action == "uninstall":
result[0], result[1] = registry.uninstall(tool_id)
except Exception as e:
result[0], result[1] = False, str(e)
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join()
spinner.stop()
return result[0], result[1]
@@ -31,7 +31,7 @@ class WabbajackMenuHandler:
# Use print_section_header for consistency
print_section_header("Modlist and Wabbajack Tasks")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist")
print(f" {COLOR_ACTION}→ Install a modlist in full: Select from a list or provide a .wabbajack file{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)")
print(f" {COLOR_ACTION}→ Modlist already downloaded? Configure and add to Steam{COLOR_RESET}")
+40
View File
@@ -10,6 +10,10 @@ from pathlib import Path
def main():
if len(sys.argv) > 1 and sys.argv[1].startswith('nxm://'):
handle_nxm_url(sys.argv[1])
return
# Check if launched with jackify:// protocol URL (OAuth callback)
if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'):
handle_protocol_url(sys.argv[1])
@@ -20,6 +24,42 @@ def main():
gui_main()
def handle_nxm_url(url: str) -> None:
"""Handle an nxm:// launch: hand off to running instance or open the app."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv)
from jackify.backend.services.nxm_ipc import send_to_running_instance
if send_to_running_instance(url):
return
# No running instance - check for installed modlists before launching
try:
from jackify.tools.verify_install import discover_installed_modlists
modlists = discover_installed_modlists()
except Exception:
modlists = []
if not modlists:
from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error
show_no_modlists_error()
return
from jackify.backend.services.nxm_url import parse_nxm_url
from jackify.backend.services import nxm_session
from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog
nxm = parse_nxm_url(url)
auto_start = nxm_session.detect_active_mo2_modlist(modlists)
if auto_start is None:
remembered = nxm_session.get_remembered_modlist()
auto_start = next((m for m in modlists if m["name"] == remembered), None) if remembered else None
dlg = NxmDownloadDialog(nxm, modlists, auto_start_modlist=auto_start)
dlg.show()
app.exec()
def handle_protocol_url(url: str):
"""Handle jackify:// protocol URL (OAuth callback)."""
from urllib.parse import urlparse, parse_qs
+2 -1
View File
@@ -6,5 +6,6 @@ Custom dialogs for the Jackify GUI application.
from .completion_dialog import NextStepsDialog
from .success_dialog import SuccessDialog
from .verification_results_dialog import VerificationResultsDialog
__all__ = ['NextStepsDialog', 'SuccessDialog']
__all__ = ['NextStepsDialog', 'SuccessDialog', 'VerificationResultsDialog']
@@ -112,8 +112,8 @@ class NextStepsDialog(QDialog):
content_text.setReadOnly(True)
content_text.setStyleSheet(
"QTextEdit { "
" background-color: #f8f9fa; "
" border: 1px solid #dee2e6; "
" background-color: #2a2a2a; "
" border: 1px solid #3a3a3a; "
" border-radius: 6px; "
" padding: 12px; "
" font-family: 'Segoe UI', Arial, sans-serif; "
@@ -139,7 +139,7 @@ class NextStepsDialog(QDialog):
return_btn.clicked.connect(self.accept) # This will close dialog and return to menu
return_btn.setStyleSheet(
"QPushButton { "
" background-color: #3498db; "
" background-color: #1a5fa8; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -147,10 +147,10 @@ class NextStepsDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #2980b9; "
" background-color: #2470b0; "
"} "
"QPushButton:pressed { "
" background-color: #21618c; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(return_btn)
@@ -163,7 +163,7 @@ class NextStepsDialog(QDialog):
exit_btn.clicked.connect(self.reject) # This will close dialog and potentially exit app
exit_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -171,10 +171,10 @@ class NextStepsDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(exit_btn)
@@ -194,15 +194,12 @@ class NextStepsDialog(QDialog):
completion_title = "Modlist Configuration complete!" if is_existing else "Modlist Install and Configuration complete!"
completion_log = "Configure_Existing_Modlist_workflow.log" if is_existing else "Configure_New_Modlist_workflow.log"
completion_text = f"""Configuration completed successfully!
completion_text = f"""Configuration completed successfully!
{completion_title}
You should now be able to Launch '{self.modlist_name}' through Steam.
Congratulations and enjoy the game!
NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of
Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).
- You should now be able to Launch '{self.modlist_name}' through Steam.
- Congratulations and enjoy the game!
Detailed log available at: {get_jackify_logs_dir()}/{completion_log}"""
@@ -55,7 +55,7 @@ class ENBProtonDialog(QDialog):
"QFrame#enbCard { "
" background: #23272e; "
" border-radius: 12px; "
" border: 2px solid #e67e22; " # Orange border for warning
" border: 2px solid #f0c040;" # Orange border for warning
"}"
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
@@ -67,17 +67,26 @@ class ENBProtonDialog(QDialog):
"QLabel { "
" font-size: 24px; "
" font-weight: 700; "
" color: #e67e22; " # Orange warning color
" color: #f0c040;" # Orange warning color
" margin-bottom: 4px; "
"}"
)
card_layout.addWidget(title_label)
# Main warning message
warning_text = (
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
)
from jackify.backend.data.modlist_proton_requirements import get_proton_requirement
_proton_req = get_proton_requirement(self.modlist_name)
if _proton_req:
warning_text = (
f"<span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span> "
f"requires a specific Proton version for ENB compatibility:"
)
else:
warning_text = (
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
)
warning_label = QLabel(warning_text)
warning_label.setAlignment(Qt.AlignCenter)
warning_label.setWordWrap(True)
@@ -93,17 +102,28 @@ class ENBProtonDialog(QDialog):
warning_label.setTextFormat(Qt.RichText)
card_layout.addWidget(warning_label)
# Proton version list (in order of recommendation)
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
"• <b style='color: #2ecc71;'>Proton-CachyOS</b><br/>"
"• <b style='color: #3498db;'>GE-Proton 10-14</b> or <b style='color: #3498db;'>lower</b><br/>"
"• <b style='color: #f39c12;'>Proton 9</b> from Valve"
"</div>"
"</div>"
)
if _proton_req:
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
f"<div style='font-size: 16px; color: #3fd0ea; font-weight: 700; margin-bottom: 8px;'>{_proton_req['required']}</div>"
f"<div style='font-size: 13px; color: #b0b0b0;'>{_proton_req['note']}</div>"
"</div>"
)
else:
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
"- <b style='color: #3fd0ea;'>Proton-CachyOS</b><br/>"
"- <b style='color: #aaa;'>GE-Proton</b><br/>"
"- <b style='color: #777;'>Proton 9</b> from Valve"
"</div>"
"<div style='font-size: 12px; color: #777; font-style: italic; margin-top: 10px;'>"
"Valve Proton 10 has known ENB compatibility issues."
"</div>"
"</div>"
)
versions_label = QLabel(versions_text)
versions_label.setAlignment(Qt.AlignLeft)
versions_label.setWordWrap(True)
@@ -118,26 +138,6 @@ class ENBProtonDialog(QDialog):
versions_label.setTextFormat(Qt.RichText)
card_layout.addWidget(versions_label)
# Additional note
note_text = (
"<div style='font-size: 12px; color: #95a5a6; font-style: italic; margin-top: 8px;'>"
"Note: Valve's Proton 10 has known ENB compatibility issues."
"</div>"
)
note_label = QLabel(note_text)
note_label.setAlignment(Qt.AlignCenter)
note_label.setWordWrap(True)
note_label.setStyleSheet(
"QLabel { "
" font-size: 12px; "
" color: #95a5a6; "
" font-style: italic; "
" margin-top: 8px; "
"}"
)
note_label.setTextFormat(Qt.RichText)
card_layout.addWidget(note_label)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addSpacing(20) # Add spacing between card and button
@@ -36,12 +36,12 @@ _STATUS_LABELS = {
_STATUS_COLOURS = {
'pending': '#808080',
'browser_opened': '#3498db',
'validating': '#f39c12',
'complete': '#27ae60',
'deferred': '#e67e22',
'skipped': '#e67e22',
'error': '#e74c3c',
'browser_opened': '#3fd0ea',
'validating': '#f0c040',
'complete': '#3fd0ea',
'deferred': '#f0c040',
'skipped': '#888',
'error': '#e05050',
}
# Column indices
@@ -61,6 +61,15 @@ def _fmt_size(n: int) -> str:
return f"{n:.1f} TB"
class _SizeTableItem(QTableWidgetItem):
"""QTableWidgetItem that sorts by raw byte count stored in UserRole."""
def __lt__(self, other: 'QTableWidgetItem') -> bool:
try:
return int(self.data(Qt.UserRole) or 0) < int(other.data(Qt.UserRole) or 0)
except (TypeError, ValueError):
return super().__lt__(other)
class _Bridge(QObject):
"""Tiny bridge so worker-thread callbacks can update the Qt table safely."""
item_updated = Signal(object) # DownloadItem
@@ -152,15 +161,17 @@ class ManualDownloadDialog(QDialog):
# Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints
if new_items:
self._table.setSortingEnabled(False)
self._table.setUpdatesEnabled(False)
try:
start_row = self._table.rowCount()
self._table.setRowCount(start_row + len(new_items))
for i, item in enumerate(new_items):
self._fill_row(start_row + i, item)
self._row_map[item.file_name] = start_row + i
finally:
self._table.setUpdatesEnabled(True)
self._table.setSortingEnabled(True)
self._rebuild_row_map()
self._table.viewport().update()
self._refresh_header()
@@ -222,7 +233,9 @@ class ManualDownloadDialog(QDialog):
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.setSortingEnabled(True)
self._table.verticalHeader().setVisible(False)
self._table.horizontalHeader().sectionClicked.connect(self._on_header_clicked)
self._table.cellDoubleClicked.connect(self._on_row_double_clicked)
self._table.setStyleSheet(
"QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; "
@@ -284,6 +297,14 @@ class ManualDownloadDialog(QDialog):
self._open_selected_btn.clicked.connect(self._on_open_selected)
btn_row.addWidget(self._open_selected_btn)
self._scan_now_btn = QPushButton("Scan Now")
self._scan_now_btn.setToolTip(
"Re-scan the watch directory for already-downloaded files.\n"
"Use this if a file downloaded successfully but was not detected automatically."
)
self._scan_now_btn.clicked.connect(self._on_scan_now)
btn_row.addWidget(self._scan_now_btn)
btn_row.addStretch()
self._start_pause_btn = QPushButton("Start")
@@ -308,9 +329,13 @@ class ManualDownloadDialog(QDialog):
def _fill_row(self, row: int, item: DownloadItem) -> None:
"""Populate cells for a pre-allocated row (row must already exist in the table)."""
from PySide6.QtGui import QColor
self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name))
mod_cell = QTableWidgetItem(item.mod_name)
mod_cell.setData(Qt.UserRole, item.file_name) # stable ID for row lookups after sort
self._table.setItem(row, _COL_MOD, mod_cell)
self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name))
self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size)))
size_cell = _SizeTableItem(_fmt_size(item.expected_size))
size_cell.setData(Qt.UserRole, item.expected_size)
self._table.setItem(row, _COL_SIZE, size_cell)
colour = _STATUS_COLOURS.get(item.status, '#808080')
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(colour))
@@ -326,6 +351,26 @@ class ManualDownloadDialog(QDialog):
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
status_cell.setToolTip(item.error_message or "")
def _rebuild_row_map(self) -> None:
self._row_map.clear()
for row in range(self._table.rowCount()):
cell = self._table.item(row, _COL_MOD)
if cell:
file_name = cell.data(Qt.UserRole)
if file_name:
self._row_map[file_name] = row
def _sync_pending_order_to_visual(self) -> None:
visual_order: dict[str, int] = {}
for row in range(self._table.rowCount()):
cell = self._table.item(row, _COL_MOD)
if cell:
file_name = cell.data(Qt.UserRole)
if file_name:
visual_order[file_name] = row
with self._manager._lock:
self._manager._items.sort(key=lambda i: visual_order.get(i.file_name, 999999))
def _refresh_header(self) -> None:
items = self._manager.items
total = len(items)
@@ -345,6 +390,10 @@ class ManualDownloadDialog(QDialog):
# Slots
# ------------------------------------------------------------------
def _on_header_clicked(self, logical_index: int) -> None:
self._rebuild_row_map()
self._sync_pending_order_to_visual()
def _on_item_updated_slot(self, item: DownloadItem) -> None:
row = self._row_map.get(item.file_name)
if row is not None:
@@ -369,7 +418,6 @@ class ManualDownloadDialog(QDialog):
self._folder_label.setText(chosen)
self._manager._watch_dir = self._watch_dir
self._manager._watcher._config.watch_directory = self._watch_dir
self._manager._watcher._known = {}
try:
cfg = ConfigHandler()
cfg.set("manual_download_watch_directory", str(self._watch_dir))
@@ -430,6 +478,16 @@ class ManualDownloadDialog(QDialog):
return
self._manager.reopen_item(file_name)
def _on_scan_now(self) -> None:
self._scan_now_btn.setEnabled(False)
self._scan_now_btn.setText("Scanning...")
self._manager.force_rescan()
from PySide6.QtCore import QTimer
QTimer.singleShot(2000, lambda: (
self._scan_now_btn.setEnabled(True),
self._scan_now_btn.setText("Scan Now"),
))
def _on_row_double_clicked(self, row: int, _column: int) -> None:
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
@@ -0,0 +1,470 @@
"""NXM download dialog: modlist picker and download runner."""
import logging
from pathlib import Path
from typing import Optional, List, Dict, Tuple
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QCheckBox,
QPushButton,
QProgressBar,
QFrame,
)
from jackify.backend.services.nxm_url import NxmUrl
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
import jackify.backend.services.nxm_session as nxm_session
logger = logging.getLogger(__name__)
_BG_DARK = "#16191d"
_BG_CARD = "#1e2228"
_BG_LIST = "#1a1d23"
_BORDER = "#333"
_TEXT = "#d0d0d0"
_TEXT_DIM = "#8f98a3"
_TEXT_BRIGHT = "#e0e0e0"
_ERROR = "#cc4444"
class _ModNameFetchThread(QThread):
name_ready = Signal(str)
def __init__(self, nxm: NxmUrl, parent=None):
super().__init__(parent)
self.nxm = nxm
def run(self) -> None:
try:
from jackify.backend.services.nexus_auth_service import NexusAuthService
svc = NexusAuthService()
token = svc.get_auth_token()
method = svc.get_auth_method() or "api_key"
except Exception:
try:
from jackify.backend.services.api_key_service import APIKeyService
token = APIKeyService().get_saved_api_key()
method = "api_key"
except Exception:
return
if not token:
return
try:
import requests
if method == "oauth":
headers = {"Authorization": f"Bearer {token}", "User-Agent": "jackify"}
else:
headers = {"apikey": token, "User-Agent": "jackify"}
url = (
f"https://api.nexusmods.com/v1/games/{self.nxm.game}"
f"/mods/{self.nxm.mod_id}.json"
)
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
name = resp.json().get("name", "")
if name:
self.name_ready.emit(name)
except Exception as e:
logger.debug("Mod name fetch failed: %s", e)
class _DownloadThread(QThread):
progress = Signal(int, int)
finished = Signal(bool, str)
def __init__(
self,
nxm: NxmUrl,
download_dir: Path,
auth_token: str,
auth_method: str,
parent=None,
):
super().__init__(parent)
self.nxm = nxm
self.download_dir = download_dir
self.auth_token = auth_token
self.auth_method = auth_method
def run(self) -> None:
from jackify.backend.services.nxm_downloader import (
get_nxm_download_url,
download_nxm_file,
filename_from_cdn_url,
)
cdn_url = get_nxm_download_url(self.nxm, self.auth_token, self.auth_method)
if not cdn_url:
self.finished.emit(False, "Could not resolve download URL from Nexus API.")
return
filename = filename_from_cdn_url(
cdn_url, f"mod_{self.nxm.mod_id}_file_{self.nxm.file_id}.zip"
)
ok, msg = download_nxm_file(cdn_url, self.download_dir, filename, self._on_progress)
self.finished.emit(ok, msg)
def _on_progress(self, downloaded: int, total: int) -> None:
self.progress.emit(downloaded, total)
class NxmDownloadDialog(QDialog):
"""Modlist picker and download runner for incoming nxm:// links.
When auto_start_modlist is provided the picker is hidden and the download
begins immediately - used when session memory has a remembered modlist.
"""
def __init__(
self,
nxm: NxmUrl,
modlists: List[Dict],
parent=None,
auto_start_modlist: Optional[Dict] = None,
):
super().__init__(parent)
self.nxm = nxm
self.modlists = modlists
self._thread: Optional[_DownloadThread] = None
self._name_thread: Optional[_ModNameFetchThread] = None
self._auto_start_modlist = auto_start_modlist
self.setWindowTitle("NXM Download")
self.setMinimumWidth(520)
self.setModal(False)
self.setStyleSheet(f"QDialog {{ background: {_BG_DARK}; color: {_TEXT}; }}")
self._build_ui(show_picker=auto_start_modlist is None)
if auto_start_modlist is None:
self._apply_session_memory()
self._fetch_mod_name()
# --- UI construction ---
def _build_ui(self, show_picker: bool = True) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.addWidget(self._make_header())
if show_picker:
layout.addWidget(self._make_section_label("Select modlist:"))
layout.addWidget(self._make_modlist_list())
layout.addWidget(self._make_dest_label())
layout.addWidget(self._make_session_row())
else:
name = self._auto_start_modlist.get("name", "")
lbl = QLabel(f"Downloading to: <span style='color:{JACKIFY_COLOR_BLUE}'>{name}</span>")
lbl.setTextFormat(Qt.RichText)
lbl.setStyleSheet(f"color: {_TEXT};")
layout.addWidget(lbl)
self._clear_session_btn = QPushButton("Change modlist (clear session)")
self._clear_session_btn.setFlat(True)
self._clear_session_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
self._clear_session_btn.clicked.connect(self._on_clear_and_reopen)
layout.addWidget(self._clear_session_btn)
layout.addWidget(self._make_progress_section())
layout.addWidget(self._make_separator())
layout.addLayout(self._make_button_row())
def _make_header(self) -> QFrame:
card = QFrame()
card.setStyleSheet(
f"QFrame {{ background: {_BG_CARD}; border-radius: 6px; border: 1px solid {_BORDER}; }}"
)
vbox = QVBoxLayout(card)
vbox.setContentsMargins(12, 10, 12, 10)
vbox.setSpacing(4)
self._title_label = QLabel(f"<b>NXM Download: Mod {self.nxm.mod_id}</b>")
self._title_label.setStyleSheet(f"color: {_TEXT_BRIGHT}; font-size: 13px; border: none;")
self._title_label.setTextFormat(Qt.RichText)
vbox.addWidget(self._title_label)
detail = QLabel(
f"Game: <span style='color:{JACKIFY_COLOR_BLUE}'>{self.nxm.game}</span>"
f" &nbsp;|&nbsp; Mod ID: {self.nxm.mod_id}"
f" &nbsp;|&nbsp; File ID: {self.nxm.file_id}"
)
detail.setTextFormat(Qt.RichText)
detail.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px; border: none;")
vbox.addWidget(detail)
return card
def _make_section_label(self, text: str) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
return lbl
def _make_modlist_list(self) -> QListWidget:
self._list = QListWidget()
self._list.setMaximumHeight(150)
self._list.setStyleSheet(
f"QListWidget {{ background: {_BG_LIST}; color: {_TEXT}; "
f"border: 1px solid {_BORDER}; border-radius: 4px; }}"
f"QListWidget::item:selected {{ background: #2a3a4a; color: {_TEXT_BRIGHT}; }}"
f"QListWidget::item:hover {{ background: #222830; }}"
)
for ml in self.modlists:
item = QListWidgetItem(ml["name"])
item.setData(Qt.UserRole, ml)
self._list.addItem(item)
self._list.currentItemChanged.connect(self._on_selection_changed)
return self._list
def _make_dest_label(self) -> QLabel:
self._dest_label = QLabel("Download directory: (none)")
self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
self._dest_label.setWordWrap(True)
return self._dest_label
def _make_session_row(self) -> QFrame:
container = QFrame()
container.setStyleSheet("QFrame { border: none; }")
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
self._remember_cb = QCheckBox("Remember for this session")
self._remember_cb.setStyleSheet(f"color: {_TEXT};")
row.addWidget(self._remember_cb)
row.addStretch()
self._clear_btn = QPushButton("Clear remembered modlist")
self._clear_btn.setFlat(True)
self._clear_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
self._clear_btn.clicked.connect(self._on_clear_session)
row.addWidget(self._clear_btn)
self._update_clear_button()
return container
def _make_progress_section(self) -> QFrame:
container = QFrame()
container.setStyleSheet("QFrame { border: none; }")
vbox = QVBoxLayout(container)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.setSpacing(4)
self._status_label = QLabel("")
self._status_label.setStyleSheet(f"color: {_TEXT_DIM};")
self._status_label.setVisible(False)
self._status_label.setWordWrap(True)
self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
vbox.addWidget(self._status_label)
self._progress_bar = QProgressBar()
self._progress_bar.setStyleSheet(
f"QProgressBar {{ border: 1px solid {_BORDER}; border-radius: 4px; "
f"background: #2c2c2c; height: 10px; color: transparent; }}"
f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}"
)
self._progress_bar.setVisible(False)
vbox.addWidget(self._progress_bar)
return container
def _make_button_row(self) -> QHBoxLayout:
row = QHBoxLayout()
row.addStretch()
self._cancel_btn = QPushButton("Cancel")
self._cancel_btn.setStyleSheet(
f"QPushButton {{ background: #3a2020; color: {_TEXT}; border: 1px solid {_BORDER}; "
f"border-radius: 4px; padding: 5px 14px; }}"
f"QPushButton:hover {{ background: #5a2828; }}"
)
self._cancel_btn.clicked.connect(self.reject)
row.addWidget(self._cancel_btn)
self._download_btn = QPushButton("Download")
self._download_btn.setEnabled(False)
self._download_btn.setDefault(True)
self._download_btn.setStyleSheet(
f"QPushButton {{ background: {JACKIFY_COLOR_BLUE}; color: #000; font-weight: 600; "
f"border: none; border-radius: 4px; padding: 5px 18px; }}"
f"QPushButton:hover {{ background: #5ae0f5; }}"
f"QPushButton:disabled {{ background: #2a4a52; color: #555; }}"
)
self._download_btn.clicked.connect(self._on_download)
row.addWidget(self._download_btn)
return row
@staticmethod
def _make_separator() -> QFrame:
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet(f"color: {_BORDER};")
return sep
# --- Session memory ---
def showEvent(self, event) -> None:
super().showEvent(event)
if self._auto_start_modlist is not None:
QTimer.singleShot(0, self._start_auto_download)
def _start_auto_download(self) -> None:
self._start_download_for(self._auto_start_modlist)
def _on_clear_and_reopen(self) -> None:
nxm_session.clear_remembered_modlist()
self.reject()
dlg = NxmDownloadDialog(self.nxm, self.modlists, parent=self.parent())
dlg.show()
def _fetch_mod_name(self) -> None:
self._name_thread = _ModNameFetchThread(self.nxm, self)
self._name_thread.name_ready.connect(self._on_mod_name_ready)
self._name_thread.start()
def _on_mod_name_ready(self, name: str) -> None:
self._title_label.setText(f"<b>NXM Download: {name}</b>")
self._name_thread = None
def _apply_session_memory(self) -> None:
remembered = nxm_session.get_remembered_modlist()
if not remembered:
return
for i in range(self._list.count()):
if self._list.item(i).text() == remembered:
self._list.setCurrentRow(i)
self._remember_cb.setChecked(True)
break
def _on_clear_session(self) -> None:
nxm_session.clear_remembered_modlist()
self._remember_cb.setChecked(False)
self._update_clear_button()
def _update_clear_button(self) -> None:
self._clear_btn.setVisible(bool(nxm_session.get_remembered_modlist()))
def _on_selection_changed(self) -> None:
item = self._list.currentItem()
self._download_btn.setEnabled(item is not None)
if item:
modlist = item.data(Qt.UserRole)
self._update_dest_label(modlist)
def _update_dest_label(self, modlist: Dict) -> None:
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
modlist_dir = Path(modlist.get("modlist_dir", ""))
download_dir = resolve_mo2_download_dir(modlist_dir)
if download_dir:
self._dest_label.setText(f"Download directory: {download_dir}")
self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
else:
self._dest_label.setText("Download directory: not configured - run Configure first")
self._dest_label.setStyleSheet(f"color: {_ERROR}; font-size: 11px;")
# --- Download ---
def _on_download(self) -> None:
item = self._list.currentItem()
if not item:
return
modlist = item.data(Qt.UserRole)
if self._remember_cb.isChecked():
nxm_session.set_remembered_modlist(modlist["name"])
self._update_clear_button()
self._start_download_for(modlist)
def _start_download_for(self, modlist: Dict) -> None:
modlist_dir = Path(modlist.get("modlist_dir", ""))
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
download_dir = resolve_mo2_download_dir(modlist_dir)
if not download_dir or not download_dir.exists():
self._set_status(
f"Download directory not found for {modlist['name']}. "
"Run Configure for this modlist first.",
error=True,
)
return
token, method = self._get_auth()
if not token:
self._set_status(
"Not logged in to Nexus. Please log in via Settings > Nexus Authentication.",
error=True,
)
return
self._set_downloading(True)
self._thread = _DownloadThread(self.nxm, download_dir, token, method, self)
self._thread.progress.connect(self._on_progress)
self._thread.finished.connect(self._on_download_finished)
self._thread.start()
def _get_auth(self) -> Tuple[Optional[str], str]:
"""Return (token, auth_method). auth_method is 'oauth' or 'api_key'."""
try:
from jackify.backend.services.nexus_auth_service import NexusAuthService
svc = NexusAuthService()
token = svc.get_auth_token()
method = svc.get_auth_method() or "api_key"
if token:
return token, method
except Exception as e:
logger.warning("NexusAuthService unavailable: %s", e)
try:
from jackify.backend.services.api_key_service import APIKeyService
key = APIKeyService().get_saved_api_key()
if key:
return key, "api_key"
except Exception as e:
logger.warning("APIKeyService unavailable: %s", e)
return None, "api_key"
def _on_progress(self, downloaded: int, total: int) -> None:
if total > 0:
self._progress_bar.setValue(int(downloaded * 100 / total))
def _on_download_finished(self, success: bool, message: str) -> None:
self._set_downloading(False)
self._thread = None
if success:
self._set_status("Download complete. MO2 will pick it up automatically.", error=False)
QTimer.singleShot(3000, self.accept)
else:
self._set_status(f"Download failed: {message}", error=True)
def _set_downloading(self, active: bool) -> None:
self._download_btn.setEnabled(not active)
self._cancel_btn.setEnabled(not active)
if hasattr(self, "_list"):
self._list.setEnabled(not active)
self._progress_bar.setVisible(active)
self._progress_bar.setValue(0)
if active:
self._set_status("Downloading...", error=False)
def _set_status(self, text: str, error: bool = False) -> None:
self._status_label.setText(text)
self._status_label.setVisible(bool(text))
colour = _ERROR if error else JACKIFY_COLOR_BLUE
self._status_label.setStyleSheet(f"color: {colour};")
def show_no_modlists_error() -> None:
"""Show a standalone error when no modlists are found."""
from PySide6.QtWidgets import QMessageBox
msg = QMessageBox()
msg.setWindowTitle("Jackify - NXM Handler")
msg.setIcon(QMessageBox.Warning)
msg.setText(
"No installed modlists found.\n\n"
"NXM download handling requires at least one modlist that has been "
"installed and configured with Jackify."
)
msg.exec()
@@ -62,7 +62,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"QFrame#protontricksCard { "
" background: #2d2323; "
" border-radius: 12px; "
" border: 2px solid #e74c3c; "
" border: 2px solid #8b2020; "
"}"
)
@@ -74,7 +74,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"QLabel { "
" font-size: 36px; "
" font-weight: bold; "
" color: #e74c3c; "
" color: #f0c040; "
" margin-bottom: 4px; "
"}"
)
@@ -87,7 +87,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"QLabel { "
" font-size: 20px; "
" font-weight: 600; "
" color: #e74c3c; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -126,7 +126,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" text-align: center; "
"} "
"QProgressBar::chunk { "
" background-color: #4fc3f7; "
" background-color: #3fd0ea; "
" border-radius: 3px; "
"}"
)
@@ -139,7 +139,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.status_label.setStyleSheet(
"QLabel { "
" font-size: 14px; "
" color: #4fc3f7; "
" color: #3fd0ea; "
" margin: 8px 0; "
"}"
)
@@ -155,7 +155,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.flatpak_btn.clicked.connect(self._install_flatpak)
self.flatpak_btn.setStyleSheet(
"QPushButton { "
" background-color: #4fc3f7; "
" background-color: #1a5fa8; "
" color: white; "
" border: none; "
" border-radius: 6px; "
@@ -164,10 +164,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #3498db; "
" background-color: #2470b0; "
"} "
"QPushButton:pressed { "
" background-color: #2980b9; "
" background-color: #3fd0ea; "
"} "
"QPushButton:disabled { "
" background-color: #555; "
@@ -182,7 +182,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.native_btn.clicked.connect(self._show_native_guidance)
self.native_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 6px; "
@@ -191,10 +191,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(self.native_btn)
@@ -211,7 +211,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.redetect_btn.clicked.connect(self._redetect)
self.redetect_btn.setStyleSheet(
"QPushButton { "
" background-color: #27ae60; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -219,10 +219,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #229954; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #1e8449; "
" background-color: #3fd0ea; "
"}"
)
bottom_layout.addWidget(self.redetect_btn)
@@ -235,7 +235,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
exit_btn.clicked.connect(self._exit_app)
exit_btn.setStyleSheet(
"QPushButton { "
" background-color: #e74c3c; "
" background-color: #8b2020; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -243,10 +243,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #c0392b; "
" background-color: #a02828; "
"} "
"QPushButton:pressed { "
" background-color: #a93226; "
" background-color: #7a1a1a; "
"}"
)
bottom_layout.addWidget(exit_btn)
@@ -287,12 +287,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
if success:
self.status_label.setText("✓ Installation successful!")
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }")
# Auto-redetect after successful installation
self._redetect()
else:
self.status_label.setText(f"✗ Installation failed: {message}")
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }")
def _show_native_guidance(self):
"""Show native installation guidance"""
@@ -307,12 +307,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
if is_installed:
self.status_label.setText("✓ Protontricks found!")
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }")
self.status_label.setVisible(True)
self.accept() # Close dialog successfully
else:
self.status_label.setText("✗ Protontricks still not found")
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }")
self.status_label.setVisible(True)
def _exit_app(self):
@@ -86,7 +86,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.api_show_btn.setText("\U0001F441")
if checked:
self.api_key_edit.setEchoMode(QLineEdit.Normal)
self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }")
self.api_show_btn.setStyleSheet("QToolButton { color: #3fd0ea; }")
else:
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_show_btn.setStyleSheet("")
@@ -122,8 +122,8 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.config_handler.clear_api_key()
MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low")
def _on_api_key_changed(self, text):
api_key = text.strip()
def _on_api_key_changed(self):
api_key = self.api_key_edit.text().strip()
self.config_handler.save_api_key(api_key)
@@ -314,6 +314,9 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
# Save auto tool compat preference
self.config_handler.set('auto_tool_compat', self.auto_tool_compat_checkbox.isChecked())
self.config_handler.set('force_github_updates', self.force_github_updates_checkbox.isChecked())
if getattr(self, 'clf3_default_checkbox', None):
from jackify.backend.services.tool_registry import set_active_engine_id
set_active_engine_id("clf3" if self.clf3_default_checkbox.isChecked() else "jackify-engine")
# Save component installation method preference
if self.winetricks_radio.isChecked():
@@ -153,7 +153,7 @@ class SettingsDialogTabsMixin:
api_key = self.config_handler.get_api_key()
self.api_key_edit.setText(api_key if api_key else "")
self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)")
self.api_key_edit.textChanged.connect(self._on_api_key_changed)
self.api_key_edit.editingFinished.connect(self._on_api_key_changed)
self.api_show_btn = QToolButton()
self.api_show_btn.setCheckable(True)
self.api_show_btn.setIcon(QIcon.fromTheme("view-visible"))
@@ -297,6 +297,20 @@ class SettingsDialogTabsMixin:
self.force_github_updates_checkbox.setStyleSheet("color: #fff;")
component_layout.addWidget(self.force_github_updates_checkbox)
self.clf3_default_checkbox = QCheckBox("Use CLF3 as default install engine")
self.clf3_default_checkbox.setToolTip(
"Use CLF3 (SulfurNitride) as the default engine for all installs. "
"CLF3 will be downloaded automatically if not already installed. "
"You can still override this per-install on the Install screen."
)
self.clf3_default_checkbox.setStyleSheet("color: #fff;")
try:
from jackify.backend.services.tool_registry import get_active_engine_id
self.clf3_default_checkbox.setChecked(get_active_engine_id() == "clf3")
except Exception:
pass
component_layout.addWidget(self.clf3_default_checkbox)
advanced_layout.addWidget(component_group)
advanced_layout.addStretch()
self.tab_widget.addTab(advanced_tab, "Advanced")
+132 -17
View File
@@ -31,22 +31,32 @@ class SuccessDialog(QDialog):
- Return and Exit buttons
"""
def __init__(self, modlist_name: str, workflow_type: str, time_taken: str, game_name: str = None, parent=None):
def __init__(
self,
modlist_name: str,
workflow_type: str,
time_taken: str,
game_name: str = None,
verification_results=None,
parent=None,
):
super().__init__(parent)
self.modlist_name = modlist_name
self.workflow_type = workflow_type
self.time_taken = time_taken
self.game_name = game_name
self.setWindowTitle("Success!")
self.verification_results = verification_results
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setFixedSize(500, 500)
self.setFixedWidth(500)
self.setMinimumHeight(400)
self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True)
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(30, 20, 30, 20) # Reduced top/bottom margins to prevent truncation
layout.setContentsMargins(20, 20, 20, 20)
# --- Card background for content ---
card = QFrame(self)
@@ -69,22 +79,36 @@ class SuccessDialog(QDialog):
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# Success title (less saturated green)
title_label = QLabel("Success!")
has_verify_failures = bool(
self.verification_results and self.verification_results.failures
)
title_text = "Complete" if has_verify_failures else "Success!"
title_color = "#f0c040" if has_verify_failures else "#3fd0ea"
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { "
" font-size: 22px; "
" font-weight: 600; "
" color: #2ecc71; "
" margin-bottom: 2px; "
"}"
f"QLabel {{ "
f" font-size: 22px; "
f" font-weight: 600; "
f" color: {title_color}; "
f" margin-bottom: 2px; "
f"}}"
)
card_layout.addWidget(title_label)
# Personalized success message (modlist name in Jackify Blue, but less bold)
# Personalized message (modlist name in Jackify Blue, but less bold)
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
if self.workflow_type == "install":
if has_verify_failures:
suffix_map = {
"install": "installed with issues - review verification results.",
"update": "updated with issues - review verification results.",
"configure_new": "configured with issues - review verification results.",
"configure_existing": "configuration updated with issues - review verification results.",
"tool_config": "tool compatibility configured with issues - review verification results.",
}
suffix_text = suffix_map.get(self.workflow_type, "completed with issues - review verification results.")
elif self.workflow_type == "install":
suffix_text = "installed successfully!"
elif self.workflow_type == "update":
suffix_text = "updated successfully!"
@@ -95,7 +119,6 @@ class SuccessDialog(QDialog):
elif self.workflow_type == "tool_config":
suffix_text = "tool compatibility configured successfully!"
else:
# Fallback for other workflow types
message_text = self._build_success_message()
suffix_text = message_text.replace(self.modlist_name, "").strip()
@@ -153,12 +176,16 @@ class SuccessDialog(QDialog):
)
card_layout.addWidget(next_steps_label)
# Verification results summary
if self.verification_results is not None:
self._add_verification_section(card_layout)
# Subtle Ko-Fi support link
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#72A5F2; 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.setStyleSheet(
"QLabel { "
" color: #72A5F2; "
" color: #3fd0ea; "
" font-size: 11px; "
" margin-top: 8px; "
" padding: 4px; "
@@ -275,6 +302,94 @@ class SuccessDialog(QDialog):
# ENB Proton warning shown in separate dialog
return base_message
def _add_verification_section(self, card_layout):
"""Add a verification summary section to the card layout."""
from PySide6.QtWidgets import QScrollArea
r = self.verification_results
n_pass = len(r.passes)
n_warn = len(r.warnings)
n_fail = len(r.failures)
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet("color: #353a40;")
card_layout.addWidget(sep)
if n_fail:
summary_text = f"[FAIL] Verification: {n_fail} failure(s), {n_warn} warning(s)"
summary_color = "#e05050"
elif n_warn:
summary_text = f"[WARN] Verification: {n_warn} warning(s) - review before playing"
summary_color = "#f0c040"
else:
summary_text = f"[OK] Verification passed ({n_pass} checks)"
summary_color = "#3fd0ea"
summary_row = QHBoxLayout()
summary_row.setContentsMargins(0, 0, 0, 0)
summary_row.setSpacing(8)
summary_lbl = QLabel(summary_text)
summary_lbl.setAlignment(Qt.AlignCenter)
summary_lbl.setWordWrap(True)
summary_lbl.setStyleSheet(
f"QLabel {{ font-size: 12px; font-weight: bold; color: {summary_color}; }}"
)
summary_row.addStretch()
summary_row.addWidget(summary_lbl)
view_btn = QPushButton("View checks")
view_btn.setFixedWidth(90)
view_btn.setStyleSheet(
"QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; "
"border: 1px solid #555; border-radius: 4px; padding: 3px 6px; }"
"QPushButton:hover { background: #4a4a4a; color: #fff; }"
)
view_btn.setCursor(Qt.PointingHandCursor)
view_btn.clicked.connect(lambda: self._show_verification_detail())
summary_row.addWidget(view_btn)
summary_row.addStretch()
row_widget = QWidget()
row_widget.setLayout(summary_row)
card_layout.addWidget(row_widget)
if n_fail or n_warn:
detail_widget = QWidget()
detail_layout = QVBoxLayout(detail_widget)
detail_layout.setContentsMargins(0, 0, 0, 0)
detail_layout.setSpacing(2)
for msg in r.failures:
lbl = QLabel(f"[FAIL] {msg}")
lbl.setWordWrap(True)
lbl.setStyleSheet("color: #e05050; font-size: 11px;")
detail_layout.addWidget(lbl)
for msg in r.warnings:
lbl = QLabel(f"[WARN] {msg}")
lbl.setWordWrap(True)
lbl.setStyleSheet("color: #f0c040; font-size: 11px;")
detail_layout.addWidget(lbl)
scroll = QScrollArea()
scroll.setWidget(detail_widget)
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(120)
scroll.setStyleSheet(
"QScrollArea { border: 1px solid #353a40; border-radius: 4px; background: #1a1d23; }"
)
card_layout.addWidget(scroll)
def _show_verification_detail(self):
"""Open the full verification results dialog."""
try:
from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog
dlg = VerificationResultsDialog(self.verification_results, parent=self)
dlg.show()
except Exception as exc:
logger.error("Could not open verification dialog: %s", exc)
def _update_countdown(self):
if self._countdown > 0:
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
@@ -389,11 +389,13 @@ class UlimitGuidanceDialog(QDialog):
"""Apply dialog styling"""
self.setStyleSheet("""
QDialog {
background-color: #f5f5f5;
background-color: #232323;
color: #e0e0e0;
}
QGroupBox {
font-weight: bold;
border: 2px solid #cccccc;
color: #e0e0e0;
border: 2px solid #3a3a3a;
border-radius: 5px;
margin-top: 1ex;
padding-top: 10px;
@@ -404,13 +406,14 @@ class UlimitGuidanceDialog(QDialog):
padding: 0 5px 0 5px;
}
QTextEdit {
background-color: #ffffff;
border: 1px solid #cccccc;
background-color: #2a2a2a;
color: #e0e0e0;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 5px;
}
QPushButton {
background-color: #007acc;
background-color: #4a5568;
color: white;
border: none;
padding: 8px 16px;
@@ -418,14 +421,14 @@ class UlimitGuidanceDialog(QDialog):
font-weight: bold;
}
QPushButton:hover {
background-color: #005a9e;
background-color: #5a6578;
}
QPushButton:pressed {
background-color: #004175;
background-color: #3fd0ea;
}
QPushButton:disabled {
background-color: #cccccc;
color: #666666;
background-color: #333;
color: #666;
}
""")
@@ -0,0 +1,210 @@
"""Verification results dialog shown after install/configure workflows."""
import logging
from PySide6.QtCore import Qt
from PySide6.QtGui import QClipboard, QGuiApplication
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QVBoxLayout,
)
logger = logging.getLogger(__name__)
_COLOR_FAIL = "#e05050"
_COLOR_WARN = "#f0c040"
_COLOR_OK = "#3fd0ea"
_COLOR_DIM = "#888888"
def _html_row(prefix: str, color: str, msg: str) -> str:
safe = msg.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return (
f'<span style="color:{color}; font-family:monospace;">'
f'<b>{prefix}</b>&nbsp;{safe}'
f'</span><br>'
)
class VerificationResultsDialog(QDialog):
"""Shows the output of verify_install.py after a workflow completes."""
def __init__(self, results, parent=None):
super().__init__(parent)
self.setWindowTitle("Installation Verification")
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setMinimumWidth(600)
self._results = results
n_pass = len(results.passes)
n_warn = len(results.warnings)
n_fail = len(results.failures)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
# --- Headline ---
if n_fail:
headline = f"[FAIL] {n_fail} failure{'s' if n_fail != 1 else ''}, {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed"
h_color = _COLOR_FAIL
elif n_warn:
headline = f"[WARN] {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed"
h_color = _COLOR_WARN
else:
headline = f"[OK] All {n_pass} checks passed"
h_color = _COLOR_OK
headline_label = QLabel(headline)
headline_label.setStyleSheet(
f"font-size: 14px; font-weight: bold; color: {h_color};"
)
layout.addWidget(headline_label)
# --- Issues view (always visible) ---
self._issues_view = QTextEdit()
self._issues_view.setReadOnly(True)
self._issues_view.setStyleSheet(
"QTextEdit { background: #1e1e1e; border: 1px solid #444; "
"border-radius: 4px; font-size: 12px; padding: 6px; }"
)
self._issues_view.setHtml(self._build_issues_html(results))
self._issues_view.setMinimumHeight(80)
self._issues_view.setMaximumHeight(220)
layout.addWidget(self._issues_view)
# --- Full report view (hidden by default) ---
self._full_view = QTextEdit()
self._full_view.setReadOnly(True)
self._full_view.setStyleSheet(
"QTextEdit { background: #1a1a1a; border: 1px solid #333; "
"border-radius: 4px; font-size: 11px; padding: 6px; }"
)
self._full_view.setHtml(self._build_full_html(results))
self._full_view.setMinimumHeight(160)
self._full_view.setMaximumHeight(300)
self._full_view.setVisible(False)
layout.addWidget(self._full_view)
# --- Button row ---
btn_row = QHBoxLayout()
self._toggle_btn = QPushButton("Show all checks")
self._toggle_btn.setFixedWidth(130)
self._toggle_btn.setStyleSheet(
"QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; "
"border: 1px solid #555; border-radius: 4px; padding: 4px 8px; }"
"QPushButton:hover { background: #4a4a4a; color: #fff; }"
)
self._toggle_btn.setCursor(Qt.PointingHandCursor)
self._toggle_btn.clicked.connect(self._toggle_full_report)
btn_row.addWidget(self._toggle_btn)
btn_row.addStretch()
copy_btn = QPushButton("Copy Report")
copy_btn.setFixedWidth(110)
copy_btn.setToolTip("Copy plain-text report to clipboard")
copy_btn.clicked.connect(self._copy_report)
btn_row.addWidget(copy_btn)
close_btn = QPushButton("Close")
close_btn.setFixedWidth(80)
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.adjustSize()
# ------------------------------------------------------------------
def _build_issues_html(self, results) -> str:
lines = []
for msg in results.failures:
lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg))
for msg in results.warnings:
lines.append(_html_row("[WARN]", _COLOR_WARN, msg))
if not results.failures and not results.warnings:
lines.append(_html_row("[OK]", _COLOR_OK, "No issues found. Your modlist is correctly configured."))
return "".join(lines)
def _build_full_html(self, results) -> str:
lines = []
if results.failures:
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">FAILURES</span><br>')
for msg in results.failures:
lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg))
lines.append("<br>")
if results.warnings:
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">WARNINGS</span><br>')
for msg in results.warnings:
lines.append(_html_row("[WARN]", _COLOR_WARN, msg))
lines.append("<br>")
if results.passes:
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">PASSED</span><br>')
for msg in results.passes:
lines.append(_html_row("[OK] ", _COLOR_OK, msg))
components = getattr(results, "installed_components", [])
if components:
lines.append("<br>")
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">INSTALLED COMPONENTS ({len(components)})</span><br>')
for c in components:
method = c.get("method", "unknown")
method_color = _COLOR_OK if method == "native" else _COLOR_DIM
safe_name = c["name"].replace("&", "&amp;").replace("<", "&lt;")
lines.append(
f'<span style="font-family:monospace; font-size:11px;">'
f'{safe_name}'
f'&nbsp;<span style="color:{method_color}; font-size:10px;">({method})</span>'
f'</span><br>'
)
return "".join(lines)
def _build_plain_text(self) -> str:
r = self._results
n_pass = len(r.passes)
n_warn = len(r.warnings)
n_fail = len(r.failures)
lines = ["Jackify - Installation Verification", "=" * 40]
if n_fail:
lines.append(f"RESULT: FAILED ({n_fail} failures, {n_warn} warnings, {n_pass} passed)")
elif n_warn:
lines.append(f"RESULT: WARNING ({n_warn} warnings, {n_pass} passed)")
else:
lines.append(f"RESULT: OK (all {n_pass} checks passed)")
lines.append("")
if r.failures:
lines.append("FAILURES:")
for msg in r.failures:
lines.append(f" [FAIL] {msg}")
lines.append("")
if r.warnings:
lines.append("WARNINGS:")
for msg in r.warnings:
lines.append(f" [WARN] {msg}")
lines.append("")
if r.passes:
lines.append("PASSED:")
for msg in r.passes:
lines.append(f" [OK] {msg}")
components = getattr(r, "installed_components", [])
if components:
lines.append("")
lines.append("INSTALLED COMPONENTS:")
for c in components:
lines.append(f" {c['name']} ({c.get('method', 'unknown')})")
return "\n".join(lines)
def _toggle_full_report(self):
visible = self._full_view.isVisible()
self._full_view.setVisible(not visible)
self._toggle_btn.setText("Hide all checks" if not visible else "Show all checks")
self.adjustSize()
def _copy_report(self):
QGuiApplication.clipboard().setText(self._build_plain_text())
+17 -17
View File
@@ -49,7 +49,7 @@ class WarningDialog(QDialog):
"QFrame#warningCard { "
" background: #2d2323; "
" border-radius: 12px; "
" border: 2px solid #e67e22; "
" border: 2px solid #f0c040; "
"}"
)
@@ -61,7 +61,7 @@ class WarningDialog(QDialog):
"QLabel { "
" font-size: 36px; "
" font-weight: bold; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 4px; "
"}"
)
@@ -74,7 +74,7 @@ class WarningDialog(QDialog):
"QLabel { "
" font-size: 20px; "
" font-weight: 600; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -106,7 +106,7 @@ class WarningDialog(QDialog):
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -118,11 +118,11 @@ class WarningDialog(QDialog):
self._default_lineedit_style = (
"QLineEdit { "
" font-size: 15px; "
" border: 1px solid #e67e22; "
" border: 1px solid #f0c040; "
" border-radius: 6px; "
" padding: 6px; "
" background: #23272e; "
" color: #e67e22; "
" color: #e0e0e0; "
"}"
)
self.confirm_edit.setStyleSheet(self._default_lineedit_style)
@@ -140,7 +140,7 @@ class WarningDialog(QDialog):
cancel_btn.clicked.connect(self.reject)
cancel_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -148,10 +148,10 @@ class WarningDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(cancel_btn)
@@ -161,7 +161,7 @@ class WarningDialog(QDialog):
confirm_btn.clicked.connect(self._on_confirm)
confirm_btn.setStyleSheet(
"QPushButton { "
" background-color: #e67e22; "
" background-color: #8b2020; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -169,10 +169,10 @@ class WarningDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #d35400; "
" background-color: #a02828; "
"} "
"QPushButton:pressed { "
" background-color: #b34700; "
" background-color: #7a1a1a; "
"}"
)
button_layout.addWidget(confirm_btn)
@@ -200,7 +200,7 @@ class WarningDialog(QDialog):
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -231,7 +231,7 @@ class WarningDialog(QDialog):
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #c0392b; " # Red for error
" color: #e05050; "
" margin-bottom: 2px; "
" font-weight: bold; "
"}"
@@ -242,10 +242,10 @@ class WarningDialog(QDialog):
self.confirm_edit.setStyleSheet(
"QLineEdit { "
" font-size: 15px; "
" border: 2px solid #c0392b; " # Red border for error
" border: 2px solid #8b2020; "
" border-radius: 6px; "
" padding: 6px; "
" background: #3b2323; " # Darker red background
" color: #e67e22; "
" background: #3b2323; "
" color: #e0e0e0; "
"}"
)
+48 -18
View File
@@ -56,7 +56,13 @@ if '--env-diagnostic' in sys.argv:
env_data['ld_library_path_suspicious'] = suspicious
# Try to find jackify-engine from bundled context
meipass = getattr(sys, '_MEIPASS', None)
appdir = os.environ.get('APPDIR')
engine_paths = []
if appdir:
appdir_engine = Path(appdir) / 'opt' / 'jackify' / 'engine' / 'jackify-engine'
if appdir_engine.exists():
engine_paths.append(str(appdir_engine))
if meipass:
meipass_path = Path(meipass)
potential_engine = meipass_path / "jackify" / "engine" / "jackify-engine"
@@ -86,13 +92,24 @@ from jackify import __version__ as jackify_version
logger = logging.getLogger(__name__)
if '--help' in sys.argv or '-h' in sys.argv:
print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n\nIf no options are given, the GUI will launch by default.\n""")
print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n jackify --list-installed [APPID]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n --list-installed List installed Wine components for a modlist prefix.\n Provide APPID to target a specific prefix, or omit to\n select from configured modlists interactively.\n\nIf no options are given, the GUI will launch by default.\n""")
sys.exit(0)
if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv:
print(f"Jackify version {jackify_version}")
sys.exit(0)
if '--list-installed' in sys.argv:
_li_idx = sys.argv.index('--list-installed')
_li_appid = (
sys.argv[_li_idx + 1]
if _li_idx + 1 < len(sys.argv) and not sys.argv[_li_idx + 1].startswith('-')
else None
)
from jackify.frontends.cli.commands.list_installed import ListInstalledCommand
ListInstalledCommand().run(appid=_li_appid)
sys.exit(0)
from jackify import __version__
# Add src directory to Python path
@@ -124,20 +141,6 @@ from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder
ENABLE_WINDOW_HEIGHT_ANIMATION = False
# Constants for styling and disclaimer
DISCLAIMER_TEXT = (
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
"without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. "
"The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. "
"Please back up your data and use caution."
)
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks"),
("Hoolamike Tasks", "hoolamike_tasks"),
("Additional Tasks", "additional_tasks"),
("Exit Jackify", "exit_jackify"),
]
class JackifyMainWindow(
MainWindowGeometryMixin,
@@ -210,7 +213,7 @@ def resource_path(relative_path):
jackify_dir = os.path.dirname(os.path.dirname(current_dir))
return os.path.join(jackify_dir, relative_path)
def main():
def main(initial_nxm_url: str = ""):
"""Main entry point for the GUI application"""
# CRITICAL: Enable faulthandler for segfault debugging
# Print Python stack traces on segfault
@@ -245,19 +248,41 @@ def main():
# Command-line --debug always takes precedence
if '--debug' in sys.argv or '-d' in sys.argv:
debug_mode = True
# Temporarily save CLI debug flag to config so engine can see it
config_handler.set('debug_mode', True)
os.environ.setdefault('QT_LOGGING_RULES', '*.debug=true')
os.environ.setdefault('QT_DEBUG_PLUGINS', '1')
import logging
# Initialize root logger: jackify.log (INFO, always) + jackify-debug.log (DEBUG, debug mode only)
from jackify.shared.logging import LoggingHandler
root_logger = LoggingHandler().setup_application_logging(debug_mode)
_unhandled_exception_shown = False
def _unhandled_exception(exc_type, exc_value, exc_tb):
nonlocal _unhandled_exception_shown
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_tb)
return
logging.getLogger().critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_tb))
if not _unhandled_exception_shown:
_unhandled_exception_shown = True
try:
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
except Exception:
log_dir = "the Jackify log directory"
def _show_dialog():
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(
None,
"Unexpected Error",
f"Jackify encountered an unexpected error. The current operation may not have completed.\n\n"
f"{exc_value}\n\n"
f"Details were written to:\n{log_dir}",
)
QTimer.singleShot(0, _show_dialog)
sys.excepthook = _unhandled_exception
@@ -277,7 +302,7 @@ def main():
dev_mode = '--dev' in sys.argv
# Launch GUI application
app = QApplication(sys.argv)
app = QApplication.instance() or QApplication(sys.argv)
# CRITICAL: Set application name before desktop file name to ensure proper window title/icon on PopOS/Ubuntu
app.setApplicationName("Jackify")
app.setApplicationDisplayName("Jackify")
@@ -363,6 +388,11 @@ def main():
# Start background update check after window is shown
window._check_for_updates_on_startup()
window._check_tool_updates_on_startup()
if initial_nxm_url:
from PySide6.QtCore import QTimer
QTimer.singleShot(500, lambda: window._on_nxm_url_received(initial_nxm_url))
# Ensure cleanup on exit
import atexit
@@ -32,6 +32,15 @@ class MainWindowBackendMixin:
from jackify.backend.services.update_service import UpdateService
from jackify import __version__
self.update_service = UpdateService(__version__)
from jackify.backend.services.nxm_ipc import NxmIpcServer
self._nxm_ipc_server = NxmIpcServer(self)
self._nxm_ipc_server.url_received.connect(self._on_nxm_url_received)
self._nxm_ipc_server.start()
from jackify.backend.services.nxm_protocol import ensure_nxm_registered
ensure_nxm_registered()
logger.debug(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
def _is_steamdeck(self):
@@ -126,8 +126,39 @@ class MainWindowDialogsMixin:
start_new_session=True
)
def _on_nxm_url_received(self, url: str) -> None:
"""Handle an incoming nxm:// URL from the IPC server or initial launch."""
try:
from jackify.backend.services.nxm_url import parse_nxm_url
from jackify.backend.services import nxm_session
from jackify.tools.verify_install import discover_installed_modlists
from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog
nxm = parse_nxm_url(url)
modlists = discover_installed_modlists()
if not modlists:
from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error
show_no_modlists_error()
return
# Prefer the actively running MO2 instance; fall back to session memory.
auto_start = nxm_session.detect_active_mo2_modlist(modlists)
if auto_start is None:
remembered = nxm_session.get_remembered_modlist()
if remembered:
auto_start = next((m for m in modlists if m["name"] == remembered), None)
dlg = NxmDownloadDialog(nxm, modlists, parent=self, auto_start_modlist=auto_start)
dlg.show()
dlg.raise_()
dlg.activateWindow()
except Exception as e:
logger.warning("Failed to handle NXM URL %r: %s", url, e)
def cleanup_processes(self):
try:
if hasattr(self, '_nxm_ipc_server') and self._nxm_ipc_server is not None:
self._nxm_ipc_server.stop()
if hasattr(self, '_update_thread') and self._update_thread is not None:
self._update_thread = self._stop_qthread(self._update_thread, "_update_thread")
if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None:
@@ -135,10 +135,10 @@ class MainWindowGeometryMixin:
self.showMaximized()
def _on_child_resize_request(self, mode: str):
logger.debug(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
logger.debug(f"_on_child_resize_request called with mode='{mode}', current_size={self.size()}")
try:
if self.system_info and self.system_info.is_steamdeck:
logger.debug("DEBUG: Steam Deck detected, ignoring resize request")
logger.debug("Steam Deck detected, ignoring resize request")
try:
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
self.install_ttw_screen.show_details_checkbox.setVisible(False)
@@ -183,7 +183,7 @@ class MainWindowGeometryMixin:
before = self.size()
self._programmatic_resize = True
self.resize(self.size().width(), target_height)
logger.debug(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
logger.debug(f"Animated fallback resize from {before} to {self.size()}")
QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False))
return
start_rect = self.geometry()
@@ -65,6 +65,47 @@ class MainWindowStartupMixin:
except Exception as e:
print(f"Error checking protontricks: {e}")
def _check_tool_updates_on_startup(self):
class _ToolUpdateCheckThread(QThread):
updates_found = Signal(bool)
def run(self):
try:
from jackify.backend.services.tool_registry import ToolRegistry, get_effective_definitions
registry = ToolRegistry()
for defn in get_effective_definitions():
if defn.pinned_version is not None:
continue
status = registry.get_status(defn.tool_id)
if not status or not status.installed:
continue
logger.debug(
"Startup tool update check: %s installed=%s version=%s",
defn.tool_id, status.installed, status.installed_version,
)
tag = registry.check_latest_version(defn.tool_id)
logger.debug("Startup tool update check: %s latest=%s", defn.tool_id, tag)
if tag and status.installed_version and tag.lstrip("v") != status.installed_version.lstrip("v"):
logger.debug("Startup tool update check: update available for %s", defn.tool_id)
self.updates_found.emit(True)
return
self.updates_found.emit(False)
except Exception as e:
logger.warning("Tool update check failed: %s", e, exc_info=True)
self.updates_found.emit(False)
def on_result(has_updates: bool):
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: (
self.main_menu.notify_tool_updates(has_updates)
if hasattr(self, 'main_menu') and hasattr(self.main_menu, 'notify_tool_updates')
else None
))
self._tool_update_check_thread = _ToolUpdateCheckThread()
self._tool_update_check_thread.updates_found.connect(on_result)
self._tool_update_check_thread.start()
def _check_for_updates_on_startup(self):
try:
logger.debug("Checking for updates on startup...")
@@ -76,13 +76,13 @@ class MainWindowUIMixin:
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
bottom_bar_layout.addStretch(1)
kofi_link = QLabel('<a href="#" style="color:#72A5F2; text-decoration:none;">Support on Ko-fi</a>')
kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;")
kofi_link = QLabel('<a href="#" style="color:#3fd0ea; text-decoration:none;">Support on Ko-fi</a>')
kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;")
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
kofi_link.setOpenExternalLinks(False)
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
kofi_link.setToolTip("Support Jackify development")
bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter)
bottom_bar_layout.addWidget(kofi_link)
bottom_bar_layout.addStretch(1)
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
@@ -229,9 +229,9 @@ class MainWindowUIMixin:
return screen
def _make_third_party_tools_screen(self):
from jackify.frontends.gui.screens.third_party_tools import ThirdPartyToolsScreen
screen = ThirdPartyToolsScreen(
stacked_widget=self.stacked_widget, main_menu_index=0,
from jackify.frontends.gui.screens.tools_hub import ToolsHubScreen
screen = ToolsHubScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, ttw_screen_index=5,
)
self.third_party_tools_screen = screen
return screen
@@ -19,6 +19,7 @@ Usage:
"""
import logging
import warnings
from typing import List, Optional
logger = logging.getLogger(__name__)
@@ -46,10 +47,12 @@ class ThreadLifecycleMixin:
return None
for name in (signal_names or []):
try:
getattr(thread, name).disconnect()
except Exception:
pass
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
try:
getattr(thread, name).disconnect()
except Exception:
pass
# Register in the module-level set so the reference survives screen destruction.
# Remove from registry when the thread finishes so it can be GC'd cleanly.
@@ -68,6 +71,36 @@ class ThreadLifecycleMixin:
pass
self._park_all_threads()
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
"""Kill wine/winetricks subprocesses on user-initiated cancel.
Called before parking threads so the blocked subprocess.run() calls inside
the thread actually return rather than running until completion.
"""
import os
import subprocess
for pattern in ('winetricks', 'protontricks'):
subprocess.run(['pkill', '-9', '-f', pattern], capture_output=True)
if not appid:
return
try:
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.handlers.winetricks_handler import WinetricksHandler
compat = PathHandler.find_compat_data(str(appid))
if not compat:
return
pfx = str(compat / 'pfx')
wine_bin = WinetricksHandler()._get_wine_binary_for_prefix(pfx)
if not wine_bin:
return
wineserver = os.path.join(os.path.dirname(wine_bin), 'wineserver')
if os.path.isfile(wineserver):
subprocess.run([wineserver, '-k'],
env={**os.environ, 'WINEPREFIX': pfx},
timeout=5, capture_output=True)
except Exception:
pass
def _park_all_threads(self):
"""Park every running QThread attribute found on this instance.
+203 -27
View File
@@ -1,8 +1,7 @@
"""
Additional Tasks & Tools Screen
Simple screen for TTW automation only.
Follows the same pattern as ModlistTasksScreen.
Additional tools and automation. Follows the same pattern as ModlistTasksScreen.
"""
import logging
@@ -23,7 +22,7 @@ logger = logging.getLogger(__name__)
class AdditionalTasksScreen(QWidget):
"""Simple Additional Tasks screen for TTW only"""
"""Additional Tasks screen for automation and standalone tools."""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
install_mo2_screen_index: int = 9):
@@ -67,7 +66,7 @@ class AdditionalTasksScreen(QWidget):
header_layout.addSpacing(10)
# Description area with fixed height
desc = QLabel("TTW automation, Wabbajack installer, and additional tools.")
desc = QLabel("Wabbajack installer, MO2 setup, and additional tools.")
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; font-size: 13px;")
desc.setAlignment(Qt.AlignHCenter)
@@ -93,10 +92,11 @@ class AdditionalTasksScreen(QWidget):
"""Set up the menu buttons section"""
# Menu options
MENU_ITEMS = [
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
("Run Install Verifier", "run_verifier", "Check an installed modlist for common configuration problems"),
("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"),
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
("Create Diagnostic Bundle", "diagnostic_bundle", "Package logs and system info for support reporting"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
@@ -148,25 +148,19 @@ class AdditionalTasksScreen(QWidget):
def _handle_button_click(self, action_id):
"""Handle button clicks"""
if action_id == "ttw_install":
self._show_ttw_info()
if action_id == "run_verifier":
self._run_install_verifier()
elif action_id == "wabbajack_install":
self._show_wabbajack_installer()
elif action_id == "setup_mo2":
self._show_mo2_setup()
elif action_id == "tool_config":
self._show_tool_config()
elif action_id == "coming_soon":
self._show_coming_soon_info()
elif action_id == "diagnostic_bundle":
self._run_diagnostic_bundle()
elif action_id == "return_main_menu":
self._return_to_main_menu()
def _show_ttw_info(self):
"""Navigate to TTW installation screen"""
if self.stacked_widget:
# Navigate to TTW installation screen (index 5)
self.stacked_widget.setCurrentIndex(5)
def _show_wabbajack_installer(self):
"""Navigate to Wabbajack installer screen"""
if self.stacked_widget:
@@ -178,20 +172,202 @@ class AdditionalTasksScreen(QWidget):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.install_mo2_screen_index)
def _show_coming_soon_info(self):
"""Show coming soon info"""
from ..services.message_service import MessageService
MessageService.information(
self,
"Coming Soon",
"Additional tools and features will be added in future updates.\n\n"
"Check back later for more functionality!"
)
def _show_tool_config(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(11)
def _run_install_verifier(self):
"""Prompt user to pick a modlist, run the verifier, and show results."""
from ..services.message_service import MessageService
try:
from jackify.backend.services.install_verifier_service import _load_verifier
verifier_mod = _load_verifier()
modlists = verifier_mod.discover_installed_modlists()
except Exception as e:
MessageService.critical(
self,
"Verifier Error",
f"Could not load install verifier: {e}",
)
return
if not modlists:
MessageService.information(
self,
"No Modlists Found",
"No installed modlists were found in Steam shortcuts.\n\n"
"Ensure ModOrganizer.exe shortcuts exist in Steam for your modlists.",
)
return
from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QHBoxLayout
picker = QDialog(self)
picker.setWindowTitle("Select Modlist to Verify")
picker.setMinimumWidth(480)
picker.setMinimumHeight(260)
picker_layout = QVBoxLayout(picker)
picker_layout.addWidget(QLabel("Select a modlist to verify:"))
lw = QListWidget()
for m in modlists:
pfx_ok = m["pfx"] and m["pfx"].is_dir()
suffix = "" if pfx_ok else " (prefix not found)"
item = QListWidgetItem(f"{m['name']}{suffix}")
item.setData(1000, m)
lw.addItem(item)
lw.setCurrentRow(0)
picker_layout.addWidget(lw)
btn_row = QHBoxLayout()
ok_btn = QPushButton("Run Verifier")
cancel_btn = QPushButton("Cancel")
btn_row.addStretch()
btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn)
picker_layout.addLayout(btn_row)
ok_btn.clicked.connect(picker.accept)
cancel_btn.clicked.connect(picker.reject)
lw.itemDoubleClicked.connect(lambda _: picker.accept())
if picker.exec() != QDialog.Accepted:
return
selected_item = lw.currentItem()
if not selected_item:
return
selected = selected_item.data(1000)
pfx = selected.get("pfx")
if not pfx or not pfx.is_dir():
MessageService.warning(
self,
"Prefix Not Found",
f"The Proton prefix for '{selected['name']}' was not found.\n\n"
"Launch the modlist from Steam at least once to create the prefix.",
)
return
from PySide6.QtCore import QThread, Signal as _Signal
class _VerifierThread(QThread):
done = _Signal(object)
def __init__(self, verifier_module, entry, parent=None):
super().__init__(parent)
self._verifier = verifier_module
self._entry = entry
def run(self):
try:
r = self._verifier.run_verification(
pfx=self._entry["pfx"],
modlist_dir=self._entry["modlist_dir"],
game_type=self._entry["game_type"],
appid=self._entry["appid"],
modlist_name=self._entry.get("name", ""),
)
except Exception as exc:
logger.warning("On-demand verifier error: %s", exc)
r = None
self.done.emit(r)
from jackify.frontends.gui.services.message_service import MessageService as _MS
progress_dlg = QDialog(self)
progress_dlg.setWindowTitle("Verifying...")
progress_dlg.setModal(True)
prog_layout = QVBoxLayout(progress_dlg)
prog_layout.addWidget(QLabel(f"Running verifier for '{selected['name']}'...\nThis may take a moment."))
progress_dlg.setFixedSize(340, 100)
progress_dlg.show()
self._verifier_ondemand_thread = _VerifierThread(verifier_mod, selected, parent=self)
def _on_done(results):
progress_dlg.accept()
self._verifier_ondemand_thread = None
if results is None:
MessageService.critical(
self,
"Verifier Error",
"The verifier encountered an error and could not complete.",
)
return
from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog
dlg = VerificationResultsDialog(results, parent=self)
dlg.exec()
self._verifier_ondemand_thread.done.connect(_on_done)
self._verifier_ondemand_thread.start()
def _run_diagnostic_bundle(self):
"""Open the diagnostic bundle dialog; bundle is only created when the user confirms."""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit
)
from PySide6.QtCore import QThread, Signal as _Signal
class _BundleThread(QThread):
done = _Signal(object, str) # (bundle_path or None, error_msg)
def run(self):
try:
from jackify.backend.services.diagnostic_service import build_bundle
path = build_bundle()
self.done.emit(path, "")
except Exception as exc:
self.done.emit(None, str(exc))
dlg = QDialog(self)
dlg.setWindowTitle("Diagnostic Bundle")
dlg.setMinimumWidth(600)
dlg.setMinimumHeight(220)
dlg.setStyleSheet("QDialog { background: #181818; color: #fff; }")
layout = QVBoxLayout(dlg)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
status_label = QLabel("Package logs and system info into a file for support reporting.")
layout.addWidget(status_label)
path_box = QTextEdit()
path_box.setReadOnly(True)
path_box.setMinimumHeight(60)
path_box.setVisible(False)
layout.addWidget(path_box)
btn_row = QHBoxLayout()
create_btn = QPushButton("Create Bundle")
cancel_btn = QPushButton("Cancel")
btn_row.addWidget(create_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
cancel_btn.clicked.connect(dlg.reject)
def _on_create():
create_btn.setEnabled(False)
cancel_btn.setEnabled(False)
status_label.setText("Collecting logs and system info...")
self._diag_thread = _BundleThread(parent=self)
self._diag_thread.done.connect(_on_done)
self._diag_thread.start()
def _on_done(bundle_path, error):
self._diag_thread = None
cancel_btn.setEnabled(True)
cancel_btn.setText("Close")
if not bundle_path:
status_label.setText(f"Failed: {error}")
return
status_label.setText("Bundle created:")
path_box.setPlainText(str(bundle_path))
path_box.setVisible(True)
create_btn.clicked.connect(_on_create)
dlg.exec()
def _return_to_main_menu(self):
"""Return to main menu"""
if self.stacked_widget:
@@ -22,7 +22,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
from jackify.frontends.gui.services.message_service import MessageService
import logging
logger = logging.getLogger(__name__)
@@ -33,12 +32,14 @@ from .configure_existing_modlist_console import ConfigureExistingModlistConsoleM
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from .install_verifier_mixin import InstallVerifierMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class ConfigureExistingModlistScreen(
ThreadLifecycleMixin,
ScreenBackMixin,
TTWIntegrationMixin,
InstallVerifierMixin,
ConfigureExistingModlistUIMixin,
ConfigureExistingModlistWorkflowMixin,
ConfigureExistingModlistShortcutsMixin,
@@ -73,6 +74,7 @@ class ConfigureExistingModlistScreen(
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
self._kill_prefix_wine_processes(str(getattr(self, '_current_appid', '') or ''))
self.cleanup_processes()
self.collapse_show_details_before_leave()
self.go_back()
@@ -105,6 +107,10 @@ class ConfigureExistingModlistScreen(
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion"""
if getattr(self, '_awaiting_steam_restart', False):
self._deferred_completion_args = (success, message, modlist_name, enb_detected)
return
# Re-enable all controls when workflow completes
self._enable_controls_after_operation()
@@ -137,34 +143,27 @@ class ConfigureExistingModlistScreen(
'time_taken': self._calculate_time_taken(),
'game_name': getattr(self, '_current_game_name', None),
'enb_detected': enb_detected,
'install_dir': install_dir,
'game_type': game_type,
'appid': getattr(self, '_current_appid', '') or '',
}
return
# Calculate time taken
time_taken = self._calculate_time_taken()
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show success dialog with celebration
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="configure_existing",
time_taken=time_taken,
game_name=getattr(self, '_current_game_name', None),
parent=self
self._run_verifier_then_show_success(
install_dir=install_dir,
game_type=game_type,
appid=getattr(self, '_current_appid', '') or '',
success_params={
'modlist_name': modlist_name,
'workflow_type': 'configure_existing',
'time_taken': time_taken,
'game_name': getattr(self, '_current_game_name', None),
'enb_detected': enb_detected,
},
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec()
except Exception as e:
import logging
logging.getLogger(__name__).warning("Failed to show ENB dialog: %s", e)
else:
self._safe_append_text(f"Configuration failed: {message}")
MessageService.show_error(self, configuration_failed(str(message)))
@@ -184,17 +183,19 @@ class ConfigureExistingModlistScreen(
if not hasattr(self, 'config_thread') or self.config_thread is None:
return
try:
self.config_thread.progress_update.disconnect()
self.config_thread.configuration_complete.disconnect()
self.config_thread.error_occurred.disconnect()
except (RuntimeError, TypeError):
pass
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
try:
getattr(self.config_thread, sig_name).disconnect()
except (RuntimeError, TypeError, AttributeError):
pass
if self.config_thread.isRunning():
self.config_thread.quit()
self.config_thread.wait(5000)
# wait() ensures the OS thread has fully exited before deleteLater,
# preventing "QThread destroyed while still running" if isRunning()
# briefly trails the actual thread exit.
self.config_thread.wait(3000)
self.config_thread.deleteLater()
self.config_thread = None
@@ -225,7 +226,7 @@ class ConfigureExistingModlistScreen(
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread")
logger.debug("cleanup called - cleaning up ConfigurationThread")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
@@ -233,7 +234,7 @@ class ConfigureExistingModlistScreen(
# Clean up config thread if running
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
logger.debug("DEBUG: Parking ConfigurationThread")
logger.debug("Parking ConfigurationThread")
self.config_thread = self._park_thread(
self.config_thread,
["progress_update", "configuration_complete", "error_occurred"],
@@ -12,6 +12,52 @@ class ConfigureExistingModlistConsoleMixin:
def _handle_progress_update(self, text):
"""Handle progress updates - update console, activity window, and progress indicator"""
if text.startswith("[NATIVE_DL] "):
parts = text.split(None, 3)
if len(parts) == 4:
_, component, pct_str, speed_str = parts
try:
pct, speed = float(pct_str), float(speed_str)
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)",
pct,
)
except ValueError:
pass
return
if text.startswith("[NATIVE_INSTALL] "):
component = text[17:].strip()
self._stop_component_install_pulse()
done = getattr(self, '_native_done_components', 0)
total = getattr(self, '_native_total_components', 0)
remaining = max(0, total - done)
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine component: {component}{suffix}",
0.0,
)
self.progress_indicator.set_status(f"Installing {component}...", 60)
self._current_native_component = component
self._native_done_components = done + 1
return
if text.startswith("[NATIVE_WAIT] "):
parts = text.split(None, 2)
if len(parts) >= 3:
component, elapsed_s = parts[1], parts[2].strip()
done = getattr(self, '_native_done_components', 1)
total = getattr(self, '_native_total_components', 0)
remaining = max(0, total - done)
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine component: {component} | {elapsed_s}s{suffix}",
0.0,
)
self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 60)
return
# Always append to console
self._safe_append_text(text)
@@ -27,13 +73,24 @@ class ConfigureExistingModlistConsoleMixin:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Applying registry files...", 40)
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
elif "installing wine components" in message_lower or "wine component" in message_lower:
elif (
"installing wine components" in message_lower
or "wine component" in message_lower
or "vcrun" in message_lower
or ("dotnet" in message_lower and "fix" not in message_lower)
):
self.progress_indicator.set_status("Installing wine components...", 60)
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
self._start_component_install_pulse()
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
self._native_total_components = len(comp_list)
self._native_done_components = 0
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine components: {len(comp_list)} queued",
0.0,
)
elif not getattr(self, '_component_install_timer', None) or not self._component_install_timer.isActive():
self._start_component_install_pulse()
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Wine components installed", 65)
@@ -127,14 +184,22 @@ class ConfigureExistingModlistConsoleMixin:
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
return
if hasattr(self, '_component_install_list') and self._component_install_list:
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in self._component_install_list
]
dl_state = getattr(self, '_native_component_progress', {})
progresses = []
for comp in self._component_install_list:
if comp in dl_state:
pct, speed = dl_state[comp]
progresses.append(FileProgress(
filename=f"Wine component: {comp} | {pct:.0f}% ({speed:.1f} MB/s)",
operation=OperationType.DOWNLOAD,
percent=pct,
))
else:
progresses.append(FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
))
self.file_progress_list.update_files(progresses, current_phase=None)
else:
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
@@ -146,5 +211,7 @@ class ConfigureExistingModlistConsoleMixin:
self._component_install_timer = None
if hasattr(self, '_component_install_list'):
del self._component_install_list
if hasattr(self, '_native_component_progress'):
del self._native_component_progress
@@ -19,7 +19,7 @@ class ConfigureExistingModlistUIMixin:
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
super().__init__()
logger.debug("DEBUG: ConfigureExistingModlistScreen __init__ called")
logger.debug("ConfigureExistingModlistScreen __init__ called")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
from jackify.backend.models.configuration import SystemInfo
@@ -183,7 +183,7 @@ class ConfigureExistingModlistUIMixin:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
@@ -56,20 +56,21 @@ class ConfigureExistingModlistWorkflowMixin:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
return
self._current_appid = shortcut.get('AppID', shortcut.get('appid', ''))
raw_appid = shortcut.get('AppID', shortcut.get('appid', ''))
self._current_appid = str(raw_appid) if raw_appid != '' else ''
resolution = self.resolution_combo.currentText()
# Handle resolution saving
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
logger.debug(f"Resolution saved successfully: {resolution}")
else:
logger.debug("DEBUG: Failed to save resolution")
logger.debug("Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
logger.debug("DEBUG: Saved resolution cleared")
logger.debug("Saved resolution cleared")
# Start the workflow (no shortcut creation needed)
self.start_workflow(modlist_name, install_dir, resolution)
@@ -104,6 +105,7 @@ class ConfigureExistingModlistWorkflowMixin:
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
steam_restart_needed = Signal(str, str, str) # app_name, exe_path, dl_path
def __init__(self, modlist_name, install_dir, resolution, system_info, detect_func):
super().__init__()
@@ -128,10 +130,11 @@ class ConfigureExistingModlistWorkflowMixin:
# Create modlist context for existing modlist configuration
mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe")
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
modlist_context = ModlistContext(
name=self.modlist_name,
install_dir=Path(self.install_dir),
download_dir=None,
download_dir=resolve_mo2_download_dir(Path(self.install_dir)),
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration-only
modlist_value='', # Not needed for existing modlist
@@ -147,26 +150,31 @@ class ConfigureExistingModlistWorkflowMixin:
# Define callbacks
def progress_callback(message):
self.progress_update.emit(message)
# Store completion args rather than emitting immediately so we can
# emit steam_restart_needed first when a restart is required.
completion_args = [None]
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# Existing modlists shouldn't need manual steps, but handle gracefully
self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})")
# Call the working configuration service method
completion_args[0] = (success, message, modlist_name, enb_detected)
self.progress_update.emit("Starting existing modlist configuration...")
# For existing modlists, call configure_modlist_post_steam directly
# since Steam setup and manual steps should already be done
success = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
if getattr(modlist_context, 'steam_restart_needed', False):
self.steam_restart_needed.emit(
getattr(modlist_context, 'mounts_app_name', ''),
getattr(modlist_context, 'mounts_exe_path', ''),
getattr(modlist_context, 'mounts_dl_path', ''),
)
if completion_args[0] is not None:
self.configuration_complete.emit(*completion_args[0])
if not success:
self.error_occurred.emit(
"Configuration did not complete successfully. "
@@ -183,12 +191,53 @@ class ConfigureExistingModlistWorkflowMixin:
self.config_thread.progress_update.connect(self._handle_progress_update)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
self.config_thread.steam_restart_needed.connect(self._on_steam_restart_needed) # (app_name, exe_path, dl_path)
self.config_thread.start()
except Exception as e:
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
MessageService.show_error(self, configuration_failed(str(e)))
def _on_steam_restart_needed(self, app_name: str, exe_path: str, dl_path: str):
"""Prompt before stopping Steam to write a deferred STEAM_COMPAT_MOUNTS update.
Receives the shortcut identity and path from the signal so it can call
ensure_mounts_in_steam_compat on the GUI's ShortcutHandler (not the backend's).
Sets _awaiting_steam_restart before showing the dialog so that
on_configuration_complete (fired by the nested event loop during exec())
defers the success/ENB dialogs until after this handler finishes.
"""
from PySide6.QtWidgets import QMessageBox
from jackify.frontends.gui.services.message_service import MessageService
self._awaiting_steam_restart = True
try:
reply = MessageService.question(
self,
"Restart Steam?",
"The download directory mount needs to be added to STEAM_COMPAT_MOUNTS for this "
"modlist. Steam must be stopped to write this change safely.\n\n"
"Any running game will be closed. Do you want Jackify to restart Steam now?",
safety_level="medium",
)
if reply == QMessageBox.No:
logger.info("User declined Steam restart; STEAM_COMPAT_MOUNTS update skipped")
else:
try:
from jackify.backend.services.steam_restart_service import shutdown_steam, start_steam
shutdown_steam()
self.shortcut_handler.ensure_mounts_in_steam_compat(app_name, exe_path, dl_path)
start_steam()
except Exception as e:
logger.warning("Steam restart/mounts update failed: %s", e)
finally:
self._awaiting_steam_restart = False
if hasattr(self, '_deferred_completion_args') and self._deferred_completion_args is not None:
args = self._deferred_completion_args
self._deferred_completion_args = None
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: self.on_configuration_complete(*args))
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
"""Check if VNV automation should run and start it if applicable.
@@ -227,26 +276,18 @@ class ConfigureExistingModlistWorkflowMixin:
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
self.file_progress_list.clear()
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=params['modlist_name'],
workflow_type=params['workflow_type'],
time_taken=params['time_taken'],
game_name=params['game_name'],
parent=self,
self._run_verifier_then_show_success(
install_dir=params.get('install_dir', ''),
game_type=params.get('game_type', 'unknown'),
appid=params.get('appid', ''),
success_params={
'modlist_name': params['modlist_name'],
'workflow_type': params['workflow_type'],
'time_taken': params['time_taken'],
'game_name': params.get('game_name'),
'enb_detected': params.get('enb_detected', False),
},
)
success_dialog.show()
if params.get('enb_detected'):
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
enb_dialog.exec()
except Exception as e:
logger.warning("Failed to show ENB dialog: %s", e)
def show_manual_steps_dialog(self, extra_warning=""):
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
@@ -24,7 +24,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
from PySide6.QtWidgets import QApplication
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.resolution_utils import get_resolution_fallback
@@ -36,11 +35,12 @@ from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, Modl
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
from .install_verifier_mixin import InstallVerifierMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
logger = logging.getLogger(__name__)
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, InstallVerifierMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
resize_request = Signal(str)
def cancel_and_cleanup(self):
@@ -48,6 +48,8 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
appid = str(getattr(self, 'context', {}).get('appid', '') or '')
self._kill_prefix_wine_processes(appid)
self.cleanup_processes()
self.collapse_show_details_before_leave()
self.go_back()
@@ -84,31 +86,27 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
'time_taken': self._calculate_time_taken(),
'game_name': getattr(self, '_current_game_name', None),
'enb_detected': enb_detected,
'install_dir': install_dir,
'game_type': game_type,
'appid': getattr(self, '_current_appid', '') or '',
}
return
# Calculate time taken
time_taken = self._calculate_time_taken()
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="configure_new",
time_taken=time_taken,
game_name=getattr(self, '_current_game_name', None),
parent=self
game_type = self._detect_game_type_from_mo2_ini(install_dir) if install_dir else "unknown"
self._run_verifier_then_show_success(
install_dir=install_dir or "",
game_type=game_type,
success_params={
'modlist_name': modlist_name,
'workflow_type': 'configure_new',
'time_taken': time_taken,
'game_name': getattr(self, '_current_game_name', None),
'enb_detected': enb_detected,
},
)
success_dialog.show()
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec()
except Exception as e:
logger.warning("Failed to show ENB dialog: %s", e)
else:
self._safe_append_text(f"Configuration failed: {message}")
MessageService.show_error(self, configuration_failed(str(message)))
@@ -15,6 +15,51 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
def _handle_progress_update(self, text):
"""Handle progress updates - update console, activity window, and progress indicator."""
if text.startswith("[NATIVE_DL] "):
parts = text.split(None, 3)
if len(parts) == 4:
_, component, pct_str, speed_str = parts
try:
pct, speed = float(pct_str), float(speed_str)
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)",
pct,
)
except ValueError:
pass
return
if text.startswith("[NATIVE_INSTALL] "):
component = text[17:].strip()
self._stop_component_install_pulse()
done = getattr(self, '_native_done_components', 0)
total = getattr(self, '_native_total_components', 0)
remaining = max(0, total - done)
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine component: {component}{suffix}",
0.0,
)
self.progress_indicator.set_status(f"Installing {component}...", 80)
self._current_native_component = component
self._native_done_components = done + 1
return
if text.startswith("[NATIVE_WAIT] "):
parts = text.split(None, 2)
if len(parts) >= 3:
component, elapsed_s = parts[1], parts[2].strip()
done = getattr(self, '_native_done_components', 1)
total = getattr(self, '_native_total_components', 0)
remaining = max(0, total - done)
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine component: {component} | {elapsed_s}s{suffix}",
0.0,
)
self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 80)
return
self._safe_append_text(text)
message_lower = text.lower()
@@ -47,11 +92,17 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Installing wine components...", 80)
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
self._start_component_install_pulse()
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
self._native_total_components = len(comp_list)
self._native_done_components = 0
self.file_progress_list.update_or_add_item(
"__native_component__",
f"Wine components: {len(comp_list)} queued",
0.0,
)
elif not hasattr(self, '_component_install_timer') or not self._component_install_timer:
self._start_component_install_pulse()
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Wine components installed", 85)
@@ -234,26 +234,18 @@ class ConfigureNewModlistDialogsMixin:
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
self.file_progress_list.clear()
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=params['modlist_name'],
workflow_type=params['workflow_type'],
time_taken=params['time_taken'],
game_name=params['game_name'],
parent=self,
self._run_verifier_then_show_success(
install_dir=params.get('install_dir', ''),
game_type=params.get('game_type', 'unknown'),
appid=params.get('appid', ''),
success_params={
'modlist_name': params['modlist_name'],
'workflow_type': params['workflow_type'],
'time_taken': params['time_taken'],
'game_name': params.get('game_name'),
'enb_detected': params.get('enb_detected', False),
},
)
success_dialog.show()
if params.get('enb_detected'):
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
enb_dialog.exec()
except Exception as e:
logger.warning("Failed to show ENB dialog: %s", e)
def show_next_steps_dialog(self, message):
dlg = QDialog(self)
@@ -18,7 +18,7 @@ class ConfigureNewModlistUISetupMixin:
def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None):
super().__init__()
logger.debug("DEBUG: ConfigureNewModlistScreen __init__ called")
logger.debug("ConfigureNewModlistScreen __init__ called")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.dev_mode = dev_mode
@@ -178,7 +178,7 @@ class ConfigureNewModlistUISetupMixin:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
@@ -95,14 +95,14 @@ class ConfigureNewModlistWorkflowMixin:
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
logger.debug(f"Resolution saved successfully: {resolution}")
else:
logger.debug("DEBUG: Failed to save resolution")
logger.debug("Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
logger.debug("DEBUG: Saved resolution cleared")
logger.debug("Saved resolution cleared")
# Start configuration - automated workflow handles Steam restart internally
self.configure_modlist()
@@ -134,7 +134,7 @@ class ConfigureNewModlistWorkflowMixin:
ensure_flatpak_steam_filesystem_access(Path(install_dir))
from jackify import __version__ as jackify_version
logger.info("Jackify v%s", jackify_version)
logger.info("Initializing automated Steam setup for '%s'...", modlist_name)
logger.info("Initialising automated Steam setup for '%s'...", modlist_name)
logger.info("Starting automated Steam shortcut creation and configuration...")
# Disable the start button to prevent multiple workflows
@@ -162,9 +162,13 @@ class ConfigureNewModlistWorkflowMixin:
def progress_callback(message):
self.progress_update.emit(message)
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
download_dir = resolve_mo2_download_dir(Path(self.install_dir))
result = prefix_service.run_working_workflow(
self.modlist_name, self.install_dir, self.mo2_exe_path,
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart,
download_dir=download_dir,
)
self.workflow_complete.emit(result)
@@ -304,8 +308,7 @@ class ConfigureNewModlistWorkflowMixin:
'modlist_source': None,
'resolution': resolution_value,
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed since automated prefix is done
'appid': new_appid, # Use the NEW AppID from automated prefix creation
'appid': new_appid,
'game_name': 'Skyrim Special Edition' # Default for new modlist
}
self.context = updated_context # Ensure context is always set
@@ -364,11 +367,6 @@ class ConfigureNewModlistWorkflowMixin:
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# This shouldn't happen since automated prefix creation is complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
# Call the service method for post-Steam configuration
self.progress_update.emit("")
self.progress_update.emit("=== Configuration Phase ===")
self.progress_update.emit("")
@@ -376,7 +374,6 @@ class ConfigureNewModlistWorkflowMixin:
result = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
@@ -412,8 +409,7 @@ class ConfigureNewModlistWorkflowMixin:
'mo2_exe_path': mo2_exe_path,
'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None,
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed
'appid': new_appid, # Use the NEW AppID from Steam
'appid': new_appid,
'game_name': 'Skyrim Special Edition' # Default for new modlist
}
logger.debug(f"Updated context with new AppID: {new_appid}")
@@ -472,17 +468,11 @@ class ConfigureNewModlistWorkflowMixin:
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# Should not reach here -- manual steps already complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
# Call the working configuration service method
self.progress_update.emit("Starting configuration with backend service...")
success = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
@@ -49,9 +49,10 @@ from .install_modlist_workflow import InstallWorkflowMixin
from .install_modlist_nexus import NexusAuthMixin
from .install_modlist_selection import ModlistSelectionMixin
from .screen_back_mixin import ScreenBackMixin
from .install_verifier_mixin import InstallVerifierMixin
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifierMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
resize_request = Signal(str) # Signal for expand/collapse like TTW screen
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
@@ -78,6 +79,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
self.nexus_login_btn,
# Checkboxes
self.auto_restart_checkbox,
self.engine_checkbox,
]
def _disable_controls_during_operation(self):
@@ -113,12 +115,13 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def _open_url_safe(self, url):
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
import subprocess
_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}
try:
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
except Exception as e:
print(f"Warning: Could not open URL {url}: {e}")
logger.warning(f"Could not open URL {url}: {e}")
def resizeEvent(self, event):
"""Handle window resize to prioritize form over console"""
@@ -216,7 +219,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception as e:
logger.debug(f"DEBUG: showEvent exception: {e}")
logger.debug(f"showEvent exception: {e}")
def _start_gallery_cache_preload(self):
"""DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow"""
@@ -248,22 +251,22 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
# Check if we got mods
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
if modlists_with_mods > 0:
logger.debug(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)")
logger.debug(f"Gallery cache ready ({modlists_with_mods} modlists with mods)")
else:
# Cache didn't have mods, but we fetched fresh - should have mods now
logger.debug("DEBUG: Gallery cache updated")
logger.debug("Gallery cache updated")
else:
logger.debug("DEBUG: Failed to load gallery cache")
logger.debug("Failed to load gallery cache")
except Exception as e:
logger.debug(f"DEBUG: Gallery cache preload error: {str(e)}")
logger.debug(f"Gallery cache preload error: {str(e)}")
# Start thread (non-blocking, invisible to user)
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
# Don't connect finished signal - we don't need to do anything, just let it run
self._gallery_cache_preload_thread.start()
logger.debug("DEBUG: Started background gallery cache preload")
logger.debug("Started background gallery cache preload")
def hideEvent(self, event):
"""Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks."""
@@ -284,17 +287,17 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
if saved_install_parent:
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
self.install_dir_edit.setText(suggested_install_dir)
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
logger.debug(f"Updated install directory suggestion: {suggested_install_dir}")
# Update download directory suggestion
saved_download_parent = self.config_handler.get_default_download_parent_dir()
if saved_download_parent:
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
self.downloads_dir_edit.setText(suggested_download_dir)
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
logger.debug(f"Updated download directory suggestion: {suggested_download_dir}")
except Exception as e:
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
logger.debug(f"Error updating directory suggestions: {e}")
def _save_parent_directories(self, install_dir, downloads_dir):
"""Removed automatic saving - user should set defaults in settings"""
@@ -422,6 +425,15 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
self._stop_clf3_decompress_pulse()
fpl = getattr(self, 'file_progress_list', None)
if fpl is not None:
try:
fpl.stop_cpu_tracking()
except Exception:
pass
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
@@ -446,7 +458,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
setattr(self, attr_name, None)
return
logger.debug(f"DEBUG: Stopping {attr_name}")
logger.debug(f"Stopping {attr_name}")
if cancel_method and hasattr(thread, cancel_method):
try:
@@ -543,13 +555,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
self._pending_manual_download_events = None
# Cancel the installation thread if it exists
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
self.install_thread.cancel()
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
if self.install_thread.isRunning():
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
try:
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
self.install_thread.cancel()
self.install_thread.wait(5000)
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
if self.install_thread.isRunning():
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
self.install_thread.cancel()
self.install_thread.wait(5000)
except RuntimeError:
self.install_thread = None
# Park prefix/config threads - disconnect their signals and let them
# finish naturally rather than terminating unsafely.
@@ -559,6 +574,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
["progress_update", "workflow_complete", "error_occurred"],
)
if hasattr(self, 'config_thread') and self.config_thread:
_ctx = getattr(self, 'context', None)
_appid = str(
getattr(self, '_current_appid', '')
or ((_ctx.get('appid', '') if isinstance(_ctx, dict) else '') or '')
)
try:
self.config_thread.requestInterruption()
except Exception:
pass
self._kill_prefix_wine_processes(_appid)
self.config_thread = self._park_thread(
self.config_thread,
["progress_update", "configuration_complete", "error_occurred"],
@@ -160,11 +160,11 @@ class AutomatedPrefixHandlersMixin:
self.prefix_thread.start()
except Exception as e:
logger.debug(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
logger.debug(f"Exception in start_automated_prefix_workflow: {e}")
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
# Re-enable controls on exception
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
"""Handle completion of automated prefix creation"""
@@ -5,7 +5,6 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from PySide6.QtGui import QFont
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.errors import manual_steps_incomplete, configuration_failed
from jackify.frontends.gui.dialogs import SuccessDialog
from jackify.backend.handlers.validation_handler import ValidationHandler
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
@@ -22,7 +21,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
def on_configuration_progress(self, progress_msg):
"""Handle progress updates from modlist configuration"""
self._safe_append_text(progress_msg)
if not (progress_msg.startswith("[NATIVE_DL] ") or progress_msg.startswith("[NATIVE_INSTALL] ") or progress_msg.startswith("[NATIVE_WAIT] ")):
self._safe_append_text(progress_msg)
self._handle_post_install_progress(progress_msg)
def show_steam_restart_progress(self, message):
@@ -90,7 +90,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
if self._show_somnium_guidance:
self._show_somnium_post_install_guidance()
# Show celebration SuccessDialog after the entire workflow
if not hasattr(self, '_install_workflow_start_time'):
self._install_workflow_start_time = time.time()
time_taken = int(time.time() - self._install_workflow_start_time)
@@ -147,15 +146,16 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
if vnv_automation_running:
self._cleanup_config_thread()
# Store success dialog params for later (after VNV automation completes)
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'workflow_type': "update" if getattr(self, "_is_update_install", False) else "install",
'time_taken': time_str,
'game_name': game_name,
'enb_detected': enb_detected
'enb_detected': enb_detected,
'install_dir': install_dir,
'game_type': getattr(self, '_current_game_type', 'unknown') or 'unknown',
'appid': getattr(self, '_current_appid', '') or '',
}
# Keep post-install feedback active during VNV automation
# Don't show success dialog yet - will be shown in _on_vnv_complete
return
# No VNV automation - end post-install feedback now
@@ -167,29 +167,19 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
except Exception as e:
logger.warning("Update mode verify: failed post-config INI verification: %s", e)
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show normal success dialog
workflow_type = "update" if getattr(self, "_is_update_install", False) else "install"
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type=workflow_type,
time_taken=time_str,
game_name=game_name,
parent=self
game_type_for_verify = getattr(self, '_current_game_type', 'unknown') or 'unknown'
self._run_verifier_then_show_success(
install_dir=install_dir,
game_type=game_type_for_verify,
success_params={
'modlist_name': modlist_name,
'workflow_type': workflow_type,
'time_taken': time_str,
'game_name': game_name,
'enb_detected': enb_detected,
},
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
logger.warning(f"Failed to show ENB dialog: {e}")
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
self._end_post_install_feedback(False)
@@ -426,8 +416,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
'modlist_source': None,
'resolution': getattr(self, '_current_resolution', None),
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed since automated prefix is done
'appid': new_appid, # Use the NEW AppID from automated prefix creation
'appid': new_appid,
'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
}
self.context = updated_context # Ensure context is always set
@@ -490,15 +479,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# Should not reach here -- prefix creation already complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
# Call the service method for post-Steam configuration
result = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
@@ -533,8 +516,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
'modlist_source': None,
'resolution': getattr(self, '_current_resolution', None),
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed
'appid': new_appid # Use the NEW AppID from Steam
'appid': new_appid
}
logger.debug(f"Updated context with new AppID: {new_appid}")
@@ -624,15 +607,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
def completion_callback(success, message, modlist_name):
self.configuration_complete.emit(success, message, modlist_name)
def manual_steps_callback(modlist_name, retry_count):
# Should not reach here -- manual steps already complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
# Call the new service method for post-Steam configuration
result = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
manual_steps_callback=manual_steps_callback,
completion_callback=completion_callback
)
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import QThread, Signal
import logging
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code, nexus_url_from_error_line
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
from jackify.shared.errors import JackifyError, cc_content_missing, creation_kit_missing
@@ -35,14 +35,17 @@ class InstallerThread(QThread):
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name,
install_mode='online', progress_state_manager=None, auth_service=None,
oauth_info=None):
oauth_info=None, game_type=None, clf3_cdn_url=None, engine_id=None):
super().__init__()
self.modlist = modlist
self.install_dir = install_dir
self.downloads_dir = downloads_dir
self.api_key = api_key
self.modlist_name = modlist_name
self.game_type = game_type
self.install_mode = install_mode
self.clf3_cdn_url = clf3_cdn_url
self.engine_id = engine_id
self.cancelled = False
self.process_manager = None
self.progress_state_manager = progress_state_manager
@@ -126,16 +129,58 @@ class InstallerThread(QThread):
return False
# CLF3 tracing line patterns (after ANSI stripping, from tracing_subscriber::fmt default format)
_CLF3_TRACING_INFO_RE = re.compile(
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(INFO|DEBUG|TRACE)\s+'
)
_CLF3_TRACING_WARN_RE = re.compile(
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(WARN|ERROR)\s+\S+:\s+(.*)'
)
_CLF3_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$')
_CLF3_ANSI_RE = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]')
def _read_stderr(self):
import time as _time
_stderr_log = os.environ.get("JACKIFY_CLF3_VERBOSE")
_stderr_fh = open("/tmp/clf3_stderr.log", "w", encoding="utf-8") if _stderr_log else None
_last_clf3_phase: str = ''
try:
for raw in self.process_manager.proc.stderr:
line = raw.decode('utf-8', errors='replace').strip()
if not line:
continue
logger.debug(f"Engine stderr: {line}")
if _stderr_fh:
_stderr_fh.write(line + "\n")
_stderr_fh.flush()
self._raw_stderr_lines.append(line)
if len(self._raw_stderr_lines) > 40:
self._raw_stderr_lines.pop(0)
# CLF3: JSON events are on stdout; human-readable detail and tracing go to stderr.
# Forward phase headers and log() messages to Show Details.
# Verbose INFO/DEBUG/TRACE tracing is dropped; WARN/ERROR is shown stripped.
clf3_parser = getattr(self, '_clf3_parser', None)
if clf3_parser is not None:
clean = self._CLF3_ANSI_RE.sub('', line)
error = parse_engine_error_line(clean)
if error and self.last_error is None:
self.last_error = error
warn_m = self._CLF3_TRACING_WARN_RE.match(clean)
if warn_m:
self.output_received.emit(f"[WARN] {warn_m.group(2)}\n")
elif not self._CLF3_TRACING_INFO_RE.match(clean):
if self._CLF3_PHASE_HEADER_RE.match(clean):
if clean != _last_clf3_phase:
_last_clf3_phase = clean
self.output_received.emit(clean + '\n')
else:
self.output_received.emit(clean + '\n')
_act = getattr(self, '_clf3_last_activity', None)
if _act is not None:
_act[0] = _time.monotonic()
continue
error = parse_engine_error_line(line)
if error and self.last_error is None:
self.last_error = error
@@ -149,9 +194,13 @@ class InstallerThread(QThread):
if self.last_error is None and is_cc_content_error(line):
self.last_error = cc_content_missing(extract_cc_filename(line) or "")
if self.last_error is None and is_creation_kit_missing_error(line):
self.last_error = creation_kit_missing()
self.last_error = creation_kit_missing(self.game_type)
except Exception as e:
logger.debug(f"Stderr reader error: {e}")
finally:
if _stderr_fh:
_stderr_fh.close()
logger.info("CLF3 stderr log written to /tmp/clf3_stderr.log")
def _remember_stdout_line(self, line: str) -> None:
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
@@ -214,6 +263,9 @@ class InstallerThread(QThread):
"""Build a user-facing failure message with the best available root cause."""
root_cause = self._extract_root_cause_line()
if root_cause:
nexus_url = nexus_url_from_error_line(root_cause)
if nexus_url:
root_cause = f"{root_cause}\nMod page: {nexus_url}"
if self._resource_limit_hint and "file descriptor" not in root_cause.lower():
return f"{root_cause}\n\nPossible contributing issue: {self._resource_limit_hint}"
return root_cause
@@ -251,42 +303,195 @@ class InstallerThread(QThread):
"Install failed, but the engine did not provide a specific error line."
)
def _run_clf3_heartbeat(self, last_activity: list, stop: threading.Event) -> None:
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
of continued silence so extended waits remain visible to the user.
Samples /proc/<pid>/io to show write throughput, giving the user concrete
evidence that extraction is progressing even when CLF3 emits no output.
Resets when real output arrives so a new silence period can trigger it again.
"""
import copy
import time as _time
THRESHOLD = 8.0
INTERVAL = 5.0
REPEAT = 30.0
last_heartbeat_at = [0.0]
while not stop.wait(INTERVAL):
proc = self.process_manager.proc if self.process_manager else None
if not proc or proc.poll() is not None:
break
now = _time.monotonic()
elapsed_since_activity = now - last_activity[0]
if elapsed_since_activity < THRESHOLD:
continue
# Allow first fire when silence starts; then only re-fire every REPEAT seconds.
already_fired = last_heartbeat_at[0] >= last_activity[0]
if already_fired and (now - last_heartbeat_at[0]) < REPEAT:
continue
clf3_parser = getattr(self, '_clf3_parser', None)
phase = (clf3_parser.get_state().phase_name if clf3_parser else None) or 'Working'
wait_secs = int(elapsed_since_activity)
# Sample write_bytes to show active extraction throughput.
# The N/N dispatch counter fires when all archives are handed to worker threads;
# the workers themselves continue decompressing in parallel after that point.
# /proc/<pid>/io write_bytes confirms real I/O is happening.
if not already_fired:
total = clf3_parser.get_state().phase_max_steps if clf3_parser else 0
archive_str = f"{total} archives" if total else "archives"
self.output_received.emit(f"[Decompressing {archive_str}: workers running...]\n")
if clf3_parser:
from jackify.shared.progress_models import InstallationPhase
current = clf3_parser.get_state()
heartbeat_state = copy.copy(current)
heartbeat_state.phase = InstallationPhase.FINALIZE
heartbeat_state.phase_name = "Decompressing"
heartbeat_state.phase_step = 0
heartbeat_state.phase_max_steps = 0
heartbeat_state.overall_percent = 99.0
heartbeat_state.message = "Decompressing archives..."
self.progress_updated.emit(heartbeat_state)
last_heartbeat_at[0] = now
def _run_clf3_stdout_loop(self, last_activity: list) -> None:
"""Read CLF3 stdout line-by-line and forward to Show Details.
JSON progress events (keyed on "type") and plain human-readable text both arrive
on stdout. Manual download events (keyed on "event") also appear on stdout.
Extraction dispatch counters ("Extracting: N/M") are dropped named per-archive
completion lines already cover this information.
Directive counters ("Processing: N/M") are buffered; only the final value is
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
"""
import time as _time
_COUNTER_LINE_RE = re.compile(r'^(?:Extracting|Building BSA|DDS Transform): \d+/\d+$')
_PROCESSING_RE = re.compile(r'^Processing: \d+/\d+$')
_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$')
_buffered_processing = None
_last_phase_header: str = ''
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
for raw in self.process_manager.proc.stdout:
if self.cancelled:
self.cancel()
break
decoded = ansi_escape.sub(b'', raw).decode('utf-8', errors='replace').rstrip('\r\n')
stripped = decoded.strip()
if not stripped:
continue
last_activity[0] = _time.monotonic()
self._remember_stdout_line(decoded)
if self._handle_engine_event(decoded):
continue
if stripped.startswith('{'):
clf3_parser = getattr(self, '_clf3_parser', None)
if clf3_parser and clf3_parser.process_line(stripped):
state = clf3_parser.get_state()
self.progress_updated.emit(state)
msg = state.message
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
# Relabel as "Queuing" — the N/M counter tracks archive dispatch
# to worker threads, not completion of decompression.
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
continue
if _COUNTER_LINE_RE.match(stripped):
continue
if _PROCESSING_RE.match(stripped):
_buffered_processing = stripped
continue
if _buffered_processing is not None:
self.output_received.emit(_buffered_processing + '\n')
_buffered_processing = None
if _PHASE_HEADER_RE.match(stripped):
if stripped == _last_phase_header:
continue
_last_phase_header = stripped
self.output_received.emit(decoded + '\n')
if _buffered_processing is not None:
self.output_received.emit(_buffered_processing + '\n')
def run(self):
try:
from jackify.backend.core.modlist_operations import get_jackify_engine_path
engine_path = get_jackify_engine_path()
if not os.path.exists(engine_path):
error_msg = f"Engine not found at: {engine_path}"
logger.debug(f"DEBUG: {error_msg}")
from jackify.backend.services.engine_invoker import (
get_active_engine_id, get_engine_path, build_install_command,
resolve_game_dir, resolve_game_location,
)
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
engine_id = self.engine_id if self.engine_id else get_active_engine_id()
engine_path = get_engine_path(engine_id)
if not engine_path or not os.path.exists(engine_path):
error_msg = f"Engine not found: {engine_id} ({engine_path or 'path unknown'})"
logger.error(error_msg)
self.installation_finished.emit(False, error_msg)
return
if not os.access(engine_path, os.X_OK):
error_msg = f"Engine is not executable: {engine_path}"
logger.debug(f"DEBUG: {error_msg}")
logger.error(error_msg)
self.installation_finished.emit(False, error_msg)
return
logger.debug(f"DEBUG: Using engine at: {engine_path}")
if self.install_mode == 'file':
cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
else:
cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
logger.debug(f"Using engine {engine_id} at: {engine_path}")
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'")
game_dir = None
clf3_mode = (engine_id == "clf3")
if clf3_mode:
location = resolve_game_location(self.game_type)
if location:
game_dir, game_store = location
if game_store != 'steam':
store_label = {'gog': 'GOG', 'epic': 'Epic Games'}.get(game_store, game_store)
self.output_received.emit(
f"[WARN] Game detected from {store_label}, not Steam. "
"Most Wabbajack modlists require the Steam version. "
"If the install fails with hash errors, a store version mismatch is likely the cause.\n"
)
else:
logger.warning("CLF3: could not resolve game directory for game_type=%s", self.game_type)
if clf3_mode and self.clf3_cdn_url and not os.path.isfile(self.modlist):
self.output_received.emit("Downloading modlist file via CLF3...\n")
import subprocess as _sp
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
fetch_cmd = [engine_path, "fetch", self.clf3_cdn_url, "--output", self.modlist]
logger.debug("CLF3 fetch command: %s", " ".join(fetch_cmd))
fetch_env = get_clean_subprocess_env({})
fetch_cwd = os.path.dirname(self.modlist) or os.path.expanduser("~")
os.makedirs(fetch_cwd, exist_ok=True)
fetch_result = _sp.run(fetch_cmd, capture_output=True, text=True, env=fetch_env, cwd=fetch_cwd)
if fetch_result.returncode != 0:
err = fetch_result.stderr.strip() or fetch_result.stdout.strip() or "unknown error"
self.installation_finished.emit(False, f"Failed to download modlist file:\n\n{err}")
return
self.output_received.emit("Modlist file ready.\n")
cmd = build_install_command(
engine_id=engine_id,
engine_path=engine_path,
wabbajack=self.modlist,
install_dir=self.install_dir,
downloads_dir=self.downloads_dir,
game_dir=game_dir,
install_mode=self.install_mode,
debug=debug_mode,
)
logger.debug(f"FULL Engine command: {' '.join(cmd)}")
logger.debug(f"modlist value being passed: '{self.modlist}'")
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
writeback_path = str(self.auth_service.get_token_writeback_path()) if self.auth_service else None
env_vars = {'NEXUS_API_KEY': self.api_key}
if self.oauth_info:
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
if writeback_path:
env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
if clf3_mode:
env_vars = {'NEXUS_OAUTH_TOKEN': self.api_key}
else:
env_vars = {'NEXUS_API_KEY': self.api_key}
if self.oauth_info:
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
if writeback_path:
env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
env = get_clean_subprocess_env(env_vars)
# Install-time resource preflight: keep this visible in workflow output so
@@ -306,172 +511,188 @@ class InstallerThread(QThread):
logger.debug(f"Resource preflight check failed: {e}")
from jackify.backend.handlers.subprocess_utils import ProcessManager
if clf3_mode:
import time as _time
from jackify.backend.handlers.progress_parser_clf3 import CLF3ProgressStateManager
self._clf3_parser = CLF3ProgressStateManager()
self._clf3_last_activity = [_time.monotonic()]
else:
self._clf3_parser = None
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True, enable_stdin=True)
stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
stderr_thread.start()
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
buffer = b''
last_was_blank = False
while True:
if self.cancelled:
self.cancel()
break
char = self.process_manager.read_stdout_char()
if not char:
break
buffer += char
while b'\n' in buffer or b'\r' in buffer:
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
line, buffer = buffer.split(b'\r', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth' and self.auth_service:
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
if self.last_error is None and is_cc_content_error(decoded):
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
if self.last_error is None and is_creation_kit_missing_error(decoded):
self.last_error = creation_kit_missing()
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
progress_state = self.progress_state_manager.get_state()
if progress_state.active_files and debug_mode:
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
self.progress_updated.emit(progress_state)
if '[FILE_PROGRESS]' in decoded:
self._install_progress_started = True
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.progress_received.emit(parts[0].rstrip())
else:
self.progress_received.emit(decoded + '\r')
elif b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth' and self.auth_service:
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
self._non_premium_info_sent = True
self.non_premium_detected.emit()
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
if self.last_error is None and is_cc_content_error(decoded):
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
if self.last_error is None and is_creation_kit_missing_error(decoded):
self.last_error = creation_kit_missing()
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
progress_state = self.progress_state_manager.get_state()
if progress_state.active_files and debug_mode:
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
self.progress_updated.emit(progress_state)
if self._handle_engine_event(decoded):
last_was_blank = False
continue
if clf3_mode:
heartbeat_stop = threading.Event()
heartbeat_thread = threading.Thread(
target=self._run_clf3_heartbeat,
args=(self._clf3_last_activity, heartbeat_stop),
daemon=True,
)
heartbeat_thread.start()
self._run_clf3_stdout_loop(self._clf3_last_activity)
heartbeat_stop.set()
heartbeat_thread.join(timeout=2.0)
else:
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
buffer = b''
last_was_blank = False
while True:
if self.cancelled:
self.cancel()
break
char = self.process_manager.read_stdout_char()
if not char:
break
buffer += char
while b'\n' in buffer or b'\r' in buffer:
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
line, buffer = buffer.split(b'\r', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth' and self.auth_service:
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
if self.last_error is None and is_cc_content_error(decoded):
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
if self.last_error is None and is_creation_kit_missing_error(decoded):
self.last_error = creation_kit_missing(self.game_type)
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
progress_state = self.progress_state_manager.get_state()
if progress_state.active_files and debug_mode:
logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
self.progress_updated.emit(progress_state)
if '[FILE_PROGRESS]' in decoded:
self._install_progress_started = True
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.progress_received.emit(parts[0].rstrip())
else:
self.progress_received.emit(decoded + '\r')
elif b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth' and self.auth_service:
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
self._non_premium_info_sent = True
self.non_premium_detected.emit()
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
if self.last_error is None and is_cc_content_error(decoded):
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
if self.last_error is None and is_creation_kit_missing_error(decoded):
self.last_error = creation_kit_missing(self.game_type)
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
progress_state = self.progress_state_manager.get_state()
if progress_state.active_files and debug_mode:
logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
self.progress_updated.emit(progress_state)
if self._handle_engine_event(decoded):
last_was_blank = False
continue
self._remember_stdout_line(decoded)
if '[FILE_PROGRESS]' in decoded:
self._install_progress_started = True
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
last_was_blank = False
continue
if decoded.strip() == '':
if not last_was_blank:
self.output_received.emit('\n')
last_was_blank = True
else:
self.output_received.emit(decoded + '\n')
last_was_blank = False
if buffer:
line = ansi_escape.sub(b'', buffer)
decoded = line.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in decoded:
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
else:
self._remember_stdout_line(decoded)
if '[FILE_PROGRESS]' in decoded:
self._install_progress_started = True
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
last_was_blank = False
continue
if decoded.strip() == '':
if not last_was_blank:
self.output_received.emit('\n')
last_was_blank = True
else:
self.output_received.emit(decoded + '\n')
last_was_blank = False
if buffer:
line = ansi_escape.sub(b'', buffer)
decoded = line.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in decoded:
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
else:
self._remember_stdout_line(decoded)
self.output_received.emit(decoded)
self.output_received.emit(decoded)
stderr_thread.join(timeout=5)
returncode = self.process_manager.wait()
if writeback_path and self.auth_service:
if writeback_path and self.auth_service and not clf3_mode:
self.auth_service.apply_token_writeback(writeback_path)
if self.process_manager.proc and self.process_manager.proc.stdout:
try:
@@ -479,7 +700,7 @@ class InstallerThread(QThread):
if remaining:
decoded_remaining = remaining.decode('utf-8', errors='replace')
if decoded_remaining.strip():
logger.debug(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}")
logger.debug(f"Remaining output after process exit: {decoded_remaining[:500]}")
if '[FILE_PROGRESS]' in decoded_remaining:
parts = decoded_remaining.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
@@ -487,7 +708,7 @@ class InstallerThread(QThread):
else:
self.output_received.emit(decoded_remaining)
except Exception as e:
logger.debug(f"DEBUG: Error reading remaining output: {e}")
logger.debug(f"Error reading remaining output: {e}")
if returncode != 0 and not self.cancelled and self.last_error is None:
stderr_tail = self._raw_stderr_lines[-10:] if self._raw_stderr_lines else []
stdout_tail = self._raw_stdout_lines[-10:] if self._raw_stdout_lines else []
@@ -35,24 +35,45 @@ class InstallModlistOutputMixin:
if not self._token_error_notified:
self._token_error_notified = True
from jackify.frontends.gui.services.message_service import MessageService
MessageService.critical(
self,
"Authentication Error",
(
"Nexus Mods authentication has failed. This may be due to:\n\n"
"• OAuth token expired and refresh failed\n"
"• Nexus Premium required for this modlist\n"
"• Network connectivity issues\n\n"
"Please check the console output (Show Details) for more information.\n"
"You may need to re-authorize in Settings."
),
safety_level="high"
)
guidance = (
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
)
engine_id = getattr(self, '_active_session_engine_id', None)
if engine_id == 'clf3':
MessageService.critical(
self,
"CLF3 Authentication Error",
(
"CLF3 could not authenticate with Nexus Mods.\n\n"
"The CLF3 binary stores its own Nexus API key, which may have "
"expired or been revoked. Your Jackify OAuth is unaffected.\n\n"
"To fix: generate an API key at nexus.mods.com (account page), "
"then run in a terminal:\n\n"
" clf3 set-api-key YOUR_KEY\n\n"
"OAuth support for CLF3 will be automatic after the next CLF3 release."
),
safety_level="high"
)
guidance = (
"\n[Jackify] CLF3 auth failed. CLF3 uses its own saved Nexus API key "
"(not your Jackify OAuth). Run: clf3 set-api-key YOUR_KEY\n"
)
else:
MessageService.critical(
self,
"Authentication Error",
(
"Nexus Mods authentication has failed. This may be due to:\n\n"
"• OAuth token expired and refresh failed\n"
"• Nexus Premium required for this modlist\n"
"• Network connectivity issues\n\n"
"Please check the console output (Show Details) for more information.\n"
"You may need to re-authorize in Settings."
),
safety_level="high"
)
guidance = (
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
)
self._safe_append_text(guidance)
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
@@ -219,12 +240,12 @@ class InstallModlistOutputMixin:
except RuntimeError as e:
if "already deleted" in str(e):
if getattr(self, 'debug', False):
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
logger.debug(f"Ignoring widget deletion error: {e}")
return
raise
except Exception as e:
if getattr(self, 'debug', False):
logger.debug(f"DEBUG: Error updating file progress list: {e}")
logger.debug(f"Error updating file progress list: {e}")
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
else:
@@ -1,4 +1,5 @@
"""Post-install UI feedback management for InstallModlistScreen (Mixin)."""
import logging
import re
import time
from typing import Optional
@@ -7,6 +8,8 @@ from PySide6.QtCore import QTimer
from jackify.shared.progress_models import InstallationProgress, InstallationPhase, FileProgress, OperationType
logger = logging.getLogger(__name__)
class PostInstallFeedbackMixin:
"""Mixin providing post-install progress tracking and UI feedback for InstallModlistScreen."""
@@ -180,6 +183,21 @@ class PostInstallFeedbackMixin:
"bsa decompression:",
],
},
{
'id': 'final_config',
'label': "Performing final tasks",
'keywords': [
"digicert",
"certificate",
"windows version",
"windows 11",
"mscoree",
"tool compat",
"nemesis setup",
"applying tool",
"symlink",
],
},
{
'id': 'config_finalize',
'label': "Finalising Jackify configuration",
@@ -200,15 +218,54 @@ class PostInstallFeedbackMixin:
self._post_install_last_label = "Preparing Steam integration"
total = max(1, self._post_install_total_steps)
self._update_post_install_ui(self._post_install_last_label, 0, total)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
def _handle_post_install_progress(self, message: str):
"""Translate backend progress messages into collapsed-mode feedback."""
if not self._post_install_active or not message:
if not self._post_install_active and message:
logger.debug("[PULSE] _handle_post_install_progress skipped - post_install_active=False, msg=%r", message[:60])
return
text = message.strip()
if not text:
return
if any(kw in text.lower() for kw in ['wine', 'vcrun', 'dotnet', 'winetricks', 'component']):
logger.debug("[PULSE] progress msg (wine-related): %r, step=%s, timer_active=%s",
text[:80], getattr(self, '_post_install_current_step', 'N/A'),
bool(getattr(self, '_component_install_timer', None) and
getattr(self._component_install_timer, 'isActive', lambda: False)()))
if text.startswith("[NATIVE_DL] "):
parts = text.split(None, 3)
if len(parts) == 4:
_, component, pct_str, speed_str = parts
try:
if not hasattr(self, '_native_component_progress'):
self._native_component_progress = {}
self._native_component_progress[component] = (float(pct_str), float(speed_str))
except ValueError:
pass
return
if text.startswith("[NATIVE_INSTALL] "):
component = text[17:].strip()
if hasattr(self, '_native_component_progress'):
self._native_component_progress.pop(component, None)
done = getattr(self, '_native_done_components', 0)
self._current_native_component = component
self._native_done_components = done + 1
if hasattr(self, '_component_install_list') and self._component_install_list:
total = len(self._component_install_list)
remaining = max(0, total - (done + 1))
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
self.file_progress_list.update_files(
[FileProgress(filename=f"Wine component: {component}{suffix}", operation=OperationType.UNKNOWN, percent=0.0)],
current_phase=None,
)
return
normalized = text.lower()
total = max(1, self._post_install_total_steps)
matched = False
@@ -239,15 +296,19 @@ class PostInstallFeedbackMixin:
# Must remove summary widget so pulser items display immediately
# (otherwise the 0.5s hold blocks update_files from adding items).
if step['id'] == 'wine_components':
logger.debug("[PULSE] wine_components step matched, msg=%r", text[:80])
self.file_progress_list.clear_summary()
self.progress_indicator.set_status(
"Installing Wine components...",
int((self._post_install_current_step / total) * 100)
)
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
timer_active = hasattr(self, '_component_install_timer') and self._component_install_timer and self._component_install_timer.isActive()
logger.debug("[PULSE] timer_active=%s", timer_active)
if not timer_active:
self._start_component_install_pulse()
# Always check for component list updates (may come in later messages)
comp_list = self._parse_wine_components_message(text)
logger.debug("[PULSE] comp_list=%s", comp_list)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
break
@@ -364,16 +425,11 @@ class PostInstallFeedbackMixin:
def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None):
"""Update progress indicator + activity summary for post-install steps."""
# Use the label as the primary display, but include step info in Activity window
display_label = label
if detail:
# Remove timestamp prefix from detail messages
clean_detail = self._strip_timestamp_prefix(detail.strip())
if clean_detail:
# Filter out winetricks/protontricks internal messages (perl, wine paths, etc.)
# These are implementation details, not user-facing status
if any(keyword in clean_detail.lower() for keyword in ['perl:', 'wine:', '/usr/bin/', 'winetricks:', 'protontricks:']):
# Use original label, ignore internal tool messages
pass
elif clean_detail.lower().startswith(label.lower()):
display_label = clean_detail
@@ -383,23 +439,24 @@ class PostInstallFeedbackMixin:
step_clamped = max(0, min(step, total))
overall_percent = (step_clamped / total) * 100.0
# CRITICAL: Ensure both displays use the SAME step counter
# Progress banner uses phase_step/phase_max_steps from progress_state
progress_state = InstallationProgress(
phase=InstallationPhase.FINALIZE,
phase_name=display_label, # This will show in progress banner
phase_step=step_clamped, # This creates [step/total] in display_text
phase_name=display_label,
phase_step=step_clamped,
phase_max_steps=total,
overall_percent=overall_percent
)
self.progress_indicator.update_progress(progress_state)
# Activity window uses summary_info with the SAME step counter
# When the component pulse timer is active, it owns the Activity window.
# Writing a summary widget here would block the heartbeat's file items via the 0.5s hold.
if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive():
return
summary_info = {
'current_step': step_clamped, # Must match phase_step above
'max_steps': total, # Must match phase_max_steps above
'current_step': step_clamped,
'max_steps': total,
}
# Use the same label for consistency
self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info)
def _end_post_install_feedback(self, success: bool):
@@ -414,6 +471,8 @@ class PostInstallFeedbackMixin:
self._update_post_install_ui(label, final_step, total)
self._post_install_active = False
self._post_install_last_label = label
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
def _parse_wine_components_message(self, text: str):
"""Extract list of wine component names from backend status message, or None."""
@@ -429,40 +488,60 @@ class PostInstallFeedbackMixin:
def _start_component_install_pulse(self):
"""Start pulsing Activity item for Wine component installation."""
logger.debug("[PULSE] _start_component_install_pulse called, post_install_active=%s", getattr(self, '_post_install_active', 'N/A'))
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
if not getattr(self, '_component_install_timer', None):
self._component_install_timer = QTimer(self)
self._component_install_timer.timeout.connect(self._component_install_heartbeat)
self._component_install_timer.start(100)
self._component_install_start_time = time.time()
logger.debug("[PULSE] component install timer started")
def _start_component_install_pulse_with_components(self, components: list):
"""Replace single item with one Activity entry per component, each with pulsing progress."""
"""Show queued count; heartbeat switches to per-component display as each starts."""
logger.debug("[PULSE] _start_component_install_pulse_with_components called, components=%s", components)
self._component_install_list = components
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in components
]
self.file_progress_list.update_files(progresses, current_phase=None)
self._native_total_components = len(components)
self._native_done_components = 0
self._current_native_component = None
self.file_progress_list.update_or_add_item(
"__wine_components__",
f"Wine components: {len(components)} queued",
0.0,
)
def _component_install_heartbeat(self):
"""Heartbeat to keep component install item(s) pulsing."""
"""Heartbeat to keep component install item pulsing."""
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
logger.debug("[PULSE] heartbeat fired but no start_time, skipping")
return
current = getattr(self, '_current_native_component', None)
if hasattr(self, '_component_install_list') and self._component_install_list:
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
total = len(self._component_install_list)
done = getattr(self, '_native_done_components', 0)
dl_state = getattr(self, '_native_component_progress', {})
if current:
if current in dl_state:
pct, speed = dl_state[current]
label = f"Wine component: {current} | {pct:.0f}% ({speed:.1f} MB/s)"
op = OperationType.DOWNLOAD
pct_val = pct
else:
remaining = max(0, total - done)
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
label = f"Wine component: {current}{suffix}"
op = OperationType.UNKNOWN
pct_val = 0.0
self.file_progress_list.update_files(
[FileProgress(filename=label, operation=op, percent=pct_val)],
current_phase=None,
)
else:
self.file_progress_list.update_or_add_item(
"__wine_components__",
f"Wine components: {total} queued",
0.0,
)
for comp in self._component_install_list
]
self.file_progress_list.update_files(progresses, current_phase=None)
else:
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
@@ -473,6 +552,8 @@ class PostInstallFeedbackMixin:
self._component_install_timer = None
if hasattr(self, '_component_install_list'):
del self._component_install_list
if hasattr(self, '_native_component_progress'):
del self._native_component_progress
def _start_bsa_decompress_pulse(self):
"""Keep the Activity window alive during long BSA decompression runs."""
@@ -1,5 +1,5 @@
"""Progress and installation event handlers for InstallModlistScreen (Mixin)."""
from PySide6.QtCore import QProcess
from PySide6.QtCore import QProcess, QTimer
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QTextCursor
from jackify.frontends.gui.services.message_service import MessageService
@@ -187,7 +187,11 @@ class ProgressHandlersMixin:
)
is_extraction_phase = (
progress_state.phase == InstallationPhase.EXTRACT or
(progress_state.phase_name and 'extract' in progress_state.phase_name.lower())
(progress_state.phase_name and (
'extract' in progress_state.phase_name.lower()
or 'queu' in progress_state.phase_name.lower()
or 'decompress' in progress_state.phase_name.lower()
))
)
# Detect BSA building phase - check multiple indicators
@@ -228,23 +232,29 @@ class ProgressHandlersMixin:
self._bsa_hold_deadline = now_mono
if is_installation_phase:
# During installation, we may have BSA building AND file installation happening
# Show both: install summary + any active BSA files
# Render loop handles smooth updates - just set target state
self._stop_clf3_decompress_pulse()
current_step = progress_state.phase_step
phase_lower = (progress_state.phase_name or "").lower()
if "bsa" in phase_lower:
step_label = f"Building BSA: {current_step}/{progress_state.phase_max_steps}"
elif "dds" in phase_lower or "texture" in phase_lower:
step_label = f"Converting Textures: {current_step}/{progress_state.phase_max_steps}"
elif "extract" in phase_lower or "queu" in phase_lower:
step_label = f"Queuing Archives: {current_step}/{progress_state.phase_max_steps}"
else:
step_label = f"Installing Files: {current_step}/{progress_state.phase_max_steps}"
display_items = []
# Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size)
if current_step > 0 or progress_state.phase_max_steps > 0:
install_line = FileProgress(
filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}",
filename=step_label,
operation=OperationType.INSTALL,
percent=0.0,
speed=-1.0
)
install_line._no_progress_bar = True # Flag to hide progress bar
install_line._no_progress_bar = True
display_items.append(install_line)
# Lines 2+: Show converting textures and BSA files
@@ -293,25 +303,31 @@ class ProgressHandlersMixin:
# Update target state (render loop handles smooth display)
# Explicitly pass None for summary_info to clear any stale summary data
if display_items:
self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None)
self.file_progress_list.update_files(display_items, current_phase=phase_label or "Installing", summary_info=None)
return
elif is_extraction_phase:
# Show summary info for Extracting phase (step count)
# Render loop handles smooth updates - just set target state
# Explicitly pass empty list for file_progresses to clear any stale file list
current_step = progress_state.phase_step
summary_info = {
'current_step': current_step,
'max_steps': progress_state.phase_max_steps,
}
phase_display_name = phase_label or "Extracting"
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
phase_lower = (progress_state.phase_name or "").lower()
if 'decompress' in phase_lower:
label = progress_state.message or "Decompressing archives..."
self._clf3_decompress_label = label
if not getattr(self, '_clf3_decompress_timer', None):
self._start_clf3_decompress_pulse(label)
else:
self._stop_clf3_decompress_pulse()
current_step = progress_state.phase_step
summary_info = {
'current_step': current_step,
'max_steps': progress_state.phase_max_steps,
}
phase_display_name = phase_label or "Queuing Archives"
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
return
elif progress_state.active_files:
self._stop_clf3_decompress_pulse()
if self.debug:
logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
logger.debug(f"Updating file progress list with {len(progress_state.active_files)} files")
for fp in progress_state.active_files:
logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
logger.debug(f" - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
# Pass phase label to update header (e.g., "[Activity - Downloading]")
# Explicitly clear summary_info when showing file list
try:
@@ -320,33 +336,64 @@ class ProgressHandlersMixin:
# Widget was deleted - ignore to prevent coredump
if "already deleted" in str(e):
if self.debug:
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
logger.debug(f"Ignoring widget deletion error: {e}")
return
raise
except Exception as e:
# Catch any other exceptions to prevent coredump
if self.debug:
logger.debug(f"DEBUG: Error updating file progress list: {e}")
logger.debug(f"Error updating file progress list: {e}")
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
else:
# Show empty state so widget stays visible even when no files are active
self._stop_clf3_decompress_pulse()
# When there are no active files but phase progress counters are set (CLF3 streaming
# pipeline: extraction runs inside the Downloading phase with no per-file events),
# show a summary widget so the Activity tab is not blank.
summary_info = None
if progress_state.phase_step > 0 or progress_state.phase_max_steps > 0:
summary_info = {
'current_step': progress_state.phase_step,
'max_steps': progress_state.phase_max_steps,
}
try:
self.file_progress_list.update_files([], current_phase=phase_label)
self.file_progress_list.update_files([], current_phase=phase_label, summary_info=summary_info)
except RuntimeError as e:
# Widget was deleted - ignore to prevent coredump
if "already deleted" in str(e):
return
raise
except Exception as e:
# Catch any other exceptions to prevent coredump
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
logger.error(f"Error updating file progress list: {e}", exc_info=True)
def _start_clf3_decompress_pulse(self, label: str = "Decompressing archives..."):
self._clf3_decompress_label = label
if not getattr(self, '_clf3_decompress_timer', None):
self._clf3_decompress_timer = QTimer(self)
self._clf3_decompress_timer.timeout.connect(self._clf3_decompress_heartbeat)
self._clf3_decompress_timer.start(250)
def _clf3_decompress_heartbeat(self):
label = getattr(self, '_clf3_decompress_label', "Decompressing archives...")
self.file_progress_list.update_or_add_item("__clf3_decompress__", label, 0.0)
def _stop_clf3_decompress_pulse(self):
timer = getattr(self, '_clf3_decompress_timer', None)
if timer:
timer.stop()
self._clf3_decompress_timer = None
self._clf3_decompress_label = None
def on_installation_finished(self, success, message):
"""Handle installation completion"""
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
# R&D: Clear all progress displays when installation completes
self._stop_clf3_decompress_pulse()
logger.debug(f"on_installation_finished called with success={success}, message={message}")
# installation_finished is emitted from inside run() via a queued connection,
# so run() may still be executing its finally block when this slot fires.
# Destroying the thread object while run() is still on the stack causes
# "QThread: Destroyed while thread is still running" / SIGABRT.
thread = getattr(self, 'install_thread', None)
if thread and thread.isRunning():
thread.wait(3000)
self.progress_state_manager.reset()
# Clear file list but keep CPU tracking running for configuration phase
self.file_progress_list.list_widget.clear()
@@ -382,6 +429,13 @@ class ProgressHandlersMixin:
except Exception as _meta_err:
logger.debug(f"Modlist meta write skipped: {_meta_err}")
try:
from jackify.backend.utils.clf3_postinstall import inject_mo2_download_dir
if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'downloads_dir', None):
inject_mo2_download_dir(thread.install_dir, thread.downloads_dir)
except Exception as _ini_err:
logger.debug(f"MO2 INI download_directory injection skipped: {_ini_err}")
logger.info(f"Installation succeeded: {message}")
if self.show_details_checkbox.isChecked():
self._safe_append_text(f"\nSuccess: {message}")
@@ -418,12 +472,12 @@ class ProgressHandlersMixin:
self.process_finished(1, QProcess.CrashExit) # Simulate error
def process_finished(self, exit_code, exit_status):
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}")
# Reset button states
self.start_btn.setEnabled(True)
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug("DEBUG: Button states reset in process_finished")
logger.debug("Button states reset in process_finished")
# Stop manual download manager if it is still running (e.g. install failed mid-phase)
if getattr(self, '_manual_dl_manager', None) is not None:
@@ -153,13 +153,13 @@ class ModlistSelectionMixin:
if hasattr(self, 'current_game_type'):
game_filter = game_type_to_human_friendly.get(self.current_game_type)
dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
self._gallery_dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
if cursor_overridden:
QApplication.restoreOverrideCursor()
cursor_overridden = False
if dlg.exec() == QDialog.Accepted and dlg.selected_metadata:
metadata = dlg.selected_metadata
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
metadata = self._gallery_dlg.selected_metadata
self.modlist_btn.setText(metadata.title)
self.selected_modlist_info = {
'machine_url': metadata.namespacedName,
@@ -170,6 +170,7 @@ class ModlistSelectionMixin:
'nsfw': metadata.nsfw,
'force_down': metadata.forceDown,
'readme_url': metadata.links.readme if metadata.links else None,
'download_url': metadata.links.download if metadata.links else None,
}
self.modlist_name_edit.setText(metadata.title)
@@ -1,9 +1,14 @@
"""Steam shortcut conflict handling for InstallModlistScreen (Mixin)."""
import logging
import os
from PySide6.QtCore import QThread, Signal
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from jackify.frontends.gui.services.message_service import MessageService
logger = logging.getLogger(__name__)
class InstallModlistShortcutDialogMixin:
"""Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen."""
@@ -62,7 +67,7 @@ class InstallModlistShortcutDialogMixin:
self._restore_controls_after_shortcut_dialog_abort()
return
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
self.continue_configuration_after_automated_prefix(int(existing_appid), modlist_name, install_dir, None)
self._reuse_shortcut_with_prefix_check(int(existing_appid), modlist_name, install_dir)
return
if action == "new":
@@ -79,6 +84,54 @@ class InstallModlistShortcutDialogMixin:
self._safe_append_text("Shortcut creation cancelled by user")
self._restore_controls_after_shortcut_dialog_abort()
def _reuse_shortcut_with_prefix_check(self, appid: int, modlist_name: str, install_dir: str) -> None:
"""Continue configuration after conflict resolution, creating the prefix if it is missing."""
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
svc = AutomatedPrefixService()
if svc.get_prefix_path(appid):
self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None)
return
logger.info("Proton prefix missing for AppID %s; creating before configuration", appid)
self._safe_append_text("[00:00:00] Proton prefix not found; creating prefix...")
class _PrefixCreateThread(QThread):
finished = Signal(bool)
def __init__(self, appid):
super().__init__()
self._appid = appid
def run(self):
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
ok = AutomatedPrefixService().create_prefix_with_proton_wrapper(self._appid)
self.finished.emit(ok)
except Exception as exc:
logger.error("Prefix creation thread failed: %s", exc)
self.finished.emit(False)
_thread = _PrefixCreateThread(appid)
def _on_done(success):
_thread.deleteLater()
if not success:
logger.error("Failed to create Proton prefix for AppID %s", appid)
MessageService.warning(
self,
"Prefix Creation Failed",
"Jackify could not create the Proton prefix.\n\n"
"Try launching the modlist from Steam once to initialise it, then run Configure.",
)
self._restore_controls_after_shortcut_dialog_abort()
return
self._safe_append_text("[00:00:00] Proton prefix created.")
self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None)
_thread.finished.connect(_on_done)
self._prefix_create_thread = _thread
_thread.start()
def retry_automated_workflow_with_new_name(self, new_name):
"""Retry the automated workflow with a new shortcut name."""
self.modlist_name_edit.setText(new_name)
@@ -33,13 +33,13 @@ class TTWIntegrationMixin:
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
logger.debug("DEBUG: TTW already installed, skipping prompt")
logger.debug("TTW already installed, skipping prompt")
return False
return True
except Exception as e:
logger.debug(f"DEBUG: Error checking TTW eligibility: {e}")
logger.debug(f"Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
@@ -73,15 +73,15 @@ class TTWIntegrationMixin:
# Verify it has actual TTW content by checking for the main ESM
ttw_esm = folder / "TaleOfTwoWastelands.esm"
if ttw_esm.exists():
logger.debug(f"DEBUG: Found existing TTW installation: {folder.name}")
logger.debug(f"Found existing TTW installation: {folder.name}")
return True
else:
logger.debug(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
logger.debug(f"Found TTW folder but no ESM, skipping: {folder.name}")
return False
except Exception as e:
logger.debug(f"DEBUG: Error detecting existing TTW: {e}")
logger.debug(f"Error detecting existing TTW: {e}")
return False # Assume not installed on error
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
@@ -173,40 +173,34 @@ class TTWIntegrationMixin:
vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir)
if vnv_automation_running:
# Store success dialog params for later (after VNV automation completes)
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'workflow_type': 'install',
'time_taken': time_str,
'game_name': game_name,
'enb_detected': False, # TTW installs don't have ENB
'ttw_version': ttw_version if 'ttw_version' in locals() else None
'enb_detected': False,
'install_dir': getattr(self, '_ttw_install_dir', '') or '',
'game_type': 'falloutnv',
'appid': getattr(self, '_current_appid', '') or '',
}
# Keep post-install feedback active during VNV automation
# Don't show success dialog yet - will be shown in _on_vnv_complete
return
# No VNV automation - end post-install feedback now
self._end_post_install_feedback(True)
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show enhanced success dialog
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
self._run_verifier_then_show_success(
install_dir=getattr(self, '_ttw_install_dir', '') or '',
game_type='falloutnv',
appid=getattr(self, '_current_appid', '') or '',
success_params={
'modlist_name': modlist_name,
'workflow_type': 'install',
'time_taken': time_str,
'game_name': game_name,
'enb_detected': False,
},
)
# Add TTW installation info to dialog if possible
if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'):
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
success_dialog.show()
except Exception as e:
logger.debug(f"ERROR: Failed to show final success dialog: {e}")
from jackify.frontends.gui.services.message_service import MessageService
@@ -104,7 +104,7 @@ class InstallModlistUISetupMixin:
header_layout = QVBoxLayout()
header_layout.setSpacing(1) # Reduce spacing between title and description
# Title (no logo)
title = QLabel("<b>Install a Modlist (Automated)</b>")
title = QLabel("<b>Install a Modlist</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
title.setAlignment(Qt.AlignHCenter)
title.setMaximumHeight(30) # Force compact height
@@ -252,6 +252,7 @@ class InstallModlistUISetupMixin:
# Update nexus status on init
self._update_nexus_status()
# --- Resolution Dropdown ---
resolution_label = QLabel("Resolution:")
self.resolution_combo = QComboBox()
@@ -293,7 +294,7 @@ class InstallModlistUISetupMixin:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
@@ -304,23 +305,44 @@ class InstallModlistUISetupMixin:
# Otherwise, default is 'Leave unchanged' (index 0)
form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
# Horizontal layout for resolution dropdown and auto-restart checkbox
# Horizontal layout for resolution dropdown and right-side checkboxes
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
self.resolution_combo.setMaximumWidth(280)
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
right_checks_layout = QVBoxLayout()
right_checks_layout.setSpacing(4)
right_checks_layout.setContentsMargins(0, 0, 0, 0)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setChecked(False)
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
right_checks_layout.addWidget(self.auto_restart_checkbox)
engine_row = QHBoxLayout()
engine_row.setSpacing(4)
engine_row.setContentsMargins(0, 0, 0, 0)
self.engine_checkbox = QCheckBox("Use Experimental Engine")
self.engine_checkbox.setToolTip("Use CLF3 (SulfurNitride) as the install engine instead of jackify-engine")
self.engine_checkbox.toggled.connect(self._on_engine_checkbox_toggled)
engine_row.addWidget(self.engine_checkbox)
engine_whats_this = QLabel('<a href="https://github.com/Omni-guides/Jackify/wiki/Install-Engines" style="color: #6fa8dc; font-size: 11px;">(what\'s this?)</a>')
engine_whats_this.setOpenExternalLinks(False)
engine_whats_this.linkActivated.connect(self._open_url_safe)
engine_row.addWidget(engine_whats_this)
engine_row.addStretch()
engine_row_widget = QWidget()
engine_row_widget.setLayout(engine_row)
self._engine_row_widget = engine_row_widget
right_checks_layout.addWidget(engine_row_widget)
self._init_engine_checkbox()
resolution_and_restart_layout.addLayout(right_checks_layout)
form_grid.addLayout(resolution_and_restart_layout, 5, 1)
form_section_widget = QWidget()
form_section_widget.setLayout(form_grid)
@@ -514,3 +536,13 @@ class InstallModlistUISetupMixin:
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _init_engine_checkbox(self) -> None:
from jackify.backend.services.tool_registry import get_active_engine_id
self._engine_row_widget.setVisible(True)
self.engine_checkbox.blockSignals(True)
self.engine_checkbox.setChecked(get_active_engine_id() == "clf3")
self.engine_checkbox.blockSignals(False)
def _on_engine_checkbox_toggled(self, checked: bool) -> None:
pass # state is read at install time; persistent default lives in Settings
@@ -50,23 +50,15 @@ class VNVAutomationMixin:
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
self.file_progress_list.clear()
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=params['modlist_name'],
workflow_type="install",
time_taken=params['time_taken'],
game_name=params['game_name'],
parent=self,
self._run_verifier_then_show_success(
install_dir=params.get('install_dir', ''),
game_type=params.get('game_type', 'unknown'),
appid=params.get('appid', ''),
success_params={
'modlist_name': params['modlist_name'],
'workflow_type': params.get('workflow_type', 'install'),
'time_taken': params['time_taken'],
'game_name': params.get('game_name'),
'enb_detected': params.get('enb_detected', False),
},
)
success_dialog.show()
if params.get('enb_detected'):
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
enb_dialog.exec()
except Exception as e:
logger.warning("Failed to show ENB dialog: %s", e)
@@ -1,7 +1,8 @@
"""Execution workflow methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtWidgets import QMessageBox
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QMessageBox
import logging
import os
@@ -14,15 +15,107 @@ logger = logging.getLogger(__name__)
class InstallWorkflowExecutionMixin:
"""Mixin containing install-run and manual-download dialog execution methods."""
def _session_engine_id(self) -> str:
"""Return the engine to use for this install based on the install screen checkbox."""
return "clf3" if self.engine_checkbox.isChecked() else "jackify-engine"
def _ensure_clf3_installed(self) -> bool:
"""
If CLF3 is already installed, return True immediately.
If not, show a download dialog and install it, returning True on success.
"""
from jackify.backend.services.tool_registry import ToolRegistry
status = ToolRegistry().get_status("clf3")
if status and status.installed:
return True
reply = QMessageBox.question(
self,
"CLF3 Not Installed",
"The experimental engine (CLF3) is not installed.\n\n"
"Download and install it now to continue?",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return False
return self._download_clf3_with_dialog()
def _download_clf3_with_dialog(self) -> bool:
"""Download CLF3 in a modal dialog with a pulsing progress bar. Returns True on success."""
class _Clf3InstallThread(QThread):
finished_signal = Signal(bool, str)
def run(self):
try:
ok, msg = ToolRegistry().install("clf3")
self.finished_signal.emit(ok, msg)
except Exception as exc:
self.finished_signal.emit(False, str(exc))
from jackify.backend.services.tool_registry import ToolRegistry
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
dlg = QDialog(self)
dlg.setWindowTitle("Installing CLF3")
dlg.setModal(True)
dlg.setMinimumWidth(360)
layout = QVBoxLayout(dlg)
layout.setSpacing(12)
layout.setContentsMargins(16, 16, 16, 16)
label = QLabel("Downloading CLF3 (experimental engine)...")
label.setStyleSheet("color: #ccc; font-size: 13px;")
layout.addWidget(label)
bar = QProgressBar()
bar.setRange(0, 0)
bar.setTextVisible(False)
bar.setFixedHeight(6)
bar.setStyleSheet(f"""
QProgressBar {{ border: none; background-color: #333; border-radius: 3px; }}
QProgressBar::chunk {{ background-color: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}
""")
layout.addWidget(bar)
result = [False, ""]
thread = _Clf3InstallThread()
def on_done(ok: bool, msg: str):
result[0] = ok
result[1] = msg
dlg.accept()
thread.finished_signal.connect(on_done)
thread.start()
dlg.exec()
thread.wait(5000)
if not result[0]:
QMessageBox.critical(
self,
"CLF3 Install Failed",
f"Could not install CLF3:\n\n{result[1]}",
)
return False
label.setText("CLF3 installed.")
return True
def validate_and_start_install(self):
import time
self._install_workflow_start_time = time.time()
logger.debug('DEBUG: validate_and_start_install called')
# Disable controls before processEvents to prevent double-click re-entry
self._disable_controls_during_operation()
# Immediately show "Initialising" status to provide feedback
self.progress_indicator.set_status("Initialising...", 0)
from PySide6.QtWidgets import QApplication
QApplication.processEvents() # Force UI update
QApplication.processEvents()
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
@@ -30,12 +123,18 @@ class InstallWorkflowExecutionMixin:
# Check protontricks before proceeding
if not self._check_protontricks():
self.progress_indicator.reset()
self._enable_controls_after_operation()
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
try:
install_dir = self.install_dir_edit.text().strip()
downloads_dir = self.downloads_dir_edit.text().strip()
if self._session_engine_id() == "clf3" and not self._ensure_clf3_installed():
self.progress_indicator.reset()
self._enable_controls_after_operation()
return
tab_index = self.source_tabs.currentIndex()
install_mode = 'online'
if tab_index == 1: # .wabbajack File tab
@@ -70,8 +169,22 @@ class InstallWorkflowExecutionMixin:
# CRITICAL: Use machine_url, NOT button text
modlist = machine_url
install_dir = self.install_dir_edit.text().strip()
downloads_dir = self.downloads_dir_edit.text().strip()
if self._session_engine_id() == "clf3":
download_url = self.selected_modlist_info.get('download_url')
if not download_url:
self._abort_with_message(
"warning",
"Download URL Unavailable",
"Could not determine the download URL for this modlist.\n\n"
"Use the '.wabbajack File' tab to select a local file instead."
)
return
from jackify.shared.paths import get_jackify_downloads_dir
list_id = machine_url.split('/')[-1] if '/' in machine_url else machine_url
wabbajack_local = str(get_jackify_downloads_dir() / f"{list_id}.wabbajack")
modlist = wabbajack_local
self._clf3_cdn_url = download_url
# Get authentication token (OAuth or API key) with automatic refresh
api_key, oauth_info = self.auth_service.get_auth_for_engine()
@@ -92,8 +205,6 @@ class InstallWorkflowExecutionMixin:
logger.info("Authentication Status at Install Start")
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
logger.info(f"Token length: {len(api_key)} chars")
if len(api_key) >= 8:
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
if auth_method == 'oauth':
token_handler = self.auth_service.token_handler
@@ -169,14 +280,14 @@ class InstallWorkflowExecutionMixin:
self._current_resolution = raw_resolution
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
logger.debug(f"Resolution saved successfully: {resolution}")
else:
logger.debug("DEBUG: Failed to save resolution")
logger.debug("Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
logger.debug("DEBUG: Saved resolution cleared")
logger.debug("Saved resolution cleared")
ensure_flatpak_steam_filesystem_access(Path(install_dir))
@@ -227,7 +338,7 @@ class InstallWorkflowExecutionMixin:
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
readme_url = self.selected_modlist_info.get('readme_url')
game_name = self.selected_modlist_info.get('game', '')
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
# Map game name to game type
game_mapping = {
@@ -247,12 +358,12 @@ class InstallWorkflowExecutionMixin:
"baldur's gate 3": 'bg3',
}
game_type = game_mapping.get(game_name.lower())
logger.debug(f"DEBUG: 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:
game_type = 'unknown'
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
logger.debug(f"Game type not found in mapping, setting to 'unknown'")
else:
logger.debug(f"DEBUG: No selected_modlist_info found")
logger.debug(f"No selected_modlist_info found")
game_type = 'unknown'
# Store game type and name for later use
@@ -260,13 +371,13 @@ class InstallWorkflowExecutionMixin:
self._current_game_name = game_name
# Check if game is supported
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
logger.debug(f"Checking if game_type '{game_type}' is supported")
logger.debug(f"game_type='{game_type}', game_name='{game_name}'")
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
logger.debug(f"is_supported_game('{game_type}') returned: {is_supported}")
if game_type and not is_supported:
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
logger.debug(f"Game '{game_type}' is not supported, showing dialog")
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
dialog = UnsupportedGameDialog(self, game_name)
if not dialog.show_dialog(self, game_name):
@@ -366,7 +477,8 @@ class InstallWorkflowExecutionMixin:
self._record_pre_update_ini_snapshot(install_real)
# CRITICAL: Final safety check - ensure online modlists use machine_url
if install_mode == 'online':
# CLF3 is exempt: it uses a pre-resolved local .wabbajack path, not machine_url
if install_mode == 'online' and self._session_engine_id() != "clf3":
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
expected_machine_url = self.selected_modlist_info.get('machine_url')
if expected_machine_url:
@@ -393,27 +505,31 @@ class InstallWorkflowExecutionMixin:
readme_url = readme_url.replace("/main/", "/blob/main/")
readme_url = readme_url.replace("/master/", "/blob/master/")
logger.info(f"Opening modlist readme: {readme_url}")
clean_env = {k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "LD_PRELOAD")}
subprocess.Popen(["xdg-open", readme_url], env=clean_env)
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
self._safe_append_text(
"Modlist readme opened in your browser. "
"Check it for any manual post-install steps before launching the game."
)
logger.debug(f'DEBUG: 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)
except Exception as e:
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
import traceback
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
# Re-enable all controls after exception
logger.error("Unexpected error in validate_and_start_install", exc_info=True)
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
from jackify.shared.paths import get_jackify_logs_dir
from ..services.message_service import MessageService
MessageService.critical(
self,
"Installation Error",
f"Could not start the installation.\n\n{e}\n\n"
f"Details were written to the Jackify log at:\n{get_jackify_logs_dir()}",
)
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
@@ -434,10 +550,15 @@ class InstallWorkflowExecutionMixin:
self._downloads_dir = downloads_dir
self.install_thread = InstallerThread(
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info, # Pass OAuth state for auto-refresh
progress_state_manager=self.progress_state_manager,
auth_service=self.auth_service,
oauth_info=oauth_info,
game_type=getattr(self, '_current_game_type', None),
clf3_cdn_url=getattr(self, '_clf3_cdn_url', None),
engine_id=self._session_engine_id(),
)
self._clf3_cdn_url = None
self._active_session_engine_id = self._session_engine_id()
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
+22 -8
View File
@@ -116,10 +116,13 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
def _open_url_safe(self, url):
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
import subprocess
import os
_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}
try:
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
except Exception as e:
print(f"Warning: Could not open URL {url}: {e}")
logger.warning(f"Could not open URL {url}: {e}")
def _load_saved_parent_directories(self):
"""No-op: do not pre-populate install/download directories from saved values."""
@@ -136,16 +139,16 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
if saved_install_parent:
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
self.install_dir_edit.setText(suggested_install_dir)
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
logger.debug(f"Updated install directory suggestion: {suggested_install_dir}")
# Update download directory suggestion
saved_download_parent = self.config_handler.get_default_download_parent_dir()
if saved_download_parent:
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
logger.debug(f"Updated download directory suggestion: {suggested_download_dir}")
except Exception as e:
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
logger.debug(f"Error updating directory suggestions: {e}")
def _save_parent_directories(self, install_dir, downloads_dir):
"""Removed automatic saving - user should set defaults in settings"""
@@ -341,14 +344,25 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
font-size: 13px;
""")
# Park all threads first (disconnects signals), then send cooperative cancel.
self._park_all_threads()
# Send process-group kill first so the subprocess tree dies before we
# disconnect signals or wait on the thread.
if hasattr(self, 'install_thread') and self.install_thread:
try:
self.install_thread.cancel()
except Exception:
pass
# Park threads (disconnects signals so no callbacks fire on the dying widget).
self._park_all_threads()
# Wait up to 5 s for the thread to exit after the kill signal.
if hasattr(self, 'install_thread') and self.install_thread:
try:
self.install_thread.wait(5000)
except Exception:
pass
self.install_thread = None
# Cleanup any remaining processes
self.cleanup_processes()
@@ -191,7 +191,7 @@ class TTWIntegrationMixin:
self.status_banner.setText("TTW integration completed successfully!")
self.status_banner.setStyleSheet(f"""
QLabel {{
background-color: #28a745;
background-color: #1a3040;
color: white;
font-weight: bold;
padding: 8px;
@@ -51,7 +51,7 @@ class TTWLifecycleMixin:
def showEvent(self, event):
"""Called when the widget becomes visible"""
super().showEvent(event)
logger.debug(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
logger.debug(f"TTW showEvent - integration_mode={self._integration_mode}")
# Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens
from PySide6.QtCore import QTimer
@@ -73,7 +73,7 @@ class TTWLifecycleMixin:
is_steamdeck = True
if is_steamdeck:
logger.debug("DEBUG: Steam Deck detected, keeping expanded")
logger.debug("Steam Deck detected, keeping expanded")
# Force expanded state and hide checkbox
if self.show_details_checkbox.isVisible():
self.show_details_checkbox.setVisible(False)
@@ -84,27 +84,27 @@ class TTWLifecycleMixin:
self.console.setMaximumHeight(16777215) # Remove height limit
return
except Exception as e:
logger.debug(f"DEBUG: Steam Deck check exception: {e}")
logger.debug(f"Steam Deck check exception: {e}")
pass
logger.debug(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
logger.debug(f"Checkbox checked={self.show_details_checkbox.isChecked()}")
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
logger.debug("DEBUG: Calling _toggle_console_visibility(Unchecked)")
logger.debug("Calling _toggle_console_visibility(Unchecked)")
self._toggle_console_visibility(_Qt.Unchecked)
# Force the window to compact height to eliminate bottom whitespace
main_window = self.window()
logger.debug(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
logger.debug(f"main_window={main_window}, size={main_window.size() if main_window else None}")
if main_window:
# Save original geometry once
if self._saved_geometry is None:
self._saved_geometry = main_window.geometry()
logger.debug(f"DEBUG: Saved geometry: {self._saved_geometry}")
logger.debug(f"Saved geometry: {self._saved_geometry}")
if self._saved_min_size is None:
self._saved_min_size = main_window.minimumSize()
logger.debug(f"DEBUG: Saved min size: {self._saved_min_size}")
logger.debug(f"Saved min size: {self._saved_min_size}")
# Fixed compact size - same as menu screens
from PySide6.QtCore import QSize
@@ -120,14 +120,14 @@ class TTWLifecycleMixin:
# Notify parent to ensure compact
try:
self.resize_request.emit('collapse')
logger.debug("DEBUG: Emitted resize_request collapse signal")
logger.debug("Emitted resize_request collapse signal")
except Exception as e:
logger.debug(f"DEBUG: Exception emitting signal: {e}")
logger.debug(f"Exception emitting signal: {e}")
pass
except Exception as e:
logger.debug(f"DEBUG: showEvent exception: {e}")
logger.debug(f"showEvent exception: {e}")
import traceback
logger.debug(f"DEBUG: {traceback.format_exc()}")
logger.debug(f"{traceback.format_exc()}")
pass
def hideEvent(self, event):
@@ -141,8 +141,8 @@ class TTWLifecycleMixin:
# Important when console is expanded
main_window.setMaximumSize(QSize(16777215, 16777215))
main_window.setMinimumSize(QSize(0, 0))
logger.debug("DEBUG: Install TTW hideEvent - cleared window size constraints")
logger.debug("Install TTW hideEvent - cleared window size constraints")
except Exception as e:
logger.debug(f"DEBUG: hideEvent exception: {e}")
logger.debug(f"hideEvent exception: {e}")
pass
@@ -1,291 +1,119 @@
"""TTW installer requirements and validation for InstallTTWScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import QMessageBox
from jackify.frontends.gui.services.message_service import MessageService
from pathlib import Path
import os
import requests
import traceback
import logging
from pathlib import Path
from typing import Dict, Optional, Tuple
from jackify.frontends.gui.services.message_service import MessageService
logger = logging.getLogger(__name__)
# Maps TTW-required game display names to Jackify game_type strings.
_TTW_GAMES = {
'Fallout 3': 'fallout3',
'Fallout New Vegas': 'falloutnv',
}
def _detect_ttw_games() -> Dict[str, Tuple[Path, str]]:
"""
Return a dict of {display_name: (path, store)} for each TTW-required game found.
Tries Steam appmanifests first, then Heroic (GOG/Epic).
"""
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
finder = VanillaGameFinder()
results = {}
for display_name, game_type in _TTW_GAMES.items():
location = finder.find(game_type)
if location:
results[display_name] = location
return results
class TTWRequirementsMixin:
"""Mixin providing TTW installer requirement checking and validation for InstallTTWScreen."""
def check_requirements(self):
"""Check and display requirements status"""
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.models.configuration import SystemInfo
path_handler = PathHandler()
# Check game detection
detected_games = path_handler.find_vanilla_game_paths()
# Fallout 3
if 'Fallout 3' in detected_games:
self.fallout3_status.setText("Fallout 3: Detected")
self.fallout3_status.setStyleSheet("color: #3fd0ea;")
else:
self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam")
self.fallout3_status.setStyleSheet("color: #f44336;")
# Fallout New Vegas
if 'Fallout New Vegas' in detected_games:
self.fnv_status.setText("Fallout New Vegas: Detected")
self.fnv_status.setStyleSheet("color: #3fd0ea;")
else:
self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam")
self.fnv_status.setStyleSheet("color: #f44336;")
# Update Start button state after checking requirements
self._update_start_button_state()
def _check_ttw_installer_status(self):
"""Check TTW_Linux_Installer installation status and update UI"""
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.models.configuration import SystemInfo
# Create handler instances
filesystem_handler = FileSystemHandler()
config_handler = ConfigHandler()
system_info = SystemInfo(is_steamdeck=False)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Check if TTW_Linux_Installer is installed
ttw_installer_handler._check_installation()
_ttw_installer_ready: bool = False
if ttw_installer_handler.ttw_installer_installed:
# Check version against pinned/latest
update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available()
if update_available:
# Determine if this is a downgrade or upgrade
from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION
if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v:
# If we have a pinned version and installed is newer, it's a downgrade
try:
# Simple version comparison - if installed version string is longer/more complex, likely newer
# For now, just check if they're different and show appropriate message
if installed_v != target_v:
version_text = f"Update to v{target_v} (currently v{installed_v})"
else:
version_text = f"Update available (v{installed_v} → v{target_v})"
except Exception:
version_text = f"Update to v{target_v}" if target_v else "Update available"
else:
# Normal update (newer version available)
version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
else:
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
self.ttw_installer_btn.setVisible(True)
def check_requirements(self):
detected = _detect_ttw_games()
for display_name, label_widget in (
('Fallout 3', self.fallout3_status),
('Fallout New Vegas', self.fnv_status),
):
if display_name in detected:
_, store = detected[display_name]
store_label = {'steam': 'Steam', 'gog': 'GOG', 'epic': 'Epic'}.get(store, store)
label_widget.setText(f"{display_name}: Detected ({store_label})")
label_widget.setStyleSheet("color: #3fd0ea;")
else:
self.ttw_installer_status.setText("Not Found")
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
label_widget.setText(f"{display_name}: Not Found")
label_widget.setStyleSheet("color: #f44336;")
self._update_start_button_state()
def _check_ttw_installer_status(self):
status = None
try:
from jackify.backend.services.tool_registry import ToolRegistry
status = ToolRegistry().get_status("ttw_installer")
self._ttw_installer_ready = bool(status and status.installed)
except Exception as e:
self.ttw_installer_status.setText("Check Failed")
logger.debug("TTW installer status check failed: %s", e)
self._ttw_installer_ready = False
if self._ttw_installer_ready:
version_text = f"Ready (v{status.installed_version})" if status and status.installed_version else "Ready"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
self.ttw_installer_btn.setVisible(False)
else:
self.ttw_installer_status.setText("Not installed - install via Tools Hub")
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setText("Open Tools Hub")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
logger.debug(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
self._update_start_button_state()
def install_ttw_installer(self):
"""Install or update TTW_Linux_Installer"""
# If not detected, show info dialog
try:
current_status = self.ttw_installer_status.text().strip()
except Exception:
current_status = ""
if current_status == "Not Found":
MessageService.information(
self,
"TTW_Linux_Installer Installation",
(
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
"Please star the repository and thank the developer.<br><br>"
"Jackify will now download and install the latest Linux build of TTW_Linux_Installer."
),
safety_level="low",
)
"""Navigate to Tools Hub for TTW Linux Installer management."""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(10)
# Update button to show installation in progress
self.ttw_installer_btn.setText("Installing...")
self.ttw_installer_btn.setEnabled(False)
def _check_ttw_requirements(self, silent: bool = False) -> bool:
detected = _detect_ttw_games()
missing = [name for name in _TTW_GAMES if name not in detected]
self.console.append("Installing/updating TTW_Linux_Installer...")
# Create background thread for installation
from PySide6.QtCore import QThread, Signal
class InstallerDownloadThread(QThread):
finished = Signal(bool, str) # success, message
progress = Signal(str) # progress message
def run(self):
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.models.configuration import SystemInfo
# Create handler instances
filesystem_handler = FileSystemHandler()
config_handler = ConfigHandler()
system_info = SystemInfo(is_steamdeck=False)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Install TTW_Linux_Installer (this will download and extract)
self.progress.emit("Downloading TTW_Linux_Installer...")
success, message = ttw_installer_handler.install_ttw_installer()
if success:
install_path = ttw_installer_handler.ttw_installer_dir
self.progress.emit(f"Installation complete: {install_path}")
else:
self.progress.emit(f"Installation failed: {message}")
self.finished.emit(success, message)
except Exception as e:
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
self.progress.emit(error_msg)
logger.debug(f"DEBUG: TTW_Linux_Installer installation error: {e}")
self.finished.emit(False, error_msg)
# Create and start thread
self.installer_download_thread = InstallerDownloadThread()
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
self.installer_download_thread.start()
# Update Activity window to show download in progress
self.file_progress_list.clear()
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Downloading TTW_Linux_Installer...",
progress=0
)
def _on_installer_download_progress(self, message):
"""Handle installer download progress updates"""
self.console.append(message)
# Update Activity window based on progress message
if "Downloading" in message:
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Downloading TTW_Linux_Installer...",
progress=0 # Indeterminate progress
)
elif "Extracting" in message or "extracting" in message.lower():
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Extracting TTW_Linux_Installer...",
progress=50
)
elif "complete" in message.lower() or "successfully" in message.lower():
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="TTW_Linux_Installer ready",
progress=100
)
def _on_installer_download_finished(self, success, message):
"""Handle installer download completion"""
if success:
self.console.append("TTW_Linux_Installer installed successfully")
# Clear Activity window after successful installation
self.file_progress_list.clear()
# Re-check status after installation (this will update button state correctly)
self._check_ttw_installer_status()
self._update_start_button_state()
else:
self.console.append(f"Installation failed: {message}")
# Clear Activity window on failure
self.file_progress_list.clear()
# Re-enable button on failure so user can retry
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
def _check_ttw_requirements(self):
"""Check TTW requirements before installation"""
from jackify.backend.handlers.path_handler import PathHandler
path_handler = PathHandler()
# Check game detection
detected_games = path_handler.find_vanilla_game_paths()
missing_games = []
if 'Fallout 3' not in detected_games:
missing_games.append("Fallout 3")
if 'Fallout New Vegas' not in detected_games:
missing_games.append("Fallout New Vegas")
if missing_games:
MessageService.warning(
self,
"Missing Required Games",
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}"
)
if missing:
if not silent:
MessageService.warning(
self,
"Missing Required Games",
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\n"
f"Not found: {', '.join(missing)}\n\n"
"Install via Steam, GOG (through Heroic), or another supported store."
)
return False
# Check TTW_Linux_Installer using the status we already checked
status_text = self.ttw_installer_status.text()
if status_text in ("Not Found", "Check Failed"):
MessageService.warning(
self,
"TTW_Linux_Installer Required",
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button."
)
if not self._ttw_installer_ready:
if not silent:
MessageService.warning(
self,
"TTW Linux Installer Required",
"TTW Linux Installer is not installed.\n\nInstall it from the Tools Hub before proceeding."
)
return False
return True
def _update_start_button_state(self):
"""Enable/disable Start button based on requirements and file selection"""
# Check if all requirements are met
requirements_met = self._check_ttw_requirements()
# Check if .mpi file is selected
requirements_met = self._check_ttw_requirements(silent=True)
mpi_file_selected = bool(self.file_edit.text().strip())
# Enable Start button only if both requirements are met and file is selected
self.start_btn.setEnabled(requirements_met and mpi_file_selected)
# Update button text to indicate what's missing
if not requirements_met:
self.start_btn.setText("Requirements Not Met")
elif not mpi_file_selected:
self.start_btn.setText("Select TTW .mpi File")
else:
self.start_btn.setText("Start Installation")
@@ -1,5 +1,7 @@
"""TTW installation worker thread."""
from PySide6.QtCore import QThread, Signal
import os
import signal
import time
from ..utils import strip_ansi_control_codes
@@ -21,11 +23,15 @@ class TTWInstallationThread(QThread):
def cancel(self):
self.cancelled = True
try:
if self.proc and self.proc.poll() is None:
self.proc.terminate()
except Exception:
pass
if self.proc and self.proc.poll() is None:
try:
pgid = os.getpgid(self.proc.pid)
os.killpg(pgid, signal.SIGTERM)
except Exception:
try:
self.proc.terminate()
except Exception:
pass
def process_and_buffer_line(self, raw_line):
"""Clean one output line and queue it for batched emit."""
@@ -62,7 +68,7 @@ class TTWInstallationThread(QThread):
from pathlib import Path
import tempfile
self.process_and_buffer_line("Initializing TTW installation...")
self.process_and_buffer_line("Initialising TTW installation...")
self.flush_output_buffer()
filesystem_handler = FileSystemHandler()
@@ -84,7 +84,7 @@ class TTWUIMixin:
# On Steam Deck, skip window resizing - keep default Steam Deck window size
if is_steamdeck:
logger.debug("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
logger.debug("Steam Deck detected, skipping window resize in _toggle_console_visibility")
return
# Restore main window to normal size (clear any compact constraints)
@@ -137,7 +137,7 @@ class TTWUIMixin:
# On Steam Deck, skip window resizing to keep maximized state
if is_steamdeck:
logger.debug("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
logger.debug("Steam Deck detected, skipping window resize in collapse branch")
return
# Use fixed compact height for consistency across all workflow screens
@@ -23,10 +23,9 @@ class TTWWorkflowMixin:
def validate_and_start_install(self):
import time
self._install_workflow_start_time = time.time()
logger.debug('DEBUG: validate_and_start_install called')
self.config_handler.reload_config()
logger.debug('DEBUG: Reloaded config from disk')
logger.debug("Reloaded config from disk")
if not self._check_ttw_requirements():
return
@@ -87,13 +86,13 @@ class TTWWorkflowMixin:
shutil.rmtree(item)
else:
item.unlink()
logger.debug(f"DEBUG: Deleted all contents of {install_dir}")
logger.debug(f"Deleted all contents of {install_dir}")
except Exception as e:
MessageService.show_error(self, install_dir_create_failed(str(install_dir), str(e)))
self._enable_controls_after_operation()
return
except Exception as e:
logger.debug(f"DEBUG: Error checking directory contents: {e}")
logger.debug(f"Error checking directory contents: {e}")
if not os.path.isdir(install_dir):
create = MessageService.question(self, "Create Directory?",
@@ -118,18 +117,15 @@ class TTWWorkflowMixin:
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
logger.debug(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
logger.debug(f"Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}")
self.run_ttw_installer(mpi_path, install_dir)
except Exception as e:
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug("DEBUG: Controls re-enabled in exception handler")
def run_ttw_installer(self, mpi_path, install_dir):
logger.debug('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
logger.debug("run_ttw_installer called - USING THREADED BACKEND WRAPPER")
self.config_handler._load_config()
@@ -141,11 +137,11 @@ class TTWWorkflowMixin:
self._safe_append_text("Starting TTW installation...")
self.file_progress_list.clear()
self._update_ttw_phase("Initializing TTW installation", 0, 0, 0)
self._update_ttw_phase("Initialising TTW installation", 0, 0, 0)
QApplication.processEvents()
self.status_banner.setVisible(True)
self.status_banner.setText("Initializing TTW installation...")
self.status_banner.setText("Initialising TTW installation...")
self.show_details_checkbox.setVisible(True)
self.status_banner.setStyleSheet(f"""
@@ -178,7 +174,7 @@ class TTWWorkflowMixin:
def on_installation_finished(self, success, message):
"""Handle installation completion."""
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
logger.debug(f"on_installation_finished called with success={success}, message={message}")
if hasattr(self, 'ttw_elapsed_timer'):
self.ttw_elapsed_timer.stop()
@@ -189,8 +185,8 @@ class TTWWorkflowMixin:
seconds = elapsed % 60
self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s")
self.status_banner.setStyleSheet("""
background-color: #1a4d1a;
color: #4CAF50;
background-color: #1a3040;
color: #3fd0ea;
padding: 8px;
border-radius: 4px;
font-weight: bold;
@@ -220,11 +216,11 @@ class TTWWorkflowMixin:
self.process_finished(1, QProcess.CrashExit)
def process_finished(self, exit_code, exit_status):
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}")
self.start_btn.setEnabled(True)
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug("DEBUG: Button states reset in process_finished")
logger.debug("Button states reset in process_finished")
if exit_code == 0:
self._safe_append_text("\nTTW installation completed successfully!")
@@ -0,0 +1,183 @@
"""Mixin that runs verify_install.py before showing the success dialog."""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtCore import QThread, Signal
logger = logging.getLogger(__name__)
class VerifierThread(QThread):
finished = Signal(object)
def __init__(self, pfx: Path, modlist_dir: Path, game_type: str, appid: str, modlist_name: str = "", parent=None):
super().__init__(parent)
self.pfx = pfx
self.modlist_dir = modlist_dir
self.game_type = game_type
self.appid = appid
self.modlist_name = modlist_name
def run(self):
try:
from jackify.backend.services.install_verifier_service import run_install_verification
results = run_install_verification(self.pfx, self.modlist_dir, self.game_type, self.appid, self.modlist_name)
except Exception as e:
logger.warning("Verifier thread error: %s", e)
results = None
self.finished.emit(results)
def _resolve_pfx_for_appid(appid: str) -> Optional[Path]:
from jackify.backend.services.install_verifier_service import resolve_pfx_for_appid
return resolve_pfx_for_appid(appid)
class InstallVerifierMixin:
"""Mixin: run the verifier before showing the success dialog."""
def _get_appid_for_install_dir(self, install_dir: str) -> str:
stored = getattr(self, "_current_appid", "") or ""
if stored:
return stored
try:
import os
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
sh = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
for sc in sh.find_shortcuts_by_exe("ModOrganizer.exe"):
if os.path.realpath(sc.get("StartDir", "")) == os.path.realpath(install_dir):
raw = sc.get("appid")
if raw is not None:
return str(int(raw) & 0xFFFFFFFF)
except Exception as e:
logger.debug("AppID lookup failed: %s", e)
return ""
def _maybe_apply_jcontainers_fix(self, install_dir: str, game_type: str) -> None:
"""Apply the JContainers Linux fix if needed, with a countdown confirmation dialog."""
try:
from jackify.backend.handlers.modlist_fixup_handler import (
check_jcontainers_needs_fix,
apply_jcontainers_fix,
)
needs_fix = check_jcontainers_needs_fix(Path(install_dir), game_type)
if not needs_fix:
return
from jackify.frontends.gui.services.message_service import SafeMessageBox
from PySide6.QtWidgets import QMessageBox
dlg = SafeMessageBox(parent=self, safety_level="low")
dlg.setup_safety_features(
title="JContainers Compatibility Fix",
message=(
"The mod JContainers has been detected. The Nexusmods version of "
"JContainers is known to cause crashes on Linux/Proton.\n\n"
"A fixed version is available from the mod's GitHub page - would you "
"like the fixed version to be applied now?\n\n"
"The original DLL will be backed up as part of the process."
),
danger_action="Yes",
safe_action="No",
is_question=True,
)
result = dlg.exec()
if result == QMessageBox.Yes:
apply_jcontainers_fix(Path(install_dir), game_type)
logger.info("JContainers fix applied post-configure")
except Exception as e:
logger.warning("JContainers fix check failed (non-fatal): %s", e)
def _run_verifier_then_show_success(
self,
install_dir: str,
game_type: str,
success_params: dict,
appid: str = "",
):
"""
Show 'Verifying...' state, run the verifier in a background thread,
then show SuccessDialog with results embedded.
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
"""
self._maybe_apply_jcontainers_fix(install_dir, game_type)
if hasattr(self, "progress_indicator"):
self.progress_indicator.set_status("Verifying installation...", 100)
if hasattr(self, "file_progress_list"):
self.file_progress_list.update_or_add_item(
"__verifier__", "Verifying installation...", 0.0
)
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
pfx = _resolve_pfx_for_appid(resolved_appid)
if not install_dir or pfx is None:
logger.info(
"Verifier skipped: pfx not found (appid=%s dir=%s)",
resolved_appid, install_dir,
)
self._show_success_dialog(success_params, verification_results=None)
return
self._verifier_thread = VerifierThread(
pfx=pfx,
modlist_dir=Path(install_dir),
game_type=game_type,
appid=resolved_appid,
modlist_name=success_params.get("modlist_name", ""),
parent=self,
)
self._verifier_thread.finished.connect(
lambda r: self._on_verifier_complete_show_success(r, success_params)
)
self._verifier_thread.start()
def _on_verifier_complete_show_success(self, results, success_params: dict):
if self._verifier_thread is not None:
self._verifier_thread.wait(2000)
self._verifier_thread.deleteLater()
self._verifier_thread = None
if results is not None:
n_pass = len(results.passes)
n_warn = len(results.warnings)
n_fail = len(results.failures)
logger.info(
"Install verification: %d passed, %d warnings, %d failures",
n_pass, n_warn, n_fail,
)
for msg in results.failures:
logger.warning("Verifier FAIL: %s", msg)
for msg in results.warnings:
logger.info("Verifier WARN: %s", msg)
else:
logger.warning("Install verifier returned no results (script error)")
self._show_success_dialog(success_params, verification_results=results)
def _show_success_dialog(self, params: dict, verification_results=None):
"""Clear the activity window and show SuccessDialog with optional verification results."""
if hasattr(self, "file_progress_list"):
self.file_progress_list.clear()
from jackify.frontends.gui.dialogs import SuccessDialog
dlg = SuccessDialog(
modlist_name=params["modlist_name"],
workflow_type=params["workflow_type"],
time_taken=params["time_taken"],
game_name=params.get("game_name"),
verification_results=verification_results,
parent=self,
)
dlg.show()
if params.get("enb_detected"):
try:
from jackify.frontends.gui.dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=params["modlist_name"], parent=self)
enb_dialog.exec()
except Exception as e:
logger.warning("Failed to show ENB dialog: %s", e)
+45 -24
View File
@@ -8,11 +8,18 @@ import os
from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT
from ..utils import set_responsive_minimum
_TOOLS_HUB_ACTION = "third_party_tools"
_UPDATE_COLOUR = "#f0c040"
_NORMAL_DESC_COLOUR = "#999"
class MainMenu(QWidget):
def __init__(self, stacked_widget=None, dev_mode=False):
super().__init__()
self.stacked_widget = stacked_widget
self.dev_mode = dev_mode
self._tools_hub_btn: QPushButton = None
self._tools_hub_desc: QLabel = None
self._tools_hub_desc_original: str = ""
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
layout.setContentsMargins(30, 30, 30, 30)
@@ -63,35 +70,17 @@ class MainMenu(QWidget):
button_height = 40
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
# ("Third Party Tools", "third_party_tools", "Install and manage Sulfur's Linux-native modding tools"), # v0.7
("Additional Tasks", "additional_tasks", "Verifier, diagnostics, Nexus OAuth, and more"),
("Tools Hub", "third_party_tools", "Install and manage additional engines and modding tools"),
("Exit Jackify", "exit_jackify", "Close the application"),
]
for label, action_id, description in MENU_ITEMS:
# Main button
btn = QPushButton(label)
btn.setFixedSize(button_width, button_height) # Use variable height
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.setFixedSize(button_width, button_height)
btn.setStyleSheet(self._btn_style())
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Button container with proper alignment
btn_container = QWidget()
btn_layout = QVBoxLayout()
btn_layout.setContentsMargins(0, 0, 0, 0)
@@ -99,10 +88,9 @@ class MainMenu(QWidget):
btn_layout.setAlignment(Qt.AlignHCenter)
btn_layout.addWidget(btn)
# Description label with proper alignment
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 11px;")
desc_label.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
btn_layout.addWidget(desc_label)
@@ -110,6 +98,11 @@ class MainMenu(QWidget):
btn_container.setLayout(btn_layout)
layout.addWidget(btn_container)
if action_id == _TOOLS_HUB_ACTION:
self._tools_hub_btn = btn
self._tools_hub_desc = desc_label
self._tools_hub_desc_original = description
# Disclaimer
layout.addSpacing(12)
disclaimer = QLabel(DISCLAIMER_TEXT)
@@ -135,6 +128,34 @@ class MainMenu(QWidget):
except Exception:
pass
def _btn_style(self, highlight: bool = False) -> str:
border = f"1px solid {_UPDATE_COLOUR}" if highlight else "none"
return f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: {border};
border-radius: 6px;
font-size: 13px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{ background-color: #5a6578; }}
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
"""
def notify_tool_updates(self, has_updates: bool) -> None:
if not self._tools_hub_btn or not self._tools_hub_desc:
return
if has_updates:
self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=True))
self._tools_hub_desc.setText("Updates available")
self._tools_hub_desc.setStyleSheet(f"color: {_UPDATE_COLOUR}; font-size: 11px; font-weight: bold;")
else:
self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=False))
self._tools_hub_desc.setText(self._tools_hub_desc_original)
self._tools_hub_desc.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;")
def menu_action(self, action_id):
if action_id == "exit_jackify":
from PySide6.QtWidgets import QApplication
@@ -137,7 +137,7 @@ class ModlistTasksScreen(QWidget):
"""Set up the menu buttons section"""
# Menu options
MENU_ITEMS = [
("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"),
("Install a Modlist", "install_modlist", "Download and install modlists automatically"),
("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"),
("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"),
]
@@ -1,478 +0,0 @@
"""
Third Party Tools screen.
Lists independently-managed tools with install status, version info,
and Install / Update / Downgrade / Uninstall actions per tool.
Version checks run in a background thread so the screen loads instantly.
"""
import logging
from typing import Dict, Optional
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from jackify.backend.services.tool_registry import TOOL_DEFINITIONS, ToolRegistry, ToolStatus
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
from jackify.frontends.gui.utils import set_responsive_minimum
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
_BTN_INSTALL = "#1a5fa8"
_BTN_UPDATE = "#2a6e2a"
_BTN_DOWNGRADE = "#7a5a00"
_BTN_UNINSTALL = "#6b2020"
_BTN_DISABLED = "#333"
_BADGE_NOT_INSTALLED = ("#555", "#ccc") # bg, fg
_BADGE_UP_TO_DATE = ("#1e4d1e", "#8fdc8f")
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
_BADGE_CHECKING = ("#333", "#888")
def _btn_style(colour: str, disabled: bool = False) -> str:
bg = _BTN_DISABLED if disabled else colour
return f"""
QPushButton {{
background-color: {bg};
color: {'#666' if disabled else 'white'};
border: none; border-radius: 4px;
font-size: 11px; font-weight: bold;
padding: 4px 8px;
}}
QPushButton:hover {{ background-color: {'#444' if disabled else bg}; }}
QPushButton:pressed {{ background-color: {bg}; }}
"""
# ---------------------------------------------------------------------------
# Background version-check thread
# ---------------------------------------------------------------------------
class _VersionCheckThread(QThread):
version_ready = Signal(str, str) # tool_id, latest_version_tag
def run(self):
registry = ToolRegistry()
for defn in TOOL_DEFINITIONS:
try:
tag = registry.check_latest_version(defn.tool_id)
if tag:
self.version_ready.emit(defn.tool_id, tag)
except Exception as e:
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
# ---------------------------------------------------------------------------
# Background install/update/downgrade/uninstall thread
# ---------------------------------------------------------------------------
class _ToolActionThread(QThread):
finished_signal = Signal(str, bool, str) # tool_id, success, message
def __init__(self, tool_id: str, action: str):
super().__init__()
self._tool_id = tool_id
self._action = action
def run(self):
registry = ToolRegistry()
try:
if self._action == "install":
ok, msg = registry.install(self._tool_id)
elif self._action == "update":
ok, msg = registry.update(self._tool_id)
elif self._action == "downgrade":
ok, msg = registry.downgrade(self._tool_id)
elif self._action == "uninstall":
ok, msg = registry.uninstall(self._tool_id)
else:
ok, msg = False, f"Unknown action: {self._action}"
except Exception as e:
ok, msg = False, str(e)
self.finished_signal.emit(self._tool_id, ok, msg)
# ---------------------------------------------------------------------------
# Per-tool card widget
# ---------------------------------------------------------------------------
class _ToolCard(QFrame):
action_requested = Signal(str, str) # tool_id, action
def __init__(self, status: ToolStatus, parent=None):
super().__init__(parent)
self._tool_id = status.definition.tool_id
self._status = status
self._busy = False
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet("""
QFrame {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
}
""")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
outer = QHBoxLayout()
outer.setContentsMargins(14, 10, 14, 10)
outer.setSpacing(12)
# --- Left: name + description ---
info_col = QVBoxLayout()
info_col.setSpacing(2)
tier_tag = " [required]" if status.definition.tier == 1 else ""
name_label = QLabel(f"<b>{status.definition.display_name}</b>{tier_tag}")
name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
info_col.addWidget(name_label)
desc_label = QLabel(status.definition.description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
info_col.addWidget(desc_label)
info_widget = QWidget()
info_widget.setLayout(info_col)
info_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
info_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(info_widget, stretch=3)
# --- Centre: status badge + version ---
centre_col = QVBoxLayout()
centre_col.setSpacing(4)
centre_col.setAlignment(Qt.AlignCenter)
self._badge = QLabel()
self._badge.setAlignment(Qt.AlignCenter)
self._badge.setFixedWidth(130)
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
self._version_label = QLabel()
self._version_label.setAlignment(Qt.AlignCenter)
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
centre_widget = QWidget()
centre_widget.setLayout(centre_col)
centre_widget.setFixedWidth(150)
centre_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(centre_widget)
# --- Right: action buttons ---
btn_col = QVBoxLayout()
btn_col.setSpacing(4)
btn_col.setAlignment(Qt.AlignCenter)
self._btn_primary = QPushButton()
self._btn_primary.setFixedWidth(90)
self._btn_primary.clicked.connect(self._on_primary)
btn_col.addWidget(self._btn_primary)
self._btn_downgrade = QPushButton("Downgrade")
self._btn_downgrade.setFixedWidth(90)
self._btn_downgrade.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "downgrade"))
btn_col.addWidget(self._btn_downgrade)
self._btn_uninstall = QPushButton("Uninstall")
self._btn_uninstall.setFixedWidth(90)
self._btn_uninstall.clicked.connect(self._on_uninstall)
btn_col.addWidget(self._btn_uninstall)
btn_widget = QWidget()
btn_widget.setLayout(btn_col)
btn_widget.setFixedWidth(110)
btn_widget.setStyleSheet("background: transparent; border: none;")
outer.addWidget(btn_widget)
self.setLayout(outer)
self._refresh_ui(status)
# ------------------------------------------------------------------
def _refresh_ui(self, status: ToolStatus):
self._status = status
installed = status.installed
update_avail = status.update_available
can_downgrade = status.can_downgrade
can_uninstall = status.definition.can_uninstall
# Badge
if not installed:
bg, fg = _BADGE_NOT_INSTALLED
badge_text = "Not Installed"
elif update_avail:
bg, fg = _BADGE_UPDATE_AVAIL
badge_text = "Update Available"
else:
bg, fg = _BADGE_UP_TO_DATE
badge_text = "Installed"
self._badge.setText(badge_text)
self._badge.setStyleSheet(
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
)
# Version line
installed_ver = status.installed_version or "-"
latest_ver = status.latest_version or "checking..."
if installed:
self._version_label.setText(f"Installed: {installed_ver}\nLatest: {latest_ver}")
else:
self._version_label.setText(f"Latest: {latest_ver}")
# Primary button
if not installed:
self._btn_primary.setText("Install")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
elif update_avail:
self._btn_primary.setText("Update")
self._btn_primary.setStyleSheet(_btn_style(_BTN_UPDATE))
self._btn_primary.setEnabled(True)
else:
self._btn_primary.setText("Reinstall")
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
self._btn_primary.setEnabled(True)
# Downgrade button
self._btn_downgrade.setStyleSheet(_btn_style(_BTN_DOWNGRADE, disabled=not can_downgrade))
self._btn_downgrade.setEnabled(can_downgrade and not self._busy)
# Uninstall button
self._btn_uninstall.setVisible(can_uninstall)
if can_uninstall:
self._btn_uninstall.setStyleSheet(_btn_style(_BTN_UNINSTALL, disabled=not installed))
self._btn_uninstall.setEnabled(installed and not self._busy)
if self._busy:
self._btn_primary.setEnabled(False)
self._btn_primary.setStyleSheet(_btn_style(_BTN_DISABLED, disabled=True))
def set_latest_version(self, tag: str):
self._status.latest_version = tag
if self._status.installed and self._status.installed_version:
installed = self._status.installed_version.lstrip("v")
latest = tag.lstrip("v")
self._status.update_available = latest != installed
self._refresh_ui(self._status)
def set_busy(self, busy: bool, label: Optional[str] = None):
self._busy = busy
if busy and label:
self._btn_primary.setText(label)
self._refresh_ui(self._status)
def mark_installed(self, version: str):
self._status.installed = True
self._status.installed_version = version
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
def mark_uninstalled(self):
self._status.installed = False
self._status.installed_version = None
self._status.update_available = False
self._busy = False
self._refresh_ui(self._status)
# ------------------------------------------------------------------
def _on_primary(self):
if not self._status.installed:
self.action_requested.emit(self._tool_id, "install")
elif self._status.update_available:
self.action_requested.emit(self._tool_id, "update")
else:
self.action_requested.emit(self._tool_id, "install")
def _on_uninstall(self):
confirmed = MessageService.question(
self,
"Uninstall Tool",
f"Uninstall {self._status.definition.display_name}?\n\nThis will delete the installed files.",
)
if confirmed:
self.action_requested.emit(self._tool_id, "uninstall")
# ---------------------------------------------------------------------------
# Main screen
# ---------------------------------------------------------------------------
class ThirdPartyToolsScreen(ThreadLifecycleMixin, QWidget):
"""Third Party Tools management screen."""
def __init__(self, stacked_widget=None, main_menu_index: int = 0, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self._cards: Dict[str, _ToolCard] = {}
self._action_thread: Optional[_ToolActionThread] = None
self._version_thread: Optional[_VersionCheckThread] = None
self._setup_ui()
def _setup_ui(self):
root = QVBoxLayout()
root.setContentsMargins(30, 24, 30, 24)
root.setSpacing(0)
self.setLayout(root)
# Header
title = QLabel("<b>Third Party Tools</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
root.addWidget(title)
root.addSpacing(6)
desc = QLabel(
"Install and manage independently-updated tools used by Jackify workflows or run via MO2.\n"
"Tools marked [required] are needed by existing Jackify workflows."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #aaa; font-size: 12px;")
desc.setAlignment(Qt.AlignHCenter)
root.addWidget(desc)
root.addSpacing(10)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background: #444;")
root.addWidget(sep)
root.addSpacing(12)
# Scrollable tool list
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
list_widget = QWidget()
list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout()
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(8)
list_widget.setLayout(self._list_layout)
registry = ToolRegistry()
for status in registry.get_all_statuses():
card = _ToolCard(status)
card.action_requested.connect(self._on_action)
self._cards[status.definition.tool_id] = card
self._list_layout.addWidget(card)
self._list_layout.addStretch()
scroll.setWidget(list_widget)
root.addWidget(scroll, stretch=1)
root.addSpacing(12)
# Back button
back_row = QHBoxLayout()
back_row.addStretch()
back_btn = QPushButton("Back to Main Menu")
back_btn.setFixedSize(160, 34)
back_btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568; color: white;
border: none; border-radius: 5px;
font-size: 12px; font-weight: bold;
}}
QPushButton:hover {{ background-color: #5a6578; }}
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
""")
back_btn.clicked.connect(self._go_back)
back_row.addWidget(back_btn)
back_row.addStretch()
root.addLayout(back_row)
# ------------------------------------------------------------------
# Version check on show
# ------------------------------------------------------------------
def showEvent(self, event):
super().showEvent(event)
try:
main_window = self.window()
if main_window:
set_responsive_minimum(main_window, min_width=960, min_height=520)
except Exception:
pass
self._start_version_check()
def _start_version_check(self):
if self._version_thread and self._version_thread.isRunning():
return
self._version_thread = _VersionCheckThread()
self._version_thread.version_ready.connect(self._on_version_ready)
self._version_thread.start()
def _on_version_ready(self, tool_id: str, tag: str):
card = self._cards.get(tool_id)
if card:
card.set_latest_version(tag)
# ------------------------------------------------------------------
# Action dispatch
# ------------------------------------------------------------------
def _on_action(self, tool_id: str, action: str):
if self._action_thread and self._action_thread.isRunning():
MessageService.information(self, "Busy", "Another operation is already running. Please wait.")
return
card = self._cards.get(tool_id)
if card:
label_map = {"install": "Installing...", "update": "Updating...",
"downgrade": "Downgrading...", "uninstall": "Removing..."}
card.set_busy(True, label_map.get(action, "Working..."))
self._action_thread = _ToolActionThread(tool_id, action)
self._action_thread.finished_signal.connect(self._on_action_finished)
self._action_thread.start()
def _on_action_finished(self, tool_id: str, success: bool, message: str):
self._action_thread = None
card = self._cards.get(tool_id)
if success:
registry = ToolRegistry()
status = registry.get_status(tool_id)
if status and status.installed and card:
card.mark_installed(status.installed_version or "")
if status.latest_version:
card.set_latest_version(status.latest_version)
elif card:
card.mark_uninstalled()
MessageService.information(self, "Done", message)
else:
if card:
card.set_busy(False)
MessageService.warning(self, "Failed", message)
# ------------------------------------------------------------------
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
self._park_all_threads()
+469
View File
@@ -0,0 +1,469 @@
"""
Tools Hub screen.
Manages independently-versioned engines and tools. On each show, the tool list
is rebuilt from the effective definitions (remote manifest if fetched, else
baked-in). A background thread fetches the manifest; if the tool list changes
the cards are rebuilt and version checks restart.
"""
import logging
from typing import Dict, List, Optional
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel,
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from jackify.backend.services.tool_registry import (
ToolDefinition, ToolRegistry, ToolStatus,
apply_remote_manifest, fetch_remote_manifest, fetch_release_list,
get_active_engine_id, get_effective_definitions,
)
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
from jackify.frontends.gui.screens.tools_hub_card import ToolCard, btn_style, section_header
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
from jackify.frontends.gui.utils import set_responsive_minimum
logger = logging.getLogger(__name__)
_C_UPDATE = "#4a5568"
_C_BACK = "#4a5568"
_C_INSTALL = "#1a5fa8"
# -- background threads ------------------------------------------------------
class _VersionCheckThread(QThread):
version_ready = Signal(str, str) # tool_id, latest_tag
def run(self):
registry = ToolRegistry()
for defn in get_effective_definitions():
try:
tag = registry.check_latest_version(defn.tool_id)
self.version_ready.emit(defn.tool_id, tag or "unknown")
except Exception as e:
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
self.version_ready.emit(defn.tool_id, "unknown")
class _ToolActionThread(QThread):
finished_signal = Signal(str, bool, str) # tool_id, success, message
def __init__(self, tool_id: str, action: str, version: Optional[str] = None):
super().__init__()
self._tool_id = tool_id
self._action = action
self._version = version
def run(self):
registry = ToolRegistry()
try:
if self._action == "install":
ok, msg = registry.install(self._tool_id, version=self._version)
elif self._action == "update":
ok, msg = registry.update(self._tool_id)
elif self._action == "uninstall":
ok, msg = registry.uninstall(self._tool_id)
else:
ok, msg = False, f"Unknown action: {self._action}"
except Exception as e:
ok, msg = False, str(e)
self.finished_signal.emit(self._tool_id, ok, msg)
class _ManifestFetchThread(QThread):
manifest_ready = Signal(list) # List[ToolDefinition]
def run(self):
result = fetch_remote_manifest()
if result:
self.manifest_ready.emit(result)
class _ReleaseFetchThread(QThread):
releases_ready = Signal(str, list) # tool_id, List[dict]
def __init__(self, tool_id: str, github_repo: str):
super().__init__()
self._tool_id = tool_id
self._github_repo = github_repo
def run(self):
releases = fetch_release_list(self._github_repo)
self.releases_ready.emit(self._tool_id, releases)
# -- main screen -------------------------------------------------------------
class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
"""Tools Hub: engine selection and third-party tool management."""
def __init__(self, stacked_widget=None, main_menu_index: int = 0, ttw_screen_index: int = 5, parent=None):
super().__init__(parent)
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.ttw_screen_index = ttw_screen_index
self._cards: Dict[str, ToolCard] = {}
self._action_thread: Optional[_ToolActionThread] = None
self._version_thread: Optional[_VersionCheckThread] = None
self._manifest_thread: Optional[_ManifestFetchThread] = None
self._release_thread: Optional[_ReleaseFetchThread] = None
self._active_engine_id = get_active_engine_id()
self._setup_ui()
def _setup_ui(self):
root = QVBoxLayout()
root.setContentsMargins(30, 24, 30, 24)
root.setSpacing(0)
self.setLayout(root)
header_row = QHBoxLayout()
self._btn_update_all = QPushButton("Update All")
self._btn_update_all.setFixedSize(100, 30)
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
self._btn_update_all.setEnabled(False)
self._btn_update_all.clicked.connect(self._on_update_all)
# Left spacer matches button width so title stays centred
left_spacer = QWidget()
left_spacer.setFixedWidth(100)
header_row.addWidget(left_spacer)
header_row.addStretch()
title = QLabel("<b>Tools Hub</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
header_row.addWidget(title)
header_row.addStretch()
header_row.addWidget(self._btn_update_all)
root.addLayout(header_row)
root.addSpacing(6)
disclaimer = QLabel(
"Some of these tools are developed and maintained by their respective authors, "
"independently of Jackify. Jackify provides download and update management "
"as a convenience only. The Jackify project offers no warranty or support "
"for third-party tools."
)
disclaimer.setWordWrap(True)
disclaimer.setStyleSheet("color: #aaa; font-size: 12px;")
root.addWidget(disclaimer)
root.addSpacing(10)
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
root.addWidget(sep)
root.addSpacing(12)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._list_widget = QWidget()
self._list_widget.setStyleSheet("background: transparent;")
self._list_layout = QVBoxLayout()
self._list_layout.setContentsMargins(0, 0, 0, 0)
self._list_layout.setSpacing(6)
self._list_widget.setLayout(self._list_layout)
scroll.setWidget(self._list_widget)
root.addWidget(scroll, stretch=1)
root.addSpacing(12)
back_row = QHBoxLayout()
back_row.addStretch()
back_btn = QPushButton("Back to Main Menu")
back_btn.setFixedSize(160, 34)
back_btn.setStyleSheet(
f"QPushButton {{ background-color: {_C_BACK}; color: white; border: none; "
f"border-radius: 6px; font-size: 12px; font-weight: bold; }}"
f"QPushButton:hover {{ background-color: #5a6578; }}"
f"QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}"
)
back_btn.clicked.connect(self._go_back)
back_row.addWidget(back_btn)
back_row.addStretch()
root.addLayout(back_row)
# card list management
def _rebuild_card_list(self):
while self._list_layout.count():
item = self._list_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self._cards.clear()
statuses = ToolRegistry().get_all_statuses()
engines = [s for s in statuses if s.definition.is_engine]
tools = [s for s in statuses if not s.definition.is_engine]
if engines:
self._list_layout.addWidget(section_header("Engine"))
self._list_layout.addSpacing(4)
for s in engines:
self._add_card(s)
self._list_layout.addSpacing(10)
if tools:
self._list_layout.addWidget(section_header("Tools"))
self._list_layout.addSpacing(4)
for s in tools:
self._add_card(s)
placeholder = QLabel("More tools coming soon")
placeholder.setAlignment(Qt.AlignCenter)
placeholder.setStyleSheet(
"color: #555; font-size: 12px; font-style: italic; "
"background-color: #222; border: 1px dashed #333; "
"border-radius: 6px; padding: 10px;"
)
self._list_layout.addWidget(placeholder)
self._list_layout.addStretch()
def _add_card(self, status: ToolStatus):
card = ToolCard(status, self._active_engine_id)
card.action_requested.connect(self._on_action)
card.engine_activated.connect(self._on_engine_activated)
self._cards[status.definition.tool_id] = card
self._list_layout.addWidget(card)
# show / manifest / version check
def showEvent(self, event):
super().showEvent(event)
try:
mw = self.window()
if mw:
set_responsive_minimum(mw, min_width=960, min_height=520)
except Exception:
pass
self._active_engine_id = get_active_engine_id()
self._rebuild_card_list()
self._start_manifest_fetch()
self._start_version_check()
def _start_manifest_fetch(self):
if self._manifest_thread and self._manifest_thread.isRunning():
return
self._manifest_thread = _ManifestFetchThread()
self._manifest_thread.manifest_ready.connect(self._on_manifest_ready)
self._manifest_thread.start()
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
current_ids = set(self._cards.keys())
new_ids = {d.tool_id for d in definitions}
apply_remote_manifest(definitions)
if current_ids != new_ids:
if self._version_thread and self._version_thread.isRunning():
self._version_thread.quit()
self._rebuild_card_list()
self._start_version_check()
def _start_version_check(self):
if self._version_thread and self._version_thread.isRunning():
return
self._version_thread = _VersionCheckThread()
self._version_thread.version_ready.connect(self._on_version_ready)
self._version_thread.start()
def _on_version_ready(self, tool_id: str, tag: str):
card = self._cards.get(tool_id)
if card:
has_update = card.set_latest_version(tag)
if has_update:
self._btn_update_all.setEnabled(True)
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE))
any_updates = any(c._status.update_available for c in self._cards.values())
main_menu = self._get_main_menu()
if main_menu:
main_menu.notify_tool_updates(any_updates)
def _get_main_menu(self):
try:
from jackify.frontends.gui.screens.main_menu import MainMenu
w = self.window()
if w and hasattr(w, 'main_menu'):
return w.main_menu
except Exception:
pass
return None
# engine activation
def _on_engine_activated(self, tool_id: str):
self._active_engine_id = tool_id
for card in self._cards.values():
card.set_active_engine(tool_id)
card = self._cards.get(tool_id)
name = card._status.definition.display_name if card else tool_id
MessageService.information(self, "Engine Changed", f"{name} is now the active engine.")
# action dispatch
def _on_action(self, tool_id: str, action: str):
if action == "launch_jackify_ui":
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.ttw_screen_index)
ttw_screen = self.stacked_widget.widget(self.ttw_screen_index)
if hasattr(ttw_screen, 'main_menu_index'):
ttw_screen.main_menu_index = self.stacked_widget.indexOf(self)
return
if self._action_thread and self._action_thread.isRunning():
MessageService.information(self, "Busy", "Another operation is running. Please wait.")
return
if action == "downgrade":
self._start_downgrade_flow(tool_id)
return
card = self._cards.get(tool_id)
if card:
label_map = {
"install": "Installing...", "update": "Updating...", "uninstall": "Removing...",
}
card.set_busy(True, label_map.get(action, "Working..."))
self._action_thread = _ToolActionThread(tool_id, action)
self._action_thread.finished_signal.connect(self._on_action_finished)
self._action_thread.start()
def _on_action_finished(self, tool_id: str, success: bool, message: str):
self._action_thread = None
card = self._cards.get(tool_id)
if success:
status = ToolRegistry().get_status(tool_id)
if status and status.installed and card:
card.mark_installed(status.installed_version or "")
if status.latest_version:
card.set_latest_version(status.latest_version)
elif card:
card.mark_uninstalled()
if message:
MessageService.information(self, "Done", message)
else:
if card:
card.set_busy(False)
MessageService.warning(self, "Failed", message)
def _on_update_all(self):
updates = [tid for tid, card in self._cards.items()
if card._status.installed and card._status.update_available]
if not updates:
return
names = ", ".join(self._cards[tid]._status.definition.display_name for tid in updates)
if MessageService.question(self, "Update All", f"Update the following tools?\n\n{names}") != QMessageBox.Yes:
return
self._pending_updates: List[str] = updates
self._run_next_update()
def _run_next_update(self):
if not self._pending_updates:
return
tool_id = self._pending_updates.pop(0)
card = self._cards.get(tool_id)
if card:
card.set_busy(True, "Updating...")
self._action_thread = _ToolActionThread(tool_id, "update")
self._action_thread.finished_signal.connect(self._on_update_all_step)
self._action_thread.start()
def _on_update_all_step(self, tool_id: str, success: bool, message: str):
self._action_thread = None
self._on_action_finished(tool_id, success, message if not success else "")
if getattr(self, "_pending_updates", []):
self._run_next_update()
else:
any_remaining = any(c._status.installed and c._status.update_available for c in self._cards.values())
self._btn_update_all.setEnabled(any_remaining)
if not any_remaining:
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
def _start_downgrade_flow(self, tool_id: str):
card = self._cards.get(tool_id)
status = ToolRegistry().get_status(tool_id)
if not status:
return
if card:
card.set_busy(True, "Fetching releases...")
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
self._release_thread.releases_ready.connect(self._on_releases_ready)
self._release_thread.start()
def _on_releases_ready(self, tool_id: str, releases: list):
card = self._cards.get(tool_id)
if card:
card.set_busy(False)
if not releases:
MessageService.warning(self, "Change Version", "Could not fetch release list from GitHub.")
return
status = ToolRegistry().get_status(tool_id)
current = status.installed_version if status else None
version = self._show_version_picker(tool_id, releases, current)
if not version:
return
if card:
card.set_busy(True, "Changing version...")
self._action_thread = _ToolActionThread(tool_id, "install", version=version)
self._action_thread.finished_signal.connect(self._on_action_finished)
self._action_thread.start()
def _show_version_picker(self, tool_id: str, releases: list, current_version: Optional[str]) -> Optional[str]:
dlg = QDialog(self.window())
dlg.setWindowTitle("Select Version")
dlg.setWindowModality(Qt.ApplicationModal)
dlg.setMinimumWidth(360)
dlg.setStyleSheet(
"QDialog { background-color: #232323; color: #e0e0e0; }"
"QLabel { color: #e0e0e0; background: transparent; border: none; }"
"QComboBox { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; "
" border-radius: 4px; padding: 4px 8px; }"
"QComboBox QAbstractItemView { background-color: #2a2a2a; color: #e0e0e0; "
" selection-background-color: #3a3a3a; }"
)
layout = QVBoxLayout(dlg)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(12)
if current_version:
lbl = QLabel(f"Currently installed: <b>{current_version}</b>")
lbl.setTextFormat(Qt.RichText)
layout.addWidget(lbl)
combo = QComboBox()
for rel in releases:
tag = rel.get("tag_name") or rel.get("name", "")
date = (rel.get("published_at") or "")[:10]
combo.addItem(f"{tag} ({date})", userData=tag)
layout.addWidget(combo)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background: #3a3a3a; border: none;")
layout.addWidget(sep)
btn_row = QHBoxLayout()
btn_row.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedSize(90, 30)
cancel_btn.setStyleSheet(btn_style(_C_BACK))
cancel_btn.clicked.connect(dlg.reject)
btn_row.addWidget(cancel_btn)
install_btn = QPushButton("Install")
install_btn.setFixedSize(90, 30)
install_btn.setStyleSheet(btn_style(_C_INSTALL))
install_btn.setDefault(True)
install_btn.clicked.connect(dlg.accept)
btn_row.addWidget(install_btn)
layout.addLayout(btn_row)
if dlg.exec() != QDialog.Accepted:
return None
return combo.currentData()
def _go_back(self):
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
self._park_all_threads()
@@ -0,0 +1,325 @@
"""
Tools Hub card widget.
Per-tool card showing status badge, version, and action buttons.
Engines show Set Active / Active badge; tools with can_launch show Launch.
"""
import logging
import subprocess
from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QDialog, QFrame, QHBoxLayout, QLabel,
QMenu, QPushButton, QSizePolicy, QVBoxLayout, QWidget,
)
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.shared_theme import JACKIFY_COLOR_BLUE
logger = logging.getLogger(__name__)
_C_INSTALL = "#1a5fa8"
_C_UPDATE = "#4a5568"
_C_LAUNCH = "#1a5fa8"
_C_SET_ACTIVE = "#4a5568"
_C_BACK = "#4a5568"
_C_DISABLED = "#333"
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
_BADGE_ACTIVE = ("#0e3d5a", JACKIFY_COLOR_BLUE)
def btn_style(colour: str, disabled: bool = False, width: int = 90) -> str:
bg = _C_DISABLED if disabled else colour
hover = "#444" if disabled else colour
return (
f"QPushButton {{ background-color: {bg}; color: {'#666' if disabled else 'white'}; "
f"border: none; border-radius: 4px; font-size: 11px; font-weight: bold; "
f"padding: 4px 8px; min-width: {width}px; }}"
f"QPushButton:hover {{ background-color: {hover}; }}"
)
def section_header(text: str) -> QLabel:
lbl = QLabel(text.upper())
lbl.setStyleSheet(
"color: #777; font-size: 10px; font-weight: bold; letter-spacing: 1px; "
"background: transparent; border: none; padding: 0;"
)
return lbl
class ToolCard(QFrame):
action_requested = Signal(str, str) # tool_id, action
engine_activated = Signal(str) # tool_id
def __init__(self, status: ToolStatus, active_engine_id: str, parent=None):
super().__init__(parent)
self._tool_id = status.definition.tool_id
self._status = status
self._active_engine_id = active_engine_id
self._busy = False
self._busy_label: Optional[str] = None
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { background-color: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }"
)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
outer = QHBoxLayout()
outer.setContentsMargins(14, 7, 14, 7)
outer.setSpacing(12)
info_col = QVBoxLayout()
info_col.setSpacing(2)
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
info_col.addWidget(self._name_label)
desc_label = QLabel(status.definition.description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
info_col.addWidget(desc_label)
info_w = QWidget()
info_w.setLayout(info_col)
info_w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
info_w.setStyleSheet("background: transparent; border: none;")
outer.addWidget(info_w, stretch=3)
centre_col = QVBoxLayout()
centre_col.setSpacing(4)
centre_col.setAlignment(Qt.AlignCenter)
self._badge = QLabel()
self._badge.setAlignment(Qt.AlignCenter)
self._badge.setFixedWidth(140)
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
self._version_label = QLabel()
self._version_label.setAlignment(Qt.AlignCenter)
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
centre_w = QWidget()
centre_w.setLayout(centre_col)
centre_w.setFixedWidth(160)
centre_w.setStyleSheet("background: transparent; border: none;")
outer.addWidget(centre_w)
btn_col = QVBoxLayout()
btn_col.setSpacing(3)
btn_col.setAlignment(Qt.AlignCenter)
self._btn_primary = QPushButton()
self._btn_primary.setFixedWidth(100)
self._btn_primary.clicked.connect(self._on_primary)
btn_col.addWidget(self._btn_primary)
self._btn_update = QPushButton("Update")
self._btn_update.setFixedWidth(100)
self._btn_update.setVisible(False)
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
btn_col.addWidget(self._btn_update)
self._btn_more = QPushButton("...")
self._btn_more.setFixedWidth(100)
self._btn_more.setStyleSheet(btn_style(_C_BACK))
self._btn_more.clicked.connect(self._on_more)
btn_col.addWidget(self._btn_more)
btn_w = QWidget()
btn_w.setLayout(btn_col)
btn_w.setFixedWidth(120)
btn_w.setStyleSheet("background: transparent; border: none;")
outer.addWidget(btn_w)
self.setLayout(outer)
self._refresh_ui()
def _refresh_ui(self):
defn = self._status.definition
installed = self._status.installed
update_avail = self._status.update_available
is_active = defn.is_engine and self._active_engine_id == self._tool_id
if self._busy:
self._badge.setText("Working...")
self._badge.setStyleSheet(
"background-color: #555; color: #ccc; border-radius: 3px; "
"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
)
self._btn_primary.setText(self._busy_label or "Working...")
self._btn_primary.setEnabled(False)
self._btn_primary.setVisible(True)
self._btn_update.setVisible(False)
self._btn_more.setEnabled(False)
return
if defn.is_engine and is_active:
bg, fg, badge_text = *_BADGE_ACTIVE, "Active Engine"
elif not installed:
bg, fg, badge_text = *_BADGE_NOT_INSTALLED, "Not Installed"
elif update_avail:
bg, fg, badge_text = *_BADGE_UPDATE_AVAIL, "Update Available"
else:
bg, fg, badge_text = *_BADGE_UP_TO_DATE, "Installed"
self._badge.setText(badge_text)
self._badge.setStyleSheet(
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
)
iv = self._status.installed_version or "-"
lv = self._status.latest_version or "checking..."
self._version_label.setText(
f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}"
)
if not installed:
self._btn_primary.setText("Install")
self._btn_primary.setStyleSheet(btn_style(_C_INSTALL))
self._btn_primary.setEnabled(not self._busy)
self._btn_primary.setVisible(True)
elif defn.is_engine:
if is_active:
self._btn_primary.setText("Active")
self._btn_primary.setStyleSheet(btn_style(_C_DISABLED, disabled=True))
self._btn_primary.setEnabled(False)
else:
self._btn_primary.setText("Set Active")
self._btn_primary.setStyleSheet(btn_style(_C_SET_ACTIVE))
self._btn_primary.setEnabled(not self._busy)
self._btn_primary.setVisible(True)
elif defn.can_launch:
self._btn_primary.setText("Launch")
self._btn_primary.setStyleSheet(btn_style(_C_LAUNCH))
self._btn_primary.setEnabled(not self._busy)
self._btn_primary.setVisible(True)
else:
self._btn_primary.setVisible(False)
self._btn_update.setVisible(installed and update_avail and not self._busy)
if installed and update_avail:
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
self._btn_more.setEnabled(not self._busy)
def set_latest_version(self, tag: str) -> bool:
self._status.latest_version = tag
if self._status.installed and self._status.installed_version and tag != "unknown":
self._status.update_available = tag.lstrip("v") != self._status.installed_version.lstrip("v")
self._refresh_ui()
return self._status.update_available
def set_active_engine(self, active_id: str):
self._active_engine_id = active_id
self._refresh_ui()
def set_busy(self, busy: bool, label: Optional[str] = None):
self._busy = busy
self._busy_label = label if busy else None
self._refresh_ui()
def mark_installed(self, version: str):
self._status.installed = True
self._status.installed_version = version
self._status.update_available = False
self._busy = False
self._busy_label = None
self._refresh_ui()
def mark_uninstalled(self):
self._status.installed = False
self._status.installed_version = None
self._status.update_available = False
self._busy = False
self._busy_label = None
self._refresh_ui()
def _prompt_uninstall(self, display_name: str):
dlg = QDialog(self.window())
dlg.setWindowTitle("Uninstall Tool")
dlg.setWindowModality(Qt.ApplicationModal)
dlg.setAttribute(Qt.WA_DeleteOnClose)
dlg.setMinimumWidth(340)
dlg.setStyleSheet(
"QDialog { background-color: #232323; color: #e0e0e0; }"
"QLabel { color: #e0e0e0; font-size: 13px; background: transparent; border: none; }"
)
layout = QVBoxLayout(dlg)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(16)
msg = QLabel(f"Uninstall <b>{display_name}</b>?<br><br>This will delete the installed files.")
msg.setTextFormat(Qt.RichText)
msg.setWordWrap(True)
layout.addWidget(msg)
sep = QLabel()
sep.setFixedHeight(1)
sep.setStyleSheet("background: #3a3a3a; border: none;")
layout.addWidget(sep)
btn_row = QHBoxLayout()
btn_row.setSpacing(8)
btn_row.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedSize(90, 30)
cancel_btn.setStyleSheet(btn_style(_C_BACK))
cancel_btn.setDefault(True)
cancel_btn.clicked.connect(dlg.reject)
btn_row.addWidget(cancel_btn)
uninstall_btn = QPushButton("Uninstall")
uninstall_btn.setFixedSize(90, 30)
uninstall_btn.setStyleSheet(btn_style("#8b2020"))
uninstall_btn.clicked.connect(dlg.accept)
btn_row.addWidget(uninstall_btn)
layout.addLayout(btn_row)
if dlg.exec() == QDialog.Accepted:
self.action_requested.emit(self._tool_id, "uninstall")
def _on_primary(self):
defn = self._status.definition
if not self._status.installed:
self.action_requested.emit(self._tool_id, "install")
elif defn.is_engine:
try:
set_active_engine_id(self._tool_id)
self.engine_activated.emit(self._tool_id)
except Exception as e:
MessageService.warning(self, "Error", str(e))
elif defn.can_launch:
if self._tool_id == "ttw_installer":
self.action_requested.emit(self._tool_id, "launch_jackify_ui")
else:
self._launch()
def _launch(self):
binary = ToolRegistry().get_binary_path(self._tool_id)
if not binary:
MessageService.warning(
self, "Not Found",
f"No executable found for {self._status.definition.display_name}. Try reinstalling it."
)
return
try:
subprocess.Popen(
[str(binary)], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception as e:
MessageService.warning(self, "Launch Failed", str(e))
def _on_more(self):
menu = QMenu(self)
menu.setStyleSheet(
"QMenu { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; }"
"QMenu::item:selected { background-color: #3a3a3a; }"
"QMenu::item:disabled { color: #555; }"
)
defn = self._status.definition
downgrade_action = menu.addAction("Change Version")
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
uninstall_action = menu.addAction("Uninstall")
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
if chosen == downgrade_action and downgrade_action.isEnabled():
self.action_requested.emit(self._tool_id, "downgrade")
elif chosen == uninstall_action and uninstall_action.isEnabled():
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
@@ -24,36 +24,12 @@ class NonFocusMessageBox(QMessageBox):
self._setup_no_focus_attributes(critical, safety_level)
def _setup_no_focus_attributes(self, critical, safety_level):
"""Configure the message box to not steal focus"""
# Set modality based on criticality and safety level
if critical or safety_level == "high":
self.setWindowModality(Qt.ApplicationModal)
elif safety_level == "medium":
self.setWindowModality(Qt.NonModal)
else:
self.setWindowModality(Qt.NonModal)
# Prevent focus stealing
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.setWindowFlags(
self.windowFlags() |
Qt.WindowStaysOnTopHint |
Qt.WindowDoesNotAcceptFocus
)
# Set focus policy to prevent taking focus
self.setFocusPolicy(Qt.NoFocus)
# Make sure child widgets don't steal focus either
for child in self.findChildren(QWidget):
child.setFocusPolicy(Qt.NoFocus)
def showEvent(self, event):
"""Override to ensure no focus stealing on show"""
super().showEvent(event)
# Ensure we don't steal focus
self.activateWindow()
self.raise_()
class SafeMessageBox(NonFocusMessageBox):
@@ -194,7 +194,10 @@ class VNVAutomationController(QObject):
logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager")
try:
import subprocess
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'])
import os
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'], env=clean_env, start_new_session=True)
except Exception:
pass
from .message_service import MessageService
+3 -5
View File
@@ -8,8 +8,6 @@ DEBUG_BORDERS = False # Enable debug borders to visualize widget boundaries
ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets')
LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png')
DISCLAIMER_TEXT = (
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
"without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. "
"The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. "
"Please back up your data and use caution."
)
"Jackify is provided as-is. Back up your modlist and game data before making changes. "
"The developers are not responsible for data loss or other issues arising from its use."
)
@@ -104,6 +104,14 @@ class OverallProgressIndicator(QWidget):
# Update status text
display_text = progress.display_text
from jackify.shared.progress_models import InstallationPhase, FileProgress
# CLF3 Status events populate progress.message with detail not captured by display_text.
# Use the message directly when display_text is just the bare phase label.
if (
progress.message
and progress.message not in ("Processing...", "")
and display_text in (progress.phase_name, progress.phase.value.title() if progress.phase else "", "Processing...", "")
):
display_text = progress.message
if not display_text or display_text == "Processing...":
if progress.phase == InstallationPhase.UNKNOWN:
# Don't overwrite the banner with "Unknown" for unrecognized section headers;
@@ -1,13 +1,15 @@
"""
Summary progress widget for phase display (e.g. Installing 123/456).
Summary progress widget for phase display (e.g. Queuing Archives 123/456).
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
from PySide6.QtCore import Qt, QTimer
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
class SummaryProgressWidget(QWidget):
"""Widget showing summary progress for phases like Installing."""
"""Single-row summary widget matching FileProgressItem layout."""
def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None):
super().__init__(parent)
@@ -16,8 +18,8 @@ class SummaryProgressWidget(QWidget):
self.max_steps = max_steps
self._target_step = current_step
self._target_max = max_steps
self._display_step = current_step
self._display_max = max_steps
self._display_step = float(current_step)
self._display_max = float(max_steps)
self._interpolation_timer = QTimer(self)
self._interpolation_timer.timeout.connect(self._interpolate_counter)
self._interpolation_timer.setInterval(16)
@@ -26,12 +28,43 @@ class SummaryProgressWidget(QWidget):
self._update_display()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(8)
op_label = QLabel("»")
op_label.setFixedWidth(20)
op_label.setAlignment(Qt.AlignCenter)
op_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;")
layout.addWidget(op_label)
self.text_label = QLabel()
self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;")
layout.addWidget(self.text_label)
self.text_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.text_label.setStyleSheet("color: #ccc; font-size: 11px;")
layout.addWidget(self.text_label, 1)
self.percent_label = QLabel()
self.percent_label.setFixedWidth(40)
self.percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.percent_label.setStyleSheet("color: #aaa; font-size: 11px;")
layout.addWidget(self.percent_label)
self.progress_bar = QProgressBar()
self.progress_bar.setFixedHeight(12)
self.progress_bar.setFixedWidth(80)
self.progress_bar.setTextVisible(False)
self.progress_bar.setStyleSheet(f"""
QProgressBar {{
border: 1px solid #444;
border-radius: 2px;
background-color: #1a1a1a;
}}
QProgressBar::chunk {{
background-color: {JACKIFY_COLOR_BLUE};
border-radius: 1px;
}}
""")
layout.addWidget(self.progress_bar)
def _interpolate_counter(self):
step_diff = self._target_step - self._display_step
@@ -53,12 +86,16 @@ class SummaryProgressWidget(QWidget):
display_max = int(round(self._display_max))
if display_max > 0:
new_text = f"{self.phase_name} ({display_step}/{display_max})"
self.text_label.setText(f"{self.phase_name} ({display_step}/{display_max})")
pct = int(display_step / display_max * 100)
if self.progress_bar.maximum() == 0:
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(pct)
self.percent_label.setText(f"{pct}%")
else:
new_text = f"{self.phase_name}"
if self.text_label.text() != new_text:
self.text_label.setText(new_text)
self.text_label.setText(self.phase_name)
self.progress_bar.setValue(0)
self.percent_label.setText("")
def update_progress(self, current_step: int, max_steps: int):
self._target_step = current_step
@@ -48,7 +48,7 @@ class UnsupportedGameDialog(QDialog):
icon_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon_label.setFixedSize(32, 32)
icon_label.setStyleSheet("color: #e67e22;")
icon_label.setStyleSheet("color: #f0c040;")
title_layout.addWidget(icon_label)
title_label = QLabel("<b>VR Platform Notice</b>" if self.vr_warning else "<b>Game Support Notice</b>")
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
@@ -73,7 +73,7 @@ class UnsupportedGameDialog(QDialog):
message_text.setStyleSheet("""
QTextEdit {
background-color: #23272e;
color: #f8f9fa;
color: #e0e0e0;
border: 1px solid #444;
border-radius: 6px;
padding: 12px;
@@ -191,10 +191,10 @@ class UnsupportedGameDialog(QDialog):
self.setStyleSheet("""
QDialog {
background-color: #23272e;
color: #f8f9fa;
color: #e0e0e0;
}
QLabel {
color: #f8f9fa;
color: #e0e0e0;
}
""")