Sync from development - prepare for v0.5.0

This commit is contained in:
Omni
2026-03-13 14:43:25 +00:00
parent 411addeea2
commit 3556914560
179 changed files with 7126 additions and 1806 deletions

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.4.0"
__version__ = "0.5.0"

View File

@@ -107,7 +107,7 @@ def get_jackify_engine_path():
logger.warning(f"AppImage engine not found at expected path: {engine_path}")
# Priority 3: Check if THIS process is actually running from Jackify AppImage
# (not just inheriting APPDIR from another AppImage like Cursor)
# (not just inheriting APPDIR from another AppImage context)
appdir = os.environ.get('APPDIR')
if appdir and sys.argv[0] and 'jackify' in sys.argv[0].lower() and '/tmp/.mount_' in sys.argv[0]:
# Only use AppImage path if we're actually running a Jackify AppImage
@@ -179,6 +179,92 @@ class ModlistInstallCLI(
# Initialize process tracking for cleanup
self._current_process = None
@staticmethod
def _normalize_version_token(value: str | None) -> str | None:
if value is None:
return None
token = str(value).strip()
if not token:
return None
return token.lstrip("vV").lower()
@staticmethod
def _normalize_modlist_name(value: str | None) -> str:
return " ".join((value or "").strip().lower().split())
def _get_requested_modlist_version(self) -> str | None:
info = self.context.get("selected_modlist_info") or {}
return self._normalize_version_token(info.get("version"))
def _evaluate_update_candidate(
self,
modlist_name: str,
install_dir: str,
existing_appid: str | None,
) -> tuple[bool, dict]:
from jackify.backend.utils.modlist_meta import read_modlist_meta
result = {
"eligible": False,
"reason": "unknown",
"requested_version": None,
"installed_version": None,
"version_relation": "unknown",
"installed_name": None,
}
if not existing_appid:
result["reason"] = "missing_shortcut_appid"
return False, result
meta = read_modlist_meta(install_dir)
if not meta:
result["reason"] = "missing_meta"
return False, result
installed_name = (meta.get("modlist_name") or "").strip()
result["installed_name"] = installed_name
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
result["reason"] = "modlist_name_mismatch"
return False, result
requested_version = self._get_requested_modlist_version()
installed_version = self._normalize_version_token(meta.get("modlist_version"))
result["requested_version"] = requested_version
result["installed_version"] = installed_version
if requested_version and installed_version:
result["version_relation"] = "same" if requested_version == installed_version else "different"
result["eligible"] = True
result["reason"] = "eligible"
return True, result
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
try:
install_real = os.path.realpath(install_dir)
candidate_exes = [
os.path.join(install_real, "ModOrganizer.exe"),
os.path.join(install_real, "files", "ModOrganizer.exe"),
]
for exe_path in candidate_exes:
if not os.path.exists(exe_path):
continue
appid = self.shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
if appid:
return appid
for shortcut in self.shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
if (
shortcut.get("AppName", "").strip() == modlist_name.strip()
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
):
raw_appid = shortcut.get("appid")
if raw_appid is not None:
return str(int(raw_appid) & 0xFFFFFFFF)
except Exception as e:
self.logger.warning("CLI update detection: failed shortcut lookup: %s", e)
return None
def cleanup(self):
"""Clean up any running jackify-engine process"""
if self._current_process and self._current_process.poll() is None:

View File

@@ -1,4 +1,5 @@
"""CLI configuration phase methods for ModlistInstallCLI (Mixin)."""
import json
import logging
import os
import subprocess
@@ -166,19 +167,81 @@ class ModlistOperationsConfigurationCLIMixin:
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
self._current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
self._current_process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=False,
env=clean_env,
cwd=engine_dir,
)
proc = self._current_process
def _write_stdin(payload: str) -> bool:
if not proc.stdin or proc.poll() is not None:
return False
try:
proc.stdin.write((payload + '\n').encode('utf-8'))
proc.stdin.flush()
return True
except Exception:
self.logger.debug("Failed writing to engine stdin", exc_info=True)
return False
buffer = b''
inline_progress_active = False
pending_manual = []
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
if chunk in (b'\n', b'\r'):
line = buffer.decode('utf-8', errors='replace')
decoded = line.rstrip('\r\n')
if decoded.startswith('{'):
try:
event = json.loads(decoded)
except (json.JSONDecodeError, ValueError):
event = None
if event:
event_name = event.get('event')
if event_name == 'manual_download_required':
pending_manual.append(event)
buffer = b''
continue
if event_name == 'manual_download_list_complete':
loop_iter = event.get('loop_iteration', 1)
for item in pending_manual:
item['loop_iteration'] = loop_iter
from jackify.backend.handlers.config_handler import ConfigHandler
raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2)
try:
manual_limit = int(raw_limit)
except (TypeError, ValueError):
manual_limit = 2
from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase
completed = run_cli_manual_download_phase(
events=list(pending_manual),
loop_iteration=loop_iter,
download_dir=actual_download_path,
stdin_write=_write_stdin,
concurrent_limit=max(1, min(5, manual_limit)),
)
if not completed:
if proc.poll() is None:
proc.terminate()
buffer = b''
break
pending_manual.clear()
buffer = b''
continue
if event_name == 'manual_download_phase_complete':
print("All manual downloads confirmed. Resuming installation...")
buffer = b''
continue
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
@@ -197,26 +260,6 @@ class ModlistOperationsConfigurationCLIMixin:
inline_progress_active = False
print(line, end='')
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in line:
parts = line.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
line = parts[0].rstrip()
else:
buffer = b''
continue
clean_line = line.rstrip('\r\n')
if clean_line.startswith("Installing files "):
print(f"\r{clean_line}", end='')
inline_progress_active = True
else:
if inline_progress_active:
print()
inline_progress_active = False
print(line, end='')
sys.stdout.flush()
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
@@ -400,6 +443,16 @@ class ModlistOperationsConfigurationCLIMixin:
app_id = None
use_automated_prefix = os.environ.get('JACKIFY_USE_AUTOMATED_PREFIX', '1') == '1'
existing_shortcut_appid = self.context.get('existing_shortcut_appid')
update_existing_install = bool(self.context.get('update_existing_install'))
if update_existing_install and existing_shortcut_appid:
app_id = str(existing_shortcut_appid)
success = True
prefix_path = None
result = True
print(f"\n{COLOR_INFO}Update mode selected. Reusing existing Steam shortcut AppID {app_id}.{COLOR_RESET}")
use_automated_prefix = False
if use_automated_prefix:
print(f"\n{COLOR_INFO}Using automated Steam setup workflow...{COLOR_RESET}")
@@ -535,17 +588,20 @@ class ModlistOperationsConfigurationCLIMixin:
success, prefix_path, app_id = True, None, None
else:
success, prefix_path, app_id = False, None, None
if success:
if success:
if update_existing_install and app_id:
print(f"{COLOR_SUCCESS}Update mode Steam setup confirmed.{COLOR_RESET}")
print(f"{COLOR_INFO}Reusing Steam AppID: {app_id}{COLOR_RESET}")
else:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
if prefix_path:
print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}")
if app_id:
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
else:
print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
@@ -572,18 +628,28 @@ class ModlistOperationsConfigurationCLIMixin:
progress_callback("")
progress_callback("=== Configuration Phase ===")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
self.logger.info("Running post-installation configuration phase using ModlistService")
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
if configuration_success:
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
self.logger.info("Post-installation configuration completed successfully")
print(f"{COLOR_INFO}Core configuration complete. Checking post-install automation...{COLOR_RESET}")
try:
# Ensure CLI install flow gets the same VNV automation behavior as GUI.
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.vnv_integration_helper import (
run_vnv_automation_if_applicable,
should_offer_vnv_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
modlist_name_for_automation = self.context.get('modlist_name') or shortcut_name or ""
def _confirm_vnv(description: str) -> bool:
@@ -593,31 +659,47 @@ class ModlistOperationsConfigurationCLIMixin:
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name_for_automation,
modlist_install_location=Path(install_dir_str),
game_root=None, # Auto-detect from modlist structure.
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
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}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
install_path = Path(install_dir_str)
if should_offer_vnv_automation(modlist_name_for_automation, install_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
vnv_service = VNVPostInstallService(
modlist_install_location=install_path,
game_root=resolved_game_root or install_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
all_vnv_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
)
if all_vnv_steps_done:
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
elif _confirm_vnv(vnv_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, vnv_error = run_vnv_automation_if_applicable(
modlist_name=modlist_name_for_automation,
modlist_install_location=install_path,
game_root=None, # Auto-detect from modlist structure.
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
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}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
else:
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
except Exception as vnv_err:
self.logger.error("VNV post-install automation failed: %s", vnv_err, exc_info=True)
print(f"{COLOR_WARNING}VNV automation could not be completed. Check logs for details.{COLOR_RESET}")
@@ -632,6 +714,7 @@ class ModlistOperationsConfigurationCLIMixin:
except Exception as ttw_err:
self.logger.error("TTW post-install prompt failed: %s", ttw_err, exc_info=True)
print(f"{COLOR_WARNING}TTW integration prompt failed. Check logs for details.{COLOR_RESET}")
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues")

View File

@@ -68,7 +68,7 @@ class ModlistOperationsConfigurationGUIMixin:
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
completion_callback(True, "Core configuration complete", config_context['name'])
return True
else:
retry_count += 1
@@ -139,7 +139,7 @@ class ModlistOperationsConfigurationGUIMixin:
if result:
if completion_callback:
completion_callback(True, "Configuration completed successfully!", config_context['name'])
completion_callback(True, "Core configuration complete", config_context['name'])
return True
else:
if progress_callback:

View File

@@ -243,6 +243,46 @@ class ModlistOperationsDiscoveryMixin:
self.context['download_dir'] = download_dir_path
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
install_dir_value = self.context.get('install_dir')
install_dir_real = os.path.realpath(str(install_dir_value[0] if isinstance(install_dir_value, tuple) else install_dir_value))
existing_appid = self._find_existing_shortcut_appid(self.context['modlist_name'], install_dir_real)
eligible_update, update_meta = self._evaluate_update_candidate(
self.context['modlist_name'],
install_dir_real,
existing_appid,
)
if eligible_update:
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Existing modlist installation detected in this directory.{COLOR_RESET}")
relation = update_meta.get("version_relation")
if relation == "different":
print(
f"{COLOR_INFO}Detected version change: installed v{update_meta.get('installed_version')} -> "
f"selected v{update_meta.get('requested_version')}.{COLOR_RESET}"
)
elif relation == "same" and update_meta.get("installed_version"):
print(
f"{COLOR_INFO}Detected same version (v{update_meta.get('installed_version')}). "
"Use update mode for repair/reconfigure behavior." + f"{COLOR_RESET}"
)
print("Choose how to proceed:")
print(" 1. Update existing install (recommended)")
print(" 2. New install with a different Steam shortcut name")
print(" 0. Cancel")
update_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if update_choice == "1":
self.context['update_existing_install'] = True
self.context['existing_shortcut_appid'] = existing_appid
self.logger.info("CLI update mode selected; reusing AppID %s", existing_appid)
elif update_choice == "2":
print(
f"{COLOR_WARNING}For a new install, choose a different Modlist Name before proceeding.{COLOR_RESET}"
)
return None
else:
self.logger.info("User cancelled at CLI update detection prompt.")
return None
if 'nexus_api_key' not in self.context or not self.context.get('nexus_api_key'):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()

View File

@@ -17,6 +17,10 @@ from typing import Optional
from .config_handler_encryption import ConfigEncryptionMixin
from .config_handler_directories import ConfigDirectoriesMixin
from .config_handler_proton import ConfigProtonMixin
from jackify.shared.steam_utils import (
STEAM_PREFERENCE_AUTO,
resolve_preferred_steam_installation,
)
logger = logging.getLogger(__name__)
@@ -50,6 +54,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"resolution": None,
"protontricks_path": None,
"steam_path": None,
"steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native
"nexus_api_key": None, # Base64 encoded API key
"default_install_parent_dir": None, # Parent directory for modlist installations
"default_download_parent_dir": None, # Parent directory for downloads
@@ -62,6 +67,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
"proton_version": None, # Install Proton version name - None means auto-detect
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs
"manual_download_watch_directory": None, # Optional override for manual-download watcher folder
"window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing)
}
@@ -72,14 +79,13 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
# Perform version migrations
self._migrate_config()
# Normalize/repair Proton selections on every startup so stale deleted versions
# cannot break workflows.
self.normalize_proton_paths_on_boot()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
if not self.settings.get("jackify_data_dir"):
@@ -95,35 +101,16 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
str: Path to the Steam installation or None if not found
"""
logger.info("Detecting Steam installation path...")
# Common Steam installation paths
steam_paths = [
os.path.expanduser("~/.steam/steam"),
os.path.expanduser("~/.local/share/Steam"),
os.path.expanduser("~/.steam/root")
]
# Check each path
for path in steam_paths:
if os.path.exists(path):
logger.info(f"Found Steam installation at: {path}")
return path
# If not found in common locations, try to find using libraryfolders.vdf
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak
]
for vdf_path in libraryfolders_vdf_paths:
if os.path.exists(vdf_path):
# Extract the Steam path from the libraryfolders.vdf path
steam_path = os.path.dirname(os.path.dirname(vdf_path))
logger.info(f"Found Steam installation at: {steam_path}")
return steam_path
preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO)
install_type, install_root = resolve_preferred_steam_installation(preference=preference)
if install_root:
logger.info(
"Selected Steam installation: %s (%s)",
install_type,
install_root,
)
return str(install_root)
logger.error("Steam installation not found")
return None
@@ -376,4 +363,4 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM

View File

@@ -3,6 +3,8 @@ Config handler Proton path and version getters and auto-detect.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
@@ -10,6 +12,105 @@ logger = logging.getLogger(__name__)
class ConfigProtonMixin:
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
@staticmethod
def _is_usable_proton_path(proton_path: Optional[str]) -> bool:
"""Return True when path looks like a valid Proton install directory."""
if not proton_path:
return False
try:
p = Path(str(proton_path)).expanduser()
if not p.is_dir():
return False
# Valve Proton structure
if (p / "dist" / "bin" / "wine").exists():
return True
# GE-Proton structure
if (p / "files" / "bin" / "wine").exists():
return True
return False
except Exception:
return False
@staticmethod
def _best_proton_entry() -> Optional[Dict[str, Any]]:
"""Get best detected Proton entry or None."""
try:
from .wine_utils import WineUtils
return WineUtils.select_best_proton()
except Exception:
return None
def normalize_proton_paths_on_boot(self) -> bool:
"""
Ensure stored Proton paths are valid at startup, repairing stale selections.
Rules:
- If install proton path is missing/invalid, auto-detect next best and persist it.
- If no compatible Proton exists, persist install path/version as null.
- If game proton path is set and invalid, reset it to install proton (or null).
Returns:
True if config values were changed and saved, False otherwise.
"""
changed = False
install_path = self.settings.get("proton_path")
if install_path == "auto":
install_path = None
install_valid = self._is_usable_proton_path(install_path)
if not install_valid:
best = self._best_proton_entry()
if best:
best_path = str(best["path"])
best_name = str(best.get("name") or Path(best_path).name)
if self.settings.get("proton_path") != best_path:
self.settings["proton_path"] = best_path
changed = True
if self.settings.get("proton_version") != best_name:
self.settings["proton_version"] = best_name
changed = True
logger.warning(
"Install Proton path was missing/invalid; auto-selected %s (%s)",
best_name,
best_path,
)
else:
if self.settings.get("proton_path") is not None:
self.settings["proton_path"] = None
changed = True
if self.settings.get("proton_version") is not None:
self.settings["proton_version"] = None
changed = True
logger.warning(
"Install Proton path was missing/invalid and no compatible Proton was found"
)
else:
# Keep proton_version in sync with existing valid path when missing/legacy.
if not self.settings.get("proton_version"):
self.settings["proton_version"] = Path(str(install_path)).name
changed = True
effective_install = self.settings.get("proton_path")
game_path = self.settings.get("game_proton_path")
# Legacy/placeholder values should not persist for runtime resolution.
if game_path in ("same_as_install", "auto"):
target = effective_install
if self.settings.get("game_proton_path") != target:
self.settings["game_proton_path"] = target
changed = True
elif game_path and not self._is_usable_proton_path(game_path):
self.settings["game_proton_path"] = effective_install
changed = True
logger.warning(
"Game Proton path was missing/invalid; reset to install Proton path"
)
if changed:
self.save_config()
return changed
def get_proton_path(self):
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
try:

View File

@@ -279,46 +279,56 @@ class ModlistMenuHandler:
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
# Run the automated workflow
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
# Handle the result
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Handle conflict - ask user what to do
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Create new shortcut anyway")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
# Use existing shortcut
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
elif choice == "2":
# Create new shortcut - would need to handle this, but for now just fail
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
return True
else:
while True:
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Choose a different shortcut name")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
print(f"{COLOR_ERROR}Could not determine existing shortcut AppID.{COLOR_RESET}")
return True
if choice == "2":
print("")
print(f"{COLOR_PROMPT}Enter a different shortcut name for this modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}(Current conflicting name: {modlist_name}){COLOR_RESET}")
new_name = input(f"{COLOR_PROMPT}New shortcut name (or 'q' to cancel): {COLOR_RESET}").strip()
if new_name.lower() == 'q':
print(f"{COLOR_INFO}Configuration cancelled by user.{COLOR_RESET}")
return True
if not new_name:
print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}")
continue
if new_name == modlist_name:
print(f"{COLOR_ERROR}Please enter a different name to resolve the conflict.{COLOR_RESET}")
continue
modlist_name = new_name
print(f"{COLOR_INFO}Retrying Steam setup with shortcut name: {modlist_name}{COLOR_RESET}")
continue
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
return True
else:
# Success - get the results
success, prefix_path, appid_int, last_timestamp = result
if success and appid_int:
context = {
@@ -330,10 +340,9 @@ class ModlistMenuHandler:
}
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
return self.run_modlist_configuration_phase(context)
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
# Unexpected result format
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
self.logger.error(f"Unexpected result format from automated workflow: {result}")
@@ -566,8 +575,18 @@ class ModlistMenuHandler:
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
# Only in CLI mode - GUI handles this in install_modlist.py
if not gui_mode:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.vnv_integration_helper import (
run_vnv_automation_if_applicable,
should_offer_vnv_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
from pathlib import Path
modlist_name = context.get('name', '')
@@ -581,33 +600,46 @@ class ModlistMenuHandler:
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
confirmation_callback=_confirm_vnv
)
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
if should_offer_vnv_automation(modlist_name, modlist_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
vnv_service = VNVPostInstallService(
modlist_install_location=modlist_path,
game_root=resolved_game_root or modlist_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
all_vnv_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
)
if all_vnv_steps_done:
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
elif _confirm_vnv(vnv_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
else:
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
except Exception as e:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable

View File

@@ -401,6 +401,18 @@ class ModlistConfigurationMixin:
else:
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
# This is intentionally separate from broad binary-path rewriting so it still runs when
# engine-installed workflows skip edit_binary_working_paths.
if not self.path_handler.align_home_path_basis(
modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj,
modlist_sdcard=self.modlist_sdcard,
):
self.logger.error("Failed to align home-path basis in ModOrganizer.ini. Configuration aborted.")
self.logger.error("Failed to align /home path basis in ModOrganizer.ini.")
return False
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
# Step 9: Update Resolution Settings (if applicable)
@@ -539,6 +551,9 @@ class ModlistConfigurationMixin:
else:
self.logger.debug("Step 13: No special launch options needed for this modlist type")
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Finalizing post-install configuration")
# Do not call status_callback here, the final message is handled in menu_handler
# if status_callback:
# status_callback("Configuration completed successfully!")
@@ -546,6 +561,8 @@ class ModlistConfigurationMixin:
self.logger.info("Configuration steps completed successfully.")
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
self._re_enforce_windows_10_mode()
return True # Return True on success
@@ -581,4 +598,3 @@ class ModlistConfigurationMixin:
else:
self.selected_resolution = None
self.logger.info("Resolution setup skipped by user.")

View File

@@ -7,6 +7,7 @@ import shutil
from pathlib import Path
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
from jackify.shared.paths import get_jackify_logs_dir
logger = logging.getLogger(__name__)
@@ -352,10 +353,16 @@ class ModlistInstallCLITTWMixin:
print(f"\nTTW has been installed to: {ttw_output_path}")
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
print(f"Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

View File

@@ -28,6 +28,95 @@ SDCARD_PREFIX = '/run/media/mmcblk0p1/'
class PathHandlerMO2Mixin:
"""Mixin providing ModOrganizer.ini path updates and formatting."""
@staticmethod
def _desired_home_basis_from_modlist_dir(modlist_dir_path: Path) -> Optional[str]:
"""
Determine desired Linux home-path basis from modlist install directory.
Returns:
"/var/home" when modlist dir is under /var/home,
"/home" when modlist dir is under /home,
None otherwise.
"""
try:
posix = modlist_dir_path.as_posix()
except Exception:
posix = str(modlist_dir_path).replace("\\", "/")
if posix.startswith("/var/home/"):
return "/var/home"
if posix.startswith("/home/"):
return "/home"
return None
@staticmethod
def _rewrite_z_home_basis_in_line(line: str, desired_home_basis: str) -> str:
"""
Rewrite only Z:-drive /home -> /var/home path basis in a single INI line.
Preserves slash style (forward or backslash), and leaves D: paths untouched.
"""
if desired_home_basis == "/var/home":
# Z:/home/... -> Z:/var/home/...
# Z:\\home\\... -> Z:\\var\\home\\...
return re.sub(r'([Zz]:[/\\]+)home([/\\]+)', r'\1var\2home\2', line)
return line
def align_home_path_basis(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool) -> bool:
"""
Align gamePath/binary/workingDirectory home-path basis to modlist_dir_path.
This is a targeted post-processing step for Z: paths only:
- If install path is /var/home/... then rewrite Z:/home/... to Z:/var/home/...
- Otherwise do nothing.
"""
if modlist_sdcard:
return True
desired_home_basis = self._desired_home_basis_from_modlist_dir(modlist_dir_path)
# This alignment pass is intentionally one-way:
# only promote Z:/home -> Z:/var/home when install dir uses /var/home.
if desired_home_basis != "/var/home":
return True
if not modlist_ini_path.is_file():
logger.error(f"INI file {modlist_ini_path} does not exist for home-basis alignment")
return False
try:
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
changed = 0
for i, line in enumerate(lines):
stripped = line.strip()
if not (
re.match(r'^\s*gamepath\s*=.*$', stripped, re.IGNORECASE)
or re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
or re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
):
continue
rewritten = self._rewrite_z_home_basis_in_line(line, desired_home_basis)
if rewritten != line:
lines[i] = rewritten
changed += 1
if changed > 0:
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(
"Aligned ModOrganizer.ini home-path basis to %s for %d line(s): %s",
desired_home_basis,
changed,
modlist_ini_path,
)
else:
logger.debug(
"No home-path basis alignment needed for %s (target %s)",
modlist_ini_path,
desired_home_basis,
)
return True
except Exception as e:
logger.error(f"Error aligning home path basis in {modlist_ini_path}: {e}")
return False
@staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
"""Removes SD card mount prefix. Returns path as POSIX-style string."""

View File

@@ -12,6 +12,10 @@ from pathlib import Path
from typing import Optional, List
from datetime import datetime
import vdf
from jackify.shared.steam_utils import (
get_ordered_steam_roots,
STEAM_PREFERENCE_AUTO,
)
logger = logging.getLogger(__name__)
@@ -23,11 +27,7 @@ class PathHandlerSteamMixin:
def find_steam_config_vdf() -> Optional[Path]:
"""Finds the active Steam config.vdf file."""
logger.debug("Searching for Steam config.vdf...")
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
possible_steam_paths = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
@@ -40,10 +40,9 @@ class PathHandlerSteamMixin:
def find_steam_library() -> Optional[Path]:
"""Find the primary Steam library common directory containing games."""
logger.debug("Attempting to find Steam library...")
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
str(root / "config" / "libraryfolders.vdf") for root in ordered_roots
]
for path in libraryfolders_vdf_paths:
if os.path.exists(path):
@@ -92,14 +91,11 @@ class PathHandlerSteamMixin:
logger.info(f"Using Steam library common path: {library_paths[0]}")
return library_paths[0]
logger.debug("No valid common paths found in VDF, checking default location...")
default_common_path = Path.home() / ".steam/steam/steamapps/common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
if default_common_path_local.is_dir():
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
return default_common_path_local
for root in ordered_roots:
default_common_path = root / "steamapps" / "common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
logger.error("No valid Steam library common path found in VDF or default locations.")
return None
except Exception as e:
@@ -181,12 +177,8 @@ class PathHandlerSteamMixin:
def get_all_steam_library_paths() -> List[Path]:
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
vdf_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".steam/root/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf",
]
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
vdf_paths = [root / "config" / "libraryfolders.vdf" for root in ordered_roots]
library_paths = set()
for vdf_path in vdf_paths:
if vdf_path.is_file():

View File

@@ -6,6 +6,7 @@ import resource
import sys
import shutil
import logging
import threading
def get_safe_python_executable():
"""
@@ -154,7 +155,7 @@ class ProcessManager:
"""
Shared process manager for robust subprocess launching, tracking, and cancellation.
"""
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False):
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
self.cmd = cmd
# Default to cleaned environment if None to prevent AppImage variable inheritance
if env is None:
@@ -165,14 +166,18 @@ class ProcessManager:
self.text = text
self.bufsize = bufsize
self.separate_stderr = separate_stderr
self.enable_stdin = enable_stdin
self.proc = None
self.process_group_pid = None
self._stdin_lock = threading.Lock()
self._start_process()
def _start_process(self):
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
stdin_arg = subprocess.PIPE if self.enable_stdin else None
self.proc = subprocess.Popen(
self.cmd,
stdin=stdin_arg,
stdout=subprocess.PIPE,
stderr=stderr_arg,
env=self.env,
@@ -190,31 +195,45 @@ class ProcessManager:
cleanup_attempts = 0
try:
if self.proc:
# Terminate process group first so child tools don't survive parent exit.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGTERM)
except Exception:
pass
try:
self.proc.terminate()
try:
self.proc.wait(timeout=timeout_terminate)
return
except subprocess.TimeoutExpired:
pass
except Exception:
pass
try:
self.proc.kill()
try:
self.proc.wait(timeout=timeout_kill)
return
except subprocess.TimeoutExpired:
pass
self.proc.wait(timeout=timeout_terminate)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Kill entire process group (catches 7zz and other child processes)
# Escalate to SIGKILL for stubborn children/process group.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGKILL)
except Exception:
pass
# Last resort: pkill by command name
try:
self.proc.kill()
except Exception:
pass
try:
self.proc.wait(timeout=timeout_kill)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Last resort: pkill by command name (kept bounded).
while cleanup_attempts < max_cleanup_attempts:
try:
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
@@ -224,7 +243,7 @@ class ProcessManager:
finally:
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
if self.proc:
for pipe in (self.proc.stdout, self.proc.stderr):
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if pipe:
try:
pipe.close()
@@ -250,4 +269,20 @@ class ProcessManager:
return self.proc.stdout.read(1)
except (ValueError, OSError):
return None
return None
return None
def write_stdin(self, line: str) -> bool:
"""
Write a line to the process stdin. Thread-safe.
Returns True on success, False if stdin is not available or process is gone.
"""
if not self.enable_stdin or not self.proc or not self.proc.stdin:
return False
with self._stdin_lock:
try:
payload = line if line.endswith('\n') else line + '\n'
self.proc.stdin.write(payload.encode())
self.proc.stdin.flush()
return True
except (OSError, BrokenPipeError):
return False

View File

@@ -64,17 +64,29 @@ class TTWInstallerBackendMixin:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
error_context: list = []
capturing_explanation = False
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info("TTW_Linux_Installer: %s", line)
lower = line.lower()
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
error_context.append(line.strip())
capturing_explanation = True
elif capturing_explanation and line.startswith(' '):
error_context.append(line.strip())
else:
capturing_explanation = False
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
if error_context:
return False, "TTW installation failed:\n" + "\n".join(error_context)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
@@ -210,6 +222,8 @@ class TTWInstallerBackendMixin:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
error_context: list = []
capturing_explanation = False
if process.stdout:
for line in process.stdout:
line = line.rstrip()
@@ -217,12 +231,22 @@ class TTWInstallerBackendMixin:
self.logger.info("TTW_Linux_Installer: %s", line)
if output_callback:
output_callback(line)
lower = line.lower()
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
error_context.append(line.strip())
capturing_explanation = True
elif capturing_explanation and line.startswith(' '):
error_context.append(line.strip())
else:
capturing_explanation = False
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
if error_context:
return False, "TTW installation failed:\n" + "\n".join(error_context)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)

View File

@@ -269,10 +269,8 @@ exit"""
def get_ttw_installer_path() -> Optional[Path]:
"""Get path to TTW_Linux_Installer if available"""
try:
from jackify.shared.paths import get_jackify_data_dir
ttw_path = get_jackify_data_dir() / "TTW_Linux_Installer" / "ttw_linux_gui"
if ttw_path.exists():
return ttw_path
from .ttw_installer_service import get_ttw_installer_path
return get_ttw_installer_path()
except Exception:
pass
return None
@@ -405,4 +403,3 @@ exit"""
return prefix_dir
else:
return None

View File

@@ -47,11 +47,19 @@ class WorkflowMixin:
startdir_matches = shortcut_startdir == modlist_install_dir
if (name_matches and (exe_matches or startdir_matches)):
raw_appid = shortcut.get('appid')
normalized_appid = None
if raw_appid is not None:
try:
normalized_appid = str(int(raw_appid) & 0xFFFFFFFF)
except Exception:
normalized_appid = str(raw_appid)
conflicts.append({
'index': i,
'name': name,
'exe': shortcut_exe,
'startdir': shortcut_startdir
'startdir': shortcut_startdir,
'appid': normalized_appid,
})
if conflicts:
@@ -124,42 +132,59 @@ class WorkflowMixin:
Tuple of (success, prefix_path, appid, last_timestamp)
"""
logger.info("Starting proven working automated prefix creation workflow")
# Show installation complete and configuration start headers FIRST
if progress_callback:
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Installation phase complete =")
progress_callback("=" * 64)
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Starting Configuration Phase =")
progress_callback("=" * 64)
progress_callback("")
# Reset timing for Steam Integration section (part of Configuration Phase)
from jackify.shared.timing import start_new_phase
start_new_phase()
# Show immediate feedback to user with section header
if progress_callback:
progress_callback("") # Blank line before Steam Integration
progress_callback("=== Steam Integration ===")
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
# Registry injection approach for both FNV and Enderal
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
# No launch options needed - FNV, FO3 and Enderal use registry injection
custom_launch_options = None
if special_game_type in ["fnv", "fo3", "enderal"]:
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
else:
logger.debug("Standard modlist - no special game handling needed")
try:
conflict_result = self.handle_existing_shortcut_conflict(
shortcut_name,
final_exe_path,
modlist_install_dir,
)
if isinstance(conflict_result, list):
logger.warning(
"Found %d existing shortcut(s) with same name and path before Steam integration",
len(conflict_result),
)
return ("CONFLICT", conflict_result, None, None)
if conflict_result is False:
logger.error("User cancelled due to shortcut conflict")
return False, None, None, None
# Show installation complete and configuration start headers only after
# conflict checks pass, so users do not see Steam integration start
# messages when Jackify is about to stop for duplicate-shortcut review.
if progress_callback:
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Installation phase complete =")
progress_callback("=" * 64)
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Starting Configuration Phase =")
progress_callback("=" * 64)
progress_callback("")
# Reset timing for Steam Integration section (part of Configuration Phase)
from jackify.shared.timing import start_new_phase
start_new_phase()
# Show immediate feedback to user with section header
if progress_callback:
progress_callback("") # Blank line before Steam Integration
progress_callback("=== Steam Integration ===")
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
# Registry injection approach for both FNV and Enderal
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
# No launch options needed - FNV, FO3 and Enderal use registry injection
custom_launch_options = None
if special_game_type in ["fnv", "fo3", "enderal"]:
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
else:
logger.debug("Standard modlist - no special game handling needed")
# Step 0: Shut down Steam before modifying VDF files
# Required to safely modify shortcuts.vdf and config.vdf without race conditions
logger.info("Step 0: Shutting down Steam before modifying VDF files")
@@ -179,22 +204,6 @@ class WorkflowMixin:
# Step 1: Create shortcut with native Steam service (Steam is now shut down)
logger.info("Step 1: Creating shortcut with native Steam service")
# DISABLED: Shortcut conflict detection temporarily disabled pending rework
# Re-enable after conflict resolution workflow refactor
# When re-enabled, this will detect and handle cases where shortcuts with the same
# name and path already exist in Steam, allowing users to resolve conflicts
# Disabled pending workflow improvements - planned for future release
# conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir)
# if isinstance(conflict_result, list): # Conflicts found
# logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path")
# # Return a special tuple to indicate conflict that needs user resolution
# return ("CONFLICT", conflict_result, None)
# elif not conflict_result: # User cancelled or other failure
# logger.error("User cancelled due to shortcut conflict")
# return False, None, None, None
logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation")
# Create shortcut using native Steam service with special game launch options
success, appid = self.create_shortcut_with_native_service(
shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options, download_dir=download_dir
@@ -387,4 +396,3 @@ class WorkflowMixin:
if progress_callback:
progress_callback(f"Error: {str(e)}")
return False, None, None, None

View File

@@ -0,0 +1,138 @@
"""
Watches a directory for newly downloaded files and matches them against a
list of pending manual download items by lax filename comparison.
"""
import os
import time
import logging
from dataclasses import dataclass, field
from pathlib import Path
from threading import Thread, Event
from typing import Callable, Optional
logger = logging.getLogger(__name__)
@dataclass
class WatcherConfig:
watch_directory: Path
watch_recursive: bool = False
debounce_seconds: float = 2.0
additional_dirs: list = field(default_factory=list)
class DownloadWatcherService:
"""
Monitors a directory for files that match pending download items.
Caller sets pending_items (list of dicts with at least 'file_name') and
registers an on_candidate callback that receives (Path, dict) when a
potential match is detected (after debounce, before hash validation).
"""
def __init__(self, config: WatcherConfig, on_candidate: Callable[[Path, dict], None]):
self._config = config
self._on_candidate = on_candidate
self._pending_items: list[dict] = []
self._pending_exact: list[tuple[str, dict]] = []
self._stop_event = Event()
self._thread: Optional[Thread] = None
# Track known files so we only react to new/changed ones
self._known: dict[Path, float] = {}
def set_pending_items(self, items: list[dict]) -> None:
"""Replace the pending items list. Thread-safe for simple list swap."""
self._pending_items = list(items)
self._pending_exact = [
(str(item.get('file_name', '')).lower(), item)
for item in self._pending_items
if item.get('file_name')
]
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = Thread(target=self._watch_loop, daemon=True, name='DownloadWatcher')
self._thread.start()
logger.debug(f"Download watcher started on: {self._config.watch_directory}")
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
logger.debug("Download watcher stopped")
def _all_watch_dirs(self) -> list[Path]:
dirs = [self._config.watch_directory]
dirs.extend(self._config.additional_dirs)
return [d for d in dirs if d.is_dir()]
def _scan(self) -> None:
for watch_dir in self._all_watch_dirs():
try:
entries = list(watch_dir.iterdir()) if not self._config.watch_recursive else \
[p for p in watch_dir.rglob('*') if p.is_file()]
for path in entries:
if not path.is_file():
continue
# Skip browser temp files
if path.suffix in ('.part', '.crdownload', '.tmp'):
continue
try:
mtime = path.stat().st_mtime
except OSError:
continue
prev_mtime = self._known.get(path)
if prev_mtime == mtime:
continue
self._known[path] = mtime
self._check_candidate(path)
except OSError as e:
logger.debug(f"Watcher scan error on {watch_dir}: {e}")
def _check_candidate(self, path: Path) -> None:
candidate_name = path.name.lower()
# Exact filename match (case-insensitive).
for expected_name, item in self._pending_exact:
if expected_name == candidate_name:
logger.debug(f"Candidate exact match: {path.name}")
self._debounce_and_emit(path, item)
return
# Some modlist metadata stores filenames with a leading dot that browsers
# strip when saving the download. Match against the stripped expected name.
for expected_name, item in self._pending_exact:
if expected_name.lstrip('.') == candidate_name:
logger.debug(f"Candidate dot-normalized match: {path.name} -> {expected_name}")
self._debounce_and_emit(path, item)
return
def _debounce_and_emit(self, path: Path, item: dict) -> None:
def _wait_and_emit():
prev_size = -1
stable_count = 0
needed = max(1, int(self._config.debounce_seconds / 0.5))
for _ in range(needed * 4): # max ~2× debounce time
if self._stop_event.is_set():
return
time.sleep(0.5)
try:
size = path.stat().st_size
except OSError:
return
if size == prev_size:
stable_count += 1
if stable_count >= needed:
break
else:
stable_count = 0
prev_size = size
if path.exists():
self._on_candidate(path, item)
Thread(target=_wait_and_emit, daemon=True, name=f'Debounce-{path.name[:20]}').start()
def _watch_loop(self) -> None:
while not self._stop_event.is_set():
self._scan()
self._stop_event.wait(timeout=1.0)

View File

@@ -0,0 +1,261 @@
"""
Hash validation and file move for manually downloaded archives.
Uses xxhash64 to match the engine's hash format exactly.
"""
import struct
import shutil
import logging
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# xxhash produces 16-char lowercase hex with no prefix - matches engine Hash.ToHex()
# C extension is ABI-locked to the Python version it was compiled against, so
# AppImage builds need a pure-Python fallback for cross-version compatibility.
try:
import xxhash
_XXHASH_IMPL = 'native'
except ImportError:
xxhash = None
_XXHASH_IMPL = 'fallback'
logger.info("xxhash C extension not available, using pure-Python fallback")
class _XXH64Fallback:
"""Pure-Python xxhash64 implementation for when the C extension can't load.
Reference: https://github.com/Cyan4973/xxHash/blob/dev/doc/xxhash_spec.md"""
_P1 = 11400714785074694791
_P2 = 14029467366897019727
_P3 = 1609587929392839161
_P4 = 9650029242287828579
_P5 = 2870177450012600261
_M64 = 0xFFFFFFFFFFFFFFFF
def __init__(self, seed: int = 0):
self._seed = seed & self._M64
self._total_len = 0
self._buf = b""
self._v1 = (seed + self._P1 + self._P2) & self._M64
self._v2 = (seed + self._P2) & self._M64
self._v3 = seed & self._M64
self._v4 = (seed - self._P1) & self._M64
@staticmethod
def _rotl64(x: int, r: int) -> int:
return ((x << r) | (x >> (64 - r))) & 0xFFFFFFFFFFFFFFFF
def _round(self, acc: int, inp: int) -> int:
acc = (acc + inp * self._P2) & self._M64
acc = self._rotl64(acc, 31)
acc = (acc * self._P1) & self._M64
return acc
def _merge_round(self, acc: int, val: int) -> int:
val = self._round(0, val)
acc ^= val
acc = (acc * self._P1 + self._P4) & self._M64
return acc
def update(self, data: bytes) -> None:
self._buf += data
self._total_len += len(data)
if len(self._buf) < 32:
return
p = 0
end = len(self._buf) - 31 # process 32-byte blocks
while p < end:
self._v1 = self._round(self._v1, struct.unpack_from('<Q', self._buf, p)[0])
self._v2 = self._round(self._v2, struct.unpack_from('<Q', self._buf, p + 8)[0])
self._v3 = self._round(self._v3, struct.unpack_from('<Q', self._buf, p + 16)[0])
self._v4 = self._round(self._v4, struct.unpack_from('<Q', self._buf, p + 24)[0])
p += 32
self._buf = self._buf[p:]
def hexdigest(self) -> str:
return format(self._digest(), '016x')
def _digest(self) -> int:
M = self._M64
if self._total_len >= 32:
h = self._rotl64(self._v1, 1)
h = (h + self._rotl64(self._v2, 7)) & M
h = (h + self._rotl64(self._v3, 12)) & M
h = (h + self._rotl64(self._v4, 18)) & M
h = self._merge_round(h, self._v1)
h = self._merge_round(h, self._v2)
h = self._merge_round(h, self._v3)
h = self._merge_round(h, self._v4)
else:
h = (self._seed + self._P5) & M
h = (h + self._total_len) & M
buf = self._buf
p = 0
remaining = len(buf)
while remaining >= 8:
k1 = struct.unpack_from('<Q', buf, p)[0]
k1 = self._round(0, k1)
h ^= k1
h = (self._rotl64(h, 27) * self._P1 + self._P4) & M
p += 8
remaining -= 8
while remaining >= 4:
k1 = struct.unpack_from('<I', buf, p)[0]
h ^= (k1 * self._P1) & M
h = (self._rotl64(h, 23) * self._P2 + self._P3) & M
p += 4
remaining -= 4
while remaining > 0:
h ^= (buf[p] * self._P5) & M
h = (self._rotl64(h, 11) * self._P1) & M
p += 1
remaining -= 1
# Avalanche
h ^= h >> 33
h = (h * self._P2) & M
h ^= h >> 29
h = (h * self._P3) & M
h ^= h >> 32
return h
_CHUNK = 1024 * 1024 # 1 MB
def _reverse_hex_byte_order(hex_value: str) -> str:
"""Reverse byte order of a hex string (e.g. aabbccdd -> ddccbbaa)."""
value = (hex_value or "").strip().lower()
if len(value) % 2 != 0:
return value
return "".join(reversed([value[i:i + 2] for i in range(0, len(value), 2)]))
def _hash_matches_expected(computed_hash: str, expected_hash: str) -> bool:
"""Accept either canonical or byte-reversed xxhash64 representations."""
computed = (computed_hash or "").strip().lower()
expected = (expected_hash or "").strip().lower()
if not computed or not expected:
return False
return computed == expected or _reverse_hex_byte_order(computed) == expected
@dataclass
class ValidationResult:
matches: bool
computed_hash: Optional[str]
file_path: Path
error: Optional[str] = None
class FileValidatorService:
"""
Validates downloaded files against expected xxhash64 and moves them to
the modlist downloads directory on success.
"""
def __init__(self, max_workers: int = 2):
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='FileValidator')
def validate_async(
self,
file_path: Path,
expected_hash: str,
modlist_download_dir: Path,
on_result: Callable[[ValidationResult, Optional[Path]], None],
dest_name: Optional[str] = None,
) -> None:
"""
Validate file_path against expected_hash in a thread pool worker.
on_result(result, dest_path) is called on the worker thread when done.
dest_path is the moved file location if validation succeeded, else None.
dest_name overrides the destination filename (used when the engine's
canonical name differs from the downloaded file's name, e.g. leading dot).
"""
self._executor.submit(
self._validate_and_move,
file_path, expected_hash, modlist_download_dir, on_result, dest_name
)
def _validate_and_move(
self,
file_path: Path,
expected_hash: str,
modlist_download_dir: Path,
on_result: Callable,
dest_name: Optional[str] = None,
) -> None:
result = self._validate(file_path, expected_hash)
dest: Optional[Path] = None
if result.matches:
try:
dest = self._move_file(file_path, modlist_download_dir, dest_name=dest_name)
logger.info(
"[MDL-1026] Archive move complete | "
f"source_path={file_path} destination_path={dest} hash={result.computed_hash or 'missing'}"
)
except OSError as e:
logger.warning(
"[MDL-9020] Archive move failed after hash validation | "
f"source_path={file_path} destination_dir={modlist_download_dir} reason={e}"
)
result = ValidationResult(
matches=False,
computed_hash=result.computed_hash,
file_path=file_path,
error=f"Move failed: {e}",
)
on_result(result, dest)
def _validate(self, file_path: Path, expected_hash: str) -> ValidationResult:
try:
# No expected hash — accept by filename match alone, just move the file.
if not (expected_hash or "").strip():
return ValidationResult(matches=True, computed_hash=None, file_path=file_path)
h = xxhash.xxh64() if xxhash else _XXH64Fallback()
with open(file_path, 'rb') as f:
while True:
chunk = f.read(_CHUNK)
if not chunk:
break
h.update(chunk)
computed = h.hexdigest().lower() # 16-char lowercase hex, no prefix
matches = _hash_matches_expected(computed, expected_hash)
return ValidationResult(
matches=matches,
computed_hash=computed,
file_path=file_path,
)
except OSError as e:
return ValidationResult(matches=False, computed_hash=None, file_path=file_path,
error=str(e))
def _move_file(self, source: Path, dest_dir: Path, dest_name: Optional[str] = None) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / (dest_name if dest_name else source.name)
# If the watched file is already in the modlist downloads directory,
# treat it as in-place and avoid a same-path move error.
try:
if source.resolve() == dest.resolve():
logger.debug(f"Validated file already in modlist downloads directory: {source}")
return dest
except OSError:
pass
shutil.move(str(source), str(dest))
logger.debug(f"Moved validated file: {source.name} -> {dest}")
return dest
def shutdown(self) -> None:
self._executor.shutdown(wait=False)

View File

@@ -0,0 +1,124 @@
"""
Orchestrates the manual download workflow:
- Maintains queue of pending items
- Opens browser tabs (sliding window, N concurrent)
- Coordinates directory watcher and file validator
- Sends continue command to engine when all items are done
"""
import logging
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Literal, Optional
from jackify.backend.services.download_watcher_service import DownloadWatcherService, WatcherConfig
from jackify.backend.services.file_validator_service import FileValidatorService
from jackify.backend.services.manual_download_manager_api_mixin import ManualDownloadManagerApiMixin
from jackify.backend.services.manual_download_manager_runtime_mixin import ManualDownloadManagerRuntimeMixin
logger = logging.getLogger(__name__)
STATUS = Literal["pending", "browser_opened", "validating", "complete", "deferred", "skipped", "error"]
_STATE_FILE = Path.home() / '.local' / 'share' / 'jackify' / 'manual_download_state.json'
@dataclass
class DownloadItem:
file_name: str
nexus_url: str
expected_hash: str
expected_size: int
mod_name: str
mod_id: int = 0
file_id: int = 0
index: int = 0
total: int = 0
loop_iteration: int = 1
status: STATUS = "pending"
local_path: Optional[str] = None
error_message: Optional[str] = None
needs_user_retry: bool = False
@classmethod
def from_event(cls, evt: dict, loop_iteration: int = 1) -> 'DownloadItem':
# Engine historically emitted `nexus_url`, but manual-only/external sources
# may arrive as generic URL fields depending on engine version.
source_url = (
evt.get('nexus_url')
or evt.get('download_url')
or evt.get('manual_url')
or evt.get('url')
or ''
)
item = cls(
file_name=evt.get('file_name', ''),
nexus_url=source_url,
expected_hash=evt.get('expected_hash', ''),
expected_size=evt.get('expected_size', 0),
mod_name=evt.get('mod_name', evt.get('file_name', '')),
mod_id=evt.get('mod_id', 0),
file_id=evt.get('file_id', 0),
index=evt.get('index', 0),
total=evt.get('total', 0),
loop_iteration=loop_iteration,
)
if not item.nexus_url:
# Engine contract says nexus_url should be present and non-empty.
# If missing, keep this item out of auto-open rotation and require
# explicit user attention/manual recovery.
item.needs_user_retry = True
item.error_message = "Malformed manual_download_required event: missing nexus_url"
return item
class ManualDownloadManager(ManualDownloadManagerApiMixin, ManualDownloadManagerRuntimeMixin):
"""
Manages the full manual download workflow for one engine session.
Usage:
manager = ManualDownloadManager(
modlist_download_dir=Path(...),
watch_directory=Path(...),
concurrent_limit=2,
on_item_updated=my_callback,
on_send_continue=installer_thread.send_continue,
)
manager.load_items(event_list, loop_iteration=1)
manager.start()
# ... user downloads files ...
# manager sends continue automatically when all done
manager.stop()
"""
def __init__(
self,
modlist_download_dir: Path,
watch_directory: Path,
concurrent_limit: int = 2,
on_item_updated: Optional[Callable[[DownloadItem], None]] = None,
on_send_continue: Optional[Callable[[], None]] = None,
on_all_done: Optional[Callable[[int, int], None]] = None,
):
self._dl_dir = modlist_download_dir
self._watch_dir = watch_directory
self._limit = max(1, min(5, concurrent_limit))
self._on_item_updated = on_item_updated
self._on_send_continue = on_send_continue
self._on_all_done = on_all_done
self._items: list[DownloadItem] = []
self._lock = threading.Lock()
self._active_tabs = 0
self._paused = False
self._started = False
self._startup_precheck_pending = 0
self._run_id = f"mdl-{int(time.time())}-{id(self) % 10000}"
self._last_progress_log_completed = -1
additional = [modlist_download_dir] if modlist_download_dir != watch_directory else []
config = WatcherConfig(watch_directory=watch_directory, additional_dirs=additional)
self._watcher = DownloadWatcherService(config, self._on_candidate)
self._validator = FileValidatorService(max_workers=2)

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
"""Public API methods for ManualDownloadManager."""
import json
from typing import Optional
class ManualDownloadManagerApiMixin:
"""Mixin containing public manager API methods and status properties."""
def load_items(self, events: list[dict], loop_iteration: int = 1) -> None:
"""
Merge a new batch of engine events into the existing item list.
On loop_iteration > 1, engine only emits still-missing files. Items NOT
in the new batch that were pending are confirmed present by the engine
(they passed its rescan) and are marked complete. Genuinely new items
(edge case) are appended. active_tabs resets so the sliding window
opens fresh tabs for the remaining items.
"""
with self._lock:
existing_map = {item.file_name: item for item in self._items}
new_batch_names = {evt.get('file_name', '') for evt in events}
# Items the engine confirmed are now present (not in new batch, were pending)
for item in self._items:
if item.file_name not in new_batch_names and item.status not in ('complete', 'deferred', 'skipped', 'error'):
item.status = 'complete'
item.needs_user_retry = False
# Recheck loop: clear temporary defer state for still-missing files so they can
# re-enter active browser rotation in the new iteration.
if loop_iteration > 1:
for item in self._items:
if item.file_name in new_batch_names and item.status in ('deferred', 'skipped'):
item.status = 'pending'
item.needs_user_retry = False
item.error_message = None
# Add items genuinely not seen before (first iteration, or edge case)
for evt in events:
name = evt.get('file_name', '')
if name not in existing_map:
# Local import avoids module-load circular dependency with manager class.
from jackify.backend.services.manual_download_manager import DownloadItem
new_item = DownloadItem.from_event(evt, loop_iteration)
self._items.append(new_item)
if not new_item.nexus_url:
self._diag(
"MDL-9012",
"Engine manual-download event missing required nexus_url",
level="error",
file_name=new_item.file_name or "missing",
loop_iteration=loop_iteration,
mod_id=new_item.mod_id,
file_id=new_item.file_id,
)
self._active_tabs = 0
total = len(self._items)
pending = sum(1 for i in self._items if i.status == 'pending')
complete = sum(1 for i in self._items if i.status == 'complete')
skipped = sum(1 for i in self._items if i.status == 'skipped')
sample_pending = [i.file_name for i in self._items if i.status == 'pending'][:5]
self._refresh_watcher_pending_items()
self._diag(
"MDL-1001",
"Manual download batch loaded",
loop_iteration=loop_iteration,
batch_size=len(events),
total_items=total,
pending=pending,
complete=complete,
skipped=skipped,
pending_sample=json.dumps(sample_pending, ensure_ascii=True),
)
def start(self) -> None:
with self._lock:
if self._started:
return
self._started = True
self._diag(
"MDL-1002",
"Manual download watcher started",
watch_dir=str(self._watch_dir),
downloads_dir=str(self._dl_dir),
concurrent_limit=self._limit,
)
self._watcher.start()
matched = self._ingest_existing_files()
with self._lock:
self._startup_precheck_pending = matched
if matched:
self._diag("MDL-1003", "Pre-existing archives detected", matched=matched)
self._diag("MDL-1016", "Deferring tab opening until precheck validation completes", pending_precheck=matched)
else:
self._open_next_tabs()
def stop(self) -> None:
self._watcher.stop()
self._validator.shutdown()
with self._lock:
self._started = False
self._startup_precheck_pending = 0
self._diag("MDL-1009", "Manual download manager stopped")
def pause(self) -> None:
with self._lock:
self._paused = True
def resume(self) -> None:
with self._lock:
self._paused = False
self._diag("MDL-1008", "Manual download resumed")
# Explicit user start/resume must open tabs even if startup precheck
# bookkeeping is still in-flight.
self._open_next_tabs(force_user_start=True)
def skip_item(self, file_name: str) -> None:
item_to_notify: Optional[DownloadItem] = None
with self._lock:
for item in self._items:
if item.file_name == file_name and item.status not in ('complete',):
item.status = 'deferred'
if self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
break
if item_to_notify is not None:
self._notify(item_to_notify)
self._open_next_tabs()
self._check_all_done()
def set_concurrent_limit(self, limit: int) -> None:
with self._lock:
self._limit = max(1, min(5, limit))
applied = self._limit
started = self._started
self._diag("MDL-1006", "Manual download concurrency updated", concurrent_limit=applied)
if started:
self._open_next_tabs()
@property
def items(self) -> list[DownloadItem]:
with self._lock:
return list(self._items)
@property
def pending_count(self) -> int:
with self._lock:
return sum(1 for i in self._items if i.status == 'pending')
@property
def complete_count(self) -> int:
with self._lock:
return sum(1 for i in self._items if i.status == 'complete')
@property
def skipped_count(self) -> int:
with self._lock:
return sum(1 for i in self._items if i.status in ('deferred', 'skipped'))

View File

@@ -0,0 +1,479 @@
from __future__ import annotations
"""Internal runtime methods for ManualDownloadManager."""
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional
from jackify.backend.services.file_validator_service import ValidationResult
logger = logging.getLogger(__name__)
class ManualDownloadManagerRuntimeMixin:
"""Mixin containing browser/watcher/validation runtime methods."""
def _open_next_tabs(self, force_user_start: bool = False) -> None:
to_open = []
to_notify = []
with self._lock:
if not self._started or self._paused:
return
if self._startup_precheck_pending > 0 and not force_user_start:
return
while self._active_tabs < self._limit:
item = self._next_pending(include_retry=force_user_start)
if item is None:
break
if force_user_start and item.needs_user_retry:
item.needs_user_retry = False
item.error_message = None
item.status = 'browser_opened'
self._active_tabs += 1
to_notify.append(item)
to_open.append(item)
active_tabs = self._active_tabs
pending_left = sum(1 for i in self._items if i.status == 'pending')
if to_open:
self._diag(
"MDL-1010",
"Opening next manual download tab window",
opening_count=len(to_open),
active_tabs=active_tabs,
pending_after_schedule=pending_left,
)
# Notify outside the lock to prevent GUI callbacks from re-entering manager state.
for item in to_notify:
self._notify(item)
# Open browser tabs outside the lock so Popen/fork doesn't stall lock holders
for item in to_open:
opened, error_message = self._open_browser(item)
if opened:
continue
item_to_notify: Optional[DownloadItem] = None
with self._lock:
# Revert failed launch so the row does not falsely remain "Browser Opened".
current = self._item_by_name(item.file_name)
if current and current.status == 'browser_opened':
current.status = 'pending'
if self._active_tabs > 0:
self._active_tabs -= 1
current.error_message = error_message
if error_message and "No URL available" in error_message:
current.needs_user_retry = True
item_to_notify = current
if item_to_notify is not None:
self._notify(item_to_notify)
self._diag(
"MDL-9001",
"Automatic browser launch failed for manual download item",
level="warning",
file_name=item.file_name,
reason=error_message or "unknown launcher failure",
)
def _next_pending(self, include_retry: bool = False) -> Optional[DownloadItem]:
for item in self._items:
if item.status != 'pending':
continue
if item.needs_user_retry and not include_retry:
continue
return item
return None
def _open_browser(self, item: DownloadItem) -> tuple[bool, Optional[str]]:
url = item.nexus_url
if not url:
msg = "No URL available for manual download item"
logger.warning(f"{msg}: {item.file_name}")
return False, msg
# Linux desktop launch fallbacks. xdg-open should cover most environments,
# but keep alternates for distributions where handlers differ.
launch_cmds = (
['xdg-open', url],
['gio', 'open', url],
['sensible-browser', url],
)
launch_errors: list[str] = []
for cmd in launch_cmds:
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
)
except OSError as e:
launch_errors.append(f"{cmd[0]} not available: {e}")
continue
try:
rc = proc.wait(timeout=3)
except subprocess.TimeoutExpired:
# Launcher still running after handoff window; treat as success.
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
return True, None
if rc == 0:
logger.debug(f"Opened browser for: {item.file_name} via {cmd[0]}")
return True, None
stderr_tail = ""
try:
stderr_tail = (proc.stderr.read() or b"").decode("utf-8", errors="replace").strip()
except Exception:
stderr_tail = ""
launch_errors.append(f"{cmd[0]} exited {rc}{(': ' + stderr_tail) if stderr_tail else ''}")
msg = f"Could not open browser automatically for {item.file_name}"
logger.error(f"{msg}. Launch attempts: {' | '.join(launch_errors)}")
return False, msg
def _on_candidate(self, path: Path, hint: dict, from_startup_precheck: bool = False) -> bool:
"""Called by watcher after debounce when a potential match is found."""
file_name = hint.get('file_name', '')
item_to_notify: Optional[DownloadItem] = None
reject_reason = ""
had_browser_slot = False
with self._lock:
item = self._item_by_name(file_name)
if item is None:
reject_reason = "unknown_item"
elif item.status in ('complete', 'skipped'):
reject_reason = f"terminal_status:{item.status}"
elif item.status == 'validating':
reject_reason = "already_validating"
if reject_reason:
self._diag(
"MDL-1022",
"Candidate ignored",
file_name=file_name or "missing",
source_path=str(path),
from_precheck=from_startup_precheck,
reason=reject_reason,
)
return False
had_browser_slot = item.status == 'browser_opened'
item.status = 'validating'
item_to_notify = item
if item_to_notify is not None:
self._notify(item_to_notify)
self._diag(
"MDL-1020",
"Candidate queued for validation",
file_name=file_name or "missing",
source_path=str(path),
from_precheck=from_startup_precheck,
)
# Pass the engine's canonical filename as dest_name so that if the browser
# stripped a leading dot, the file is renamed correctly on move.
canonical_name = hint.get('file_name') or None
dest_name = canonical_name if canonical_name and canonical_name != path.name else None
self._validator.validate_async(
file_path=path,
expected_hash=hint.get('expected_hash', ''),
modlist_download_dir=self._dl_dir,
on_result=lambda result, dest: self._on_validation_result(
file_name,
result,
dest,
from_startup_precheck=from_startup_precheck,
had_browser_slot=had_browser_slot,
),
dest_name=dest_name,
)
return True
def _on_validation_result(
self,
file_name: str,
result: ValidationResult,
dest: Optional[Path],
from_startup_precheck: bool = False,
had_browser_slot: bool = False,
) -> None:
item_to_notify: Optional[DownloadItem] = None
validation_failed = False
completed_now = False
precheck_ready = False
expected_hash = ""
mod_id = 0
file_id = 0
source_path = str(result.file_path) if getattr(result, "file_path", None) else ""
computed_hash = (result.computed_hash or "").lower() if result.computed_hash else ""
with self._lock:
item = self._item_by_name(file_name)
if item is None:
return
expected_hash = (item.expected_hash or "").lower()
mod_id = item.mod_id
file_id = item.file_id
if result.matches and dest:
item.status = 'complete'
item.local_path = str(dest)
item.needs_user_retry = False
if had_browser_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
completed_now = True
else:
# Hash mismatch or validation error — revert to pending so the
# sliding window can re-open a browser tab and the watcher can
# re-validate if the user downloads the correct file.
item.status = 'pending'
msg = result.error or f"Hash mismatch (got {result.computed_hash})"
item.error_message = msg
logger.warning(f"Validation failed for {file_name}: {msg}")
if had_browser_slot and self._active_tabs > 0:
self._active_tabs -= 1
item_to_notify = item
validation_failed = True
if from_startup_precheck and self._startup_precheck_pending > 0:
self._startup_precheck_pending -= 1
precheck_ready = self._startup_precheck_pending == 0
if item_to_notify is not None:
self._notify(item_to_notify)
if completed_now:
self._diag(
"MDL-1021",
"Archive validated and accepted",
file_name=file_name,
source_path=source_path or "missing",
destination_path=str(dest) if dest else "missing",
expected_hash=expected_hash or "missing",
computed_hash=computed_hash or "missing",
from_precheck=from_startup_precheck,
mod_id=mod_id,
file_id=file_id,
)
self._maybe_log_progress_summary()
if precheck_ready:
self._diag("MDL-1017", "Startup precheck validation complete; opening tabs")
self._open_next_tabs()
if validation_failed:
self._refresh_watcher_pending_items()
if not from_startup_precheck:
self._open_next_tabs()
self._diag(
"MDL-9002",
"Archive validation failed",
level="warning",
file_name=file_name,
expected_hash=expected_hash or "missing",
computed_hash=computed_hash or "missing",
source_path=source_path or "missing",
mod_id=mod_id,
file_id=file_id,
from_precheck=from_startup_precheck,
reason=result.error or "hash mismatch",
)
return
# Update watcher pending list (remove completed item, keep other in-flight items).
self._refresh_watcher_pending_items()
if not from_startup_precheck:
self._open_next_tabs()
self._check_all_done()
def _check_all_done(self) -> None:
with self._lock:
remaining = [i for i in self._items if i.status not in ('complete', 'deferred', 'skipped', 'error')]
if remaining:
return
completed = sum(1 for i in self._items if i.status == 'complete')
skipped = sum(1 for i in self._items if i.status in ('deferred', 'skipped'))
self._diag("MDL-1011", "Manual download phase completed", completed=completed, skipped=skipped)
if self._on_all_done:
self._on_all_done(completed, skipped)
if self._on_send_continue:
self._diag("MDL-1012", "Sending continue command to engine")
self._on_send_continue()
def _item_by_name(self, file_name: str) -> Optional[DownloadItem]:
for item in self._items:
if item.file_name == file_name:
return item
return None
def _refresh_watcher_pending_items(self) -> None:
"""Keep watcher tracking all non-terminal items, not only pure 'pending' ones."""
with self._lock:
pending_items = [
{'file_name': i.file_name, 'expected_hash': i.expected_hash, 'expected_size': i.expected_size}
for i in self._items
if i.status not in ('complete', 'error')
]
pending_count = len(pending_items)
sample_pending = [i['file_name'] for i in pending_items[:5]]
self._watcher.set_pending_items(pending_items)
self._diag(
"MDL-1019",
"Watcher pending list refreshed",
pending_count=pending_count,
pending_sample=json.dumps(sample_pending, ensure_ascii=True),
)
def _ingest_existing_files(self) -> int:
"""
Pre-check watch/modlist directories for already-present archives so users
do not need to re-download files that already exist.
"""
dirs: list[Path] = []
if self._watch_dir.is_dir():
dirs.append(self._watch_dir)
if self._dl_dir.is_dir() and self._dl_dir != self._watch_dir:
dirs.append(self._dl_dir)
if not dirs:
return 0
existing_files: list[Path] = []
for d in dirs:
try:
for p in d.iterdir():
if p.is_file() and p.suffix not in ('.part', '.crdownload', '.tmp'):
existing_files.append(p)
except OSError as e:
logger.warning(f"[MDL-9021] Precheck scan error: dir={d} reason={e}")
continue
if not existing_files:
self._diag("MDL-1023", "Startup precheck found no candidate files", scan_dirs=len(dirs))
return 0
exact_map: dict[str, Path] = {}
for p in existing_files:
exact_map.setdefault(p.name.lower(), p)
with self._lock:
targets = [
{'file_name': i.file_name, 'expected_hash': i.expected_hash, 'expected_size': i.expected_size}
for i in self._items
if i.status not in ('complete', 'error')
]
self._diag(
"MDL-1024",
"Startup precheck scan summary",
scan_dirs=len(dirs),
discovered_files=len(existing_files),
pending_targets=len(targets),
discovered_sample=json.dumps([p.name for p in existing_files[:5]], ensure_ascii=True),
target_sample=json.dumps([t.get('file_name', '') for t in targets[:5]], ensure_ascii=True),
)
matched = 0
used_paths: set[Path] = set()
for hint in targets:
name = hint['file_name']
exact = exact_map.get(name.lower())
if exact is None:
# Leading-dot normalization: browser may strip a leading dot that
# the engine uses in its canonical filename.
stripped = name.lower().lstrip('.')
if stripped != name.lower():
exact = exact_map.get(stripped)
if exact is None or exact in used_paths:
continue
used_paths.add(exact)
if self._on_candidate(exact, hint, from_startup_precheck=True):
matched += 1
if matched:
logger.info(f"[MDL-1025] Startup precheck queued {matched} archive(s) for validation")
else:
self._diag("MDL-1025", "Startup precheck found zero exact filename matches")
return matched
def reopen_item(self, file_name: str) -> bool:
"""Re-open a specific item's URL (e.g. if user closed browser tab accidentally)."""
notify_item: Optional[DownloadItem] = None
with self._lock:
item = self._item_by_name(file_name)
if item is None:
return False
if item.status in ('complete', 'skipped'):
return False
if item.status != 'browser_opened':
item.status = 'browser_opened'
item.needs_user_retry = False
self._active_tabs += 1
notify_item = item
if notify_item is not None:
self._notify(notify_item)
if item is None:
return False
opened, error = self._open_browser(item)
if not opened:
revert_item: Optional[DownloadItem] = None
with self._lock:
current = self._item_by_name(file_name)
if current is not None and current.status == 'browser_opened':
current.status = 'pending'
current.needs_user_retry = True
if self._active_tabs > 0:
self._active_tabs -= 1
revert_item = current
if revert_item is not None:
self._notify(revert_item)
self._diag(
"MDL-9011",
"Manual reopen failed",
level="warning",
file_name=file_name,
reason=error or "unknown launcher failure",
)
return False
self._diag("MDL-1018", "Manual item URL re-opened by user", file_name=file_name)
return True
def _maybe_log_progress_summary(self) -> None:
with self._lock:
complete = sum(1 for i in self._items if i.status == 'complete')
skipped = sum(1 for i in self._items if i.status in ('deferred', 'skipped'))
pending = sum(1 for i in self._items if i.status == 'pending')
validating = sum(1 for i in self._items if i.status == 'validating')
opened = sum(1 for i in self._items if i.status == 'browser_opened')
total = len(self._items)
if complete == self._last_progress_log_completed:
return
if complete in (1, total) or complete % 10 == 0:
self._last_progress_log_completed = complete
self._diag(
"MDL-1013",
"Manual download progress summary",
total=total,
complete=complete,
browser_opened=opened,
validating=validating,
pending=pending,
skipped=skipped,
needs_retry=sum(1 for i in self._items if i.needs_user_retry),
)
def _diag(self, code: str, message: str, level: str = "info", **ctx) -> None:
details = " ".join(f"{k}={v}" for k, v in ctx.items())
text = f"[{code}] run={self._run_id} {message}"
if details:
text = f"{text} | {details}"
if level == "warning":
logger.warning(text)
elif level == "error":
logger.error(text)
else:
logger.info(text)
def _notify(self, item: DownloadItem) -> None:
if self._on_item_updated:
try:
self._on_item_updated(item)
except Exception as e:
logger.debug(f"on_item_updated callback error: {e}")

View File

@@ -11,6 +11,8 @@ import re
import shutil
import logging
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Callable, Optional, Tuple
@@ -31,10 +33,55 @@ class MO2SetupService:
GITHUB_API = "https://api.github.com/repos/ModOrganizer2/modorganizer/releases/latest"
ASSET_PATTERN = re.compile(r"Mod\.Organizer-\d+\.\d+(\.\d+)?\.7z$")
def _extract_archive(
self,
archive_path: Path,
install_dir: Path,
should_cancel: Optional[Callable[[], bool]] = None,
) -> Tuple[bool, Optional[str]]:
"""Extract the MO2 archive without interactive prompts and honor cancellation."""
process = None
try:
process = subprocess.Popen(
['7z', 'x', '-y', '-aoa', str(archive_path), f'-o{install_dir}'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
while True:
if should_cancel and should_cancel():
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
return False, "MO2 setup cancelled."
returncode = process.poll()
if returncode is not None:
stdout, stderr = process.communicate()
if returncode != 0:
err = (stderr or stdout or "").strip()
return False, f"Extraction failed: {err or '7z returned a non-zero exit code.'}"
return True, None
time.sleep(0.1)
except Exception as e:
if process is not None:
try:
process.kill()
except Exception:
pass
return False, f"Extraction failed: {e}"
def setup_mo2(
self,
install_dir: Path,
shortcut_name: str = "Mod Organizer 2",
existing_appid: Optional[int] = None,
progress_callback: Optional[Callable[[str], None]] = None,
should_cancel: Optional[Callable[[], bool]] = None,
) -> Tuple[bool, Optional[int], Optional[str]]:
@@ -88,16 +135,21 @@ class MO2SetupService:
return False, None, "Could not find main MO2 .7z asset in latest release."
# Download
archive_path = install_dir / asset['name']
_progress(f"Downloading {asset['name']}...")
if _cancel_requested():
return False, None, "MO2 setup cancelled."
try:
with tempfile.NamedTemporaryFile(prefix="jackify-mo2-", suffix=".7z", delete=False) as tmp_file:
archive_path = Path(tmp_file.name)
with requests.get(asset['browser_download_url'], stream=True, timeout=120, verify=True) as r:
r.raise_for_status()
with open(archive_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if _cancel_requested():
try:
archive_path.unlink(missing_ok=True)
except Exception:
pass
return False, None, "MO2 setup cancelled."
f.write(chunk)
except Exception as e:
@@ -107,18 +159,13 @@ class MO2SetupService:
_progress(f"Extracting to {install_dir}...")
if _cancel_requested():
return False, None, "MO2 setup cancelled."
try:
result = subprocess.run(
['7z', 'x', str(archive_path), f'-o{install_dir}'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=1200,
)
if result.returncode != 0:
err = result.stderr.decode(errors='ignore')
return False, None, f"Extraction failed: {err}"
except Exception as e:
return False, None, f"Extraction failed: {e}"
extract_ok, extract_error = self._extract_archive(archive_path, install_dir, should_cancel)
if not extract_ok:
try:
archive_path.unlink(missing_ok=True)
except Exception:
pass
return False, None, extract_error
# Validate
mo2_exe = install_dir / "ModOrganizer.exe"
@@ -149,12 +196,22 @@ class MO2SetupService:
try:
from .automated_prefix_service import AutomatedPrefixService
svc = AutomatedPrefixService()
success, prefix_path, app_id, _last_ts = svc.run_working_workflow(
shortcut_name=shortcut_name,
modlist_install_dir=str(install_dir),
final_exe_path=str(mo2_exe),
progress_callback=_progress,
)
if existing_appid is not None:
app_id = int(existing_appid)
_progress(f"Reusing existing Steam shortcut with AppID: {app_id}")
prefix_path = svc.get_prefix_path(app_id)
if prefix_path is None:
if not svc.create_prefix_with_proton_wrapper(app_id):
return False, None, "Failed to create Proton prefix for existing shortcut."
prefix_path = svc.get_prefix_path(app_id)
success = True
else:
success, prefix_path, app_id, _last_ts = svc.run_working_workflow(
shortcut_name=shortcut_name,
modlist_install_dir=str(install_dir),
final_exe_path=str(mo2_exe),
progress_callback=_progress,
)
except Exception as e:
logger.error(f"AutomatedPrefixService failed: {e}")
return False, None, f"Prefix setup failed: {e}"

View File

@@ -334,9 +334,9 @@ class ModlistService(ModlistServiceInstallationMixin):
if completion_callback:
if success:
debug_callback("Configuration completed successfully, calling completion callback")
debug_callback("Core configuration complete, calling completion callback")
# Pass ENB detection status through callback
completion_callback(True, "Configuration completed successfully!", context.name, enb_detected)
completion_callback(True, "Core configuration complete", context.name, enb_detected)
else:
debug_callback("Configuration failed, calling completion callback with failure")
completion_callback(False, "Configuration failed", context.name, False)
@@ -439,7 +439,7 @@ class ModlistService(ModlistServiceInstallationMixin):
if success:
logger.info("Modlist configuration completed successfully")
if completion_callback:
completion_callback(True, "Configuration completed successfully", context.name, False)
completion_callback(True, "Core configuration complete", context.name, False)
else:
logger.warning("Modlist configuration had issues")
if completion_callback:

View File

@@ -186,10 +186,23 @@ class ModlistServiceInstallationMixin:
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=False, env=clean_env, cwd=engine_dir
)
def _write_stdin(line: str) -> bool:
try:
payload = line if line.endswith('\n') else line + '\n'
proc.stdin.write(payload.encode())
proc.stdin.flush()
return True
except (OSError, BrokenPipeError):
return False
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename
import json as _json
_cc_filename = None
_pending_manual: list = []
buffer = b''
while True:
chunk = proc.stdout.read(1)
@@ -197,26 +210,81 @@ class ModlistServiceInstallationMixin:
break
buffer += chunk
if chunk == b'\n':
if chunk in (b'\n', b'\r'):
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
decoded = line.rstrip()
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
# JSON engine events - handle silently, don't pass to output_callback
if decoded.strip().startswith('{'):
try:
obj = _json.loads(decoded.strip())
event = obj.get('event')
if event == 'manual_download_required':
_pending_manual.append(obj)
continue
if event == 'manual_download_list_complete':
loop_iter = obj.get('loop_iteration', 1)
for item in _pending_manual:
item['loop_iteration'] = loop_iter
items_batch = list(_pending_manual)
_pending_manual.clear()
from jackify.backend.handlers.config_handler import ConfigHandler
raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2)
try:
manual_limit = int(raw_limit)
except (TypeError, ValueError):
manual_limit = 2
manual_limit = max(1, min(5, manual_limit))
from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase
completed = run_cli_manual_download_phase(
events=items_batch,
loop_iteration=loop_iter,
download_dir=actual_download_path,
stdin_write=_write_stdin,
output_callback=output_callback,
concurrent_limit=manual_limit,
)
if not completed:
if proc.poll() is None:
proc.terminate()
break
continue
if event == 'manual_download_phase_complete':
if output_callback:
found = obj.get('total_found', 0)
required = obj.get('total_required', 0)
output_callback(f"All manual downloads confirmed ({found}/{required}). Resuming installation...")
continue
except (_json.JSONDecodeError, ValueError):
pass
if output_callback:
output_callback(line.rstrip())
buffer = b''
output_callback(decoded)
if _cc_filename is None and is_cc_content_error(decoded):
_cc_filename = extract_cc_filename(decoded) or ""
if buffer:
line = buffer.decode('utf-8', errors='replace')
decoded = line.rstrip()
if output_callback:
output_callback(line.rstrip())
output_callback(decoded)
if _cc_filename is None and is_cc_content_error(decoded):
_cc_filename = extract_cc_filename(decoded) or ""
proc.wait()
if proc.returncode != 0:
if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
if _cc_filename is not None and output_callback:
fname_note = f" ({_cc_filename})" if _cc_filename else ""
output_callback("")
output_callback(f"[WARN] Anniversary Edition Content Missing{fname_note}")
output_callback(" - Open Vanilla Skyrim SE/AE and let it run until all Creation Club content has downloaded.")
output_callback(" - From the Skyrim main menu, go into Creations and select 'Download All'.")
output_callback(" - If specific files are still missing, search for and download them from the Creations menu.")
output_callback(" - If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.")
output_callback(" - Note: Skyrim AE via Steam Family Sharing does not transfer DLC content.")
return False
if output_callback:
output_callback("Installation completed successfully")

View File

@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Optional, Tuple, Dict, Any, List
from ..handlers.vdf_handler import VDFHandler
from jackify.shared.steam_utils import get_ordered_steam_roots, STEAM_PREFERENCE_AUTO
logger = logging.getLogger(__name__)
@@ -30,13 +31,14 @@ class NativeSteamService:
"""
def __init__(self):
self.steam_paths = [
Path.home() / ".steam" / "steam",
Path.home() / ".local" / "share" / "Steam",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam"
]
preference = STEAM_PREFERENCE_AUTO
try:
from jackify.backend.handlers.config_handler import ConfigHandler
preference = ConfigHandler().get("steam_install_preference", STEAM_PREFERENCE_AUTO)
except Exception:
pass
self.steam_paths = get_ordered_steam_roots(preference=preference)
self.steam_path = None
self.userdata_path = None
self.user_id = None
@@ -620,4 +622,4 @@ class NativeSteamService:
except Exception as e:
logger.error(f"Error creating symlink: {e}")
return False
return False

View File

@@ -26,6 +26,7 @@ class NexusOAuthProtocolMixin:
'APPIMAGE' in env or 'APPDIR' in env or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
exec_path_reliable = True
if is_appimage:
if 'APPIMAGE' in env:
exec_path = env['APPIMAGE']
@@ -35,34 +36,27 @@ class NexusOAuthProtocolMixin:
logger.info("Using resolved sys.argv[0]: %s", exec_path)
else:
exec_path = sys.argv[0]
exec_path_reliable = False
logger.warning("Using sys.argv[0] as fallback: %s", exec_path)
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
logger.info("DEV mode exec path: %s", exec_path)
logger.info("Source directory: %s", src_dir)
needs_update = False
if not desktop_file.exists():
needs_update = True
logger.info("Creating desktop file for protocol handler")
else:
expected_exec = f'Exec="{exec_path}" %u' if is_appimage else f'Exec={exec_path} %u'
needs_write = not desktop_file.exists()
if not needs_write and exec_path_reliable:
current_content = desktop_file.read_text()
if is_appimage:
expected_exec = f'Exec="{exec_path}" %u'
else:
expected_exec = f"Exec={exec_path} %u"
if expected_exec not in current_content:
needs_update = True
logger.info("Updating desktop file with new Exec path: %s", exec_path)
if is_appimage and ' ' in exec_path:
import re
if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content):
needs_update = True
logger.info("Fixing malformed desktop file (unquoted path with spaces)")
if needs_update:
desktop_file.parent.mkdir(parents=True, exist_ok=True)
if is_appimage:
desktop_content = f"""[Desktop Entry]
needs_write = True
logger.info("Desktop file Exec path outdated, updating: %s", exec_path)
elif not needs_write and not exec_path_reliable:
logger.warning("Could not reliably determine AppImage path, keeping existing desktop file")
desktop_file.parent.mkdir(parents=True, exist_ok=True)
if needs_write and is_appimage:
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
@@ -72,9 +66,9 @@ Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
"""
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
desktop_content = f"""[Desktop Entry]
elif needs_write:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
@@ -85,10 +79,14 @@ Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
Path={src_dir}
"""
if needs_write:
desktop_file.write_text(desktop_content)
logger.info("Desktop file written: %s", desktop_file)
logger.info("Exec path: %s", exec_path)
logger.info("AppImage mode: %s", is_appimage)
else:
logger.debug("Desktop file up to date, skipping write")
logger.info("Registering jackify:// protocol handler")
apps_dir = Path.home() / ".local" / "share" / "applications"
subprocess.run(['update-desktop-database', str(apps_dir)], capture_output=True, timeout=10)

View File

@@ -673,6 +673,7 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=start_env)
if final_check.returncode == 0:
report("Steam started successfully.")
report("[Jackify] Steam restart complete")
logger.info(f"Steam confirmed running after {elapsed_wait}s wait.")
return True
else:

View File

@@ -0,0 +1,62 @@
"""Shared backend helpers for locating and installing TTW_Linux_Installer."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Callable, Optional, Tuple
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
logger = logging.getLogger(__name__)
def _build_handler() -> TTWInstallerHandler:
return TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=FileSystemHandler(),
config_handler=ConfigHandler(),
)
def get_ttw_installer_path() -> Optional[Path]:
"""Return the resolved TTW_Linux_Installer executable path, if available."""
handler = _build_handler()
path = handler.ttw_installer_executable_path
if path and path.exists():
return path
return None
def ensure_ttw_installer_available(
progress_callback: Optional[Callable[[str], None]] = None,
) -> Tuple[Optional[Path], str]:
"""
Ensure TTW_Linux_Installer is installed and return its executable path.
Returns:
(path, message)
"""
existing = get_ttw_installer_path()
if existing:
return existing, "TTW_Linux_Installer ready"
if progress_callback:
progress_callback("TTW_Linux_Installer not found, installing...")
handler = _build_handler()
success, message = handler.install_ttw_installer()
if not success:
logger.error("Failed to install TTW_Linux_Installer: %s", message)
return None, message
path = handler.ttw_installer_executable_path
if path and path.exists():
if progress_callback:
progress_callback("TTW_Linux_Installer installed successfully")
return path, message
return None, "TTW_Linux_Installer install completed but executable was not found"

View File

@@ -101,13 +101,15 @@ class UpdateService:
break
if download_url:
# Prefer Nexus CDN for Premium users when release embeds nexus_file_id
release_body = release_data.get('body', '')
nexus_url = self._try_nexus_download_url(release_body)
# Prefer Nexus CDN for Premium users if this version is available there
nexus_url = self._try_nexus_download_url(latest_version)
update_source = "github"
if nexus_url:
download_url = nexus_url
update_source = "nexus"
logger.debug(f"UPD-1001 update_source_selected source=nexus version={latest_version}")
else:
logger.debug(f"UPD-1001 update_source_selected source=github version={latest_version}")
# Determine if this is a delta update
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
@@ -152,54 +154,69 @@ class UpdateService:
logger.error(f"Unexpected error checking for updates: {e}")
return None
def _try_nexus_download_url(self, release_body: str) -> Optional[str]:
"""
If the user is Nexus Premium and the release body embeds nexus_file_id,
return a Nexus CDN download URL. Returns None on any failure.
_NEXUS_MOD_ID = 1427
Release body format expected:
nexus_mod_id: 12345
nexus_file_id: 67890
def _try_nexus_download_url(self, target_version: str) -> Optional[str]:
"""
If the user is Nexus Premium, query the Nexus files list for the mod
and return a CDN download URL for the file matching target_version.
Returns None on any failure or if the version is not yet on Nexus.
"""
import re
try:
mod_match = re.search(r'nexus_mod_id:\s*(\d+)', release_body, re.IGNORECASE)
file_match = re.search(r'nexus_file_id:\s*(\d+)', release_body, re.IGNORECASE)
if not file_match:
return None
nexus_file_id = int(file_match.group(1))
nexus_mod_id = int(mod_match.group(1)) if mod_match else None
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
token = auth_service.get_auth_token()
if not token:
logger.debug("UPD-1002 nexus_lookup_skipped reason=missing_auth_token")
return None
auth_method = auth_service.get_auth_method()
is_oauth = auth_method == "oauth"
from jackify.backend.services.nexus_premium_service import NexusPremiumService
is_premium, _ = NexusPremiumService().check_premium_status(token)
is_premium, _ = NexusPremiumService().check_premium_status(token, is_oauth=is_oauth)
if not is_premium:
logger.debug("Nexus download skipped: user is not Premium")
logger.debug("UPD-1002 nexus_lookup_skipped reason=not_premium")
return None
if nexus_mod_id is None:
auth_headers = {"Accept": "application/json"}
if is_oauth:
auth_headers["Authorization"] = f"Bearer {token}"
else:
auth_headers["apikey"] = token
files_url = f"https://api.nexusmods.com/v1/games/site/mods/{self._NEXUS_MOD_ID}/files.json"
resp = requests.get(files_url, headers=auth_headers, timeout=8)
resp.raise_for_status()
files = resp.json().get("files", [])
# Prefer MAIN category; accept any non-archived/removed file matching the version.
match = None
for f in files:
if f.get("version") != target_version:
continue
if f.get("category_name") == "MAIN":
match = f
break
if f.get("category_name") not in ("ARCHIVED", "REMOVED"):
match = match or f
if match is None:
logger.debug(f"UPD-1002 nexus_lookup_skipped reason=version_not_on_nexus version={target_version}")
return None
api_url = f"https://api.nexusmods.com/v1/games/site/mods/{nexus_mod_id}/files/{nexus_file_id}/download_link.json"
resp = requests.get(
api_url,
headers={"apikey": token, "Accept": "application/json"},
timeout=8,
)
nexus_file_id = match["file_id"]
dl_url = f"https://api.nexusmods.com/v1/games/site/mods/{self._NEXUS_MOD_ID}/files/{nexus_file_id}/download_link.json"
resp = requests.get(dl_url, headers=auth_headers, timeout=8)
resp.raise_for_status()
links = resp.json()
if isinstance(links, list) and links:
cdn_url = links[0].get("URI")
if cdn_url:
logger.debug(f"Using Nexus CDN URL for update")
logger.debug(f"UPD-1003 nexus_lookup_success file_id={nexus_file_id} version={target_version}")
return cdn_url
logger.debug("UPD-1002 nexus_lookup_skipped reason=empty_download_links")
except Exception as e:
logger.debug(f"Nexus download URL lookup failed: {e}")
logger.debug(f"UPD-1004 nexus_lookup_failed error={e}")
return None
def _is_newer_version(self, version: str) -> bool:
@@ -494,4 +511,4 @@ rm -f "{helper_script}"
except Exception as e:
logger.error(f"Failed to create update helper script: {e}")
return None
return None

View File

@@ -13,6 +13,7 @@ Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth.
import logging
import os
import json
import shutil
import subprocess
import stat
@@ -83,6 +84,110 @@ class VNVPostInstallService:
self.download_service = NexusDownloadService(auth_token)
return True
def _ensure_download_service(self, progress_callback: Optional[Callable[[str], None]] = None) -> bool:
if self.download_service is not None:
return True
return self._ensure_auth(progress_callback)
def _find_cached_4gb_patcher(self) -> Optional[Path]:
for path in self.cache_dir.iterdir():
if path.is_file() and path.suffix.lower() == ".zip" and "4gb" in path.name.lower():
return path
for path in self.cache_dir.iterdir():
if path.is_dir() and path.name.lower().endswith("_extracted") and "4gb" in path.name.lower():
for child in path.iterdir():
if child.is_file():
return child
return None
def _find_cached_bsa_mpi(self) -> Optional[Path]:
for path in self.cache_dir.iterdir():
if path.is_file() and path.suffix.lower() == ".mpi" and "bsa" in path.name.lower():
return path
for path in self.cache_dir.iterdir():
if path.is_dir() and path.name.lower().endswith("_extracted") and "bsa" in path.name.lower():
for child in path.rglob("*.mpi"):
if child.is_file():
return child
return None
def _find_cached_bsa_package(self) -> Optional[Path]:
preferred = []
fallback = []
for path in self.cache_dir.iterdir():
if not path.is_file():
continue
lower = path.name.lower()
if "bsa" not in lower or path.suffix.lower() not in {".zip", ".7z"}:
continue
if path.suffix.lower() == ".zip":
preferred.append(path)
else:
fallback.append(path)
candidates = sorted(preferred) or sorted(fallback)
return candidates[0] if candidates else None
def _extract_bsa_package(self, archive_path: Path) -> tuple[bool, Optional[Path], str]:
extract_dir = self.cache_dir / f"{archive_path.stem}_extracted"
mpi_path = next((p for p in extract_dir.rglob("*.mpi") if p.is_file()), None) if extract_dir.exists() else None
if mpi_path:
return True, mpi_path, f"Using extracted BSA package from {archive_path.name}"
extract_dir.mkdir(parents=True, exist_ok=True)
try:
suffix = archive_path.suffix.lower()
if suffix == ".zip":
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
zip_ref.extractall(extract_dir)
elif suffix == ".7z":
result = subprocess.run(
["7z", "x", "-y", f"-o{extract_dir}", str(archive_path)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return False, None, (result.stderr or result.stdout or "7z extraction failed").strip()
else:
return False, None, f"Unsupported BSA package format: {archive_path.name}"
except Exception as e:
return False, None, str(e)
mpi_path = next((p for p in extract_dir.rglob("*.mpi") if p.is_file()), None)
if not mpi_path:
return False, None, f"No .mpi file found in BSA package: {archive_path.name}"
return True, mpi_path, f"Extracted BSA package {archive_path.name}"
@staticmethod
def _select_manual_download_file(files: list[dict], mod_id: int) -> Optional[dict]:
def _active(entries: list[dict]) -> list[dict]:
return [f for f in entries if f.get("category_name") not in ("ARCHIVED", "REMOVED")]
active_files = _active(files)
if mod_id == VNVPostInstallService.LINUX_4GB_PATCHER_MOD_ID:
proton_files = [
f for f in active_files
if "proton" in f.get("file_name", "").lower() and f.get("file_name", "").lower().endswith(".zip")
]
if proton_files:
proton_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True)
return proton_files[0]
if mod_id == VNVPostInstallService.FNV_BSA_DECOMPRESSOR_MOD_ID:
zip_files = [f for f in active_files if f.get("file_name", "").lower().endswith(".zip")]
if zip_files:
zip_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True)
return zip_files[0]
main_files = [f for f in active_files if f.get("category_name") == "MAIN"]
if main_files:
main_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True)
return main_files[0]
if active_files:
active_files.sort(key=lambda f: f.get("uploaded_timestamp", 0), reverse=True)
return active_files[0]
return None
def should_run_automation(self, modlist_name: str) -> bool:
"""
Check if this modlist should trigger VNV automation.
@@ -108,11 +213,60 @@ class VNVPostInstallService:
"1. Copy root mods to game directory\n"
"2. Download and run Linux 4GB patcher\n"
"3. Download and run BSA decompressor (reduces loading times)\n\n"
"Premium users: Downloads happen automatically\n"
"Non-Premium users: You'll be prompted to download files manually\n\n"
"Jackify will download the required tools automatically where possible.\n"
"If you are not a Nexus Premium member, you will be prompted to\n"
"manually download any tools that cannot be fetched automatically.\n\n"
"Would you like Jackify to automate these steps?"
)
def get_manual_download_items(self, include_bsa: bool = False) -> list:
"""
Query Nexus for the current MAIN file of each required VNV tool and return
a list of DownloadItem-compatible event dicts for use with ManualDownloadManager.
Works with any Nexus auth (not Premium-only).
Returns an empty list if auth is unavailable or queries fail.
"""
import requests as _requests
token = self.auth_service.get_auth_token()
if not token:
return []
auth_method = self.auth_service.get_auth_method()
headers = {"Accept": "application/json"}
if auth_method == "oauth":
headers["Authorization"] = f"Bearer {token}"
else:
headers["apikey"] = token
tools = [(self.LINUX_4GB_PATCHER_MOD_ID, "4GB Patcher")]
if include_bsa:
tools.append((self.FNV_BSA_DECOMPRESSOR_MOD_ID, "BSA Decompressor"))
items = []
for mod_id, label in tools:
try:
resp = _requests.get(
f"https://api.nexusmods.com/v1/games/newvegas/mods/{mod_id}/files.json",
headers=headers, timeout=8,
)
resp.raise_for_status()
files = resp.json().get("files", [])
match = self._select_manual_download_file(files, mod_id)
if match is None:
logger.warning(f"VNV tool lookup: no suitable file found for mod {mod_id} ({label})")
continue
file_id = match["file_id"]
items.append({
"file_name": match["file_name"],
"mod_name": label,
"nexus_url": f"https://www.nexusmods.com/newvegas/mods/{mod_id}?tab=files&file_id={file_id}",
"expected_hash": "",
"expected_size": match.get("size_kb", 0) * 1024,
"mod_id": mod_id,
"file_id": file_id,
})
except Exception as e:
logger.warning(f"VNV tool lookup failed for mod {mod_id} ({label}): {e}")
return items
def check_already_completed(self) -> dict:
"""
Check which VNV automation steps have already been completed.
@@ -158,11 +312,6 @@ class VNVPostInstallService:
logger.info(msg)
try:
# Ensure authentication
update_progress("Checking Nexus authentication...")
if not self._ensure_auth(progress_callback):
return False, "Nexus authentication required. Please authenticate in Settings."
# Step 1: Copy root mods
update_progress("Step 1/3: Copying root mods to game directory...")
success, msg = self.copy_root_mods()
@@ -253,25 +402,15 @@ class VNVPostInstallService:
return True, "Game already patched (backup exists)"
# Check cache first - look for extracted executable or zip
patcher_path = None
cached_extracted = list(self.cache_dir.glob("*4gb*_extracted/*"))
if cached_extracted:
# Use already extracted executable
for f in cached_extracted:
if f.is_file():
patcher_path = f
logger.info(f"Using cached extracted 4GB patcher: {patcher_path}")
break
if not patcher_path:
cached_files = list(self.cache_dir.glob("*4gb*.zip"))
if cached_files:
patcher_path = cached_files[0]
logger.info(f"Using cached 4GB patcher zip: {patcher_path}")
patcher_path = self._find_cached_4gb_patcher()
if patcher_path:
logger.info(f"Using cached 4GB patcher: {patcher_path}")
if not patcher_path:
# Try to download from Nexus
# Linux version is named "FNV4GB for Proton", not "linux"
if not self._ensure_download_service(progress_callback):
return False, "Nexus authentication required to download the 4GB patcher."
success, patcher_path, msg = self.download_service.download_latest_file(
self.GAME_DOMAIN,
self.LINUX_4GB_PATCHER_MOD_ID,
@@ -394,60 +533,58 @@ class VNVPostInstallService:
return True, "BSA decompression already completed"
if not self.ttw_installer_path or not self.ttw_installer_path.exists():
logger.warning("TTW_Linux_Installer not found, skipping BSA decompression")
return True, "BSA decompression skipped (TTW_Linux_Installer not available)"
from .ttw_installer_service import ensure_ttw_installer_available
# Check cache first
cached_files = list(self.cache_dir.glob("*BSA*.mpi"))
if cached_files:
mpi_path = cached_files[0]
self.ttw_installer_path, message = ensure_ttw_installer_available(progress_callback)
if not self.ttw_installer_path:
return False, f"TTW_Linux_Installer is required for BSA decompression: {message}"
mpi_path = self._find_cached_bsa_mpi()
if mpi_path:
logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}")
else:
# Also check for exact filename match (handles spaces in filename)
exact_path = self.cache_dir / "FNV BSA Decompressor.mpi"
if exact_path.exists():
mpi_path = exact_path
logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}")
else:
# Try to download from Nexus
# Look for files with .mpi extension (TTW installer format)
success, mpi_path, msg = self.download_service.download_latest_file(
package_path = self._find_cached_bsa_package()
if not package_path:
if not self._ensure_download_service(progress_callback):
return False, "Nexus authentication required to download the BSA Decompressor."
success, package_path, msg = self.download_service.download_latest_file(
self.GAME_DOMAIN,
self.FNV_BSA_DECOMPRESSOR_MOD_ID,
self.cache_dir,
file_name_filter=".mpi",
file_name_filter=".zip",
progress_callback=progress_callback
)
if not success:
# Download failed - offer manual download
logger.warning(f"Automatic download failed: {msg}")
if not manual_file_callback:
return False, f"Failed to download BSA Decompressor MPI: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/65854"
return False, f"Failed to download BSA Decompressor package: {msg}\n\nPlease download manually from: https://www.nexusmods.com/newvegas/mods/65854"
instructions = (
"Automatic download failed (requires Nexus Premium).\n\n"
"Please download the FNV BSA Decompressor manually:\n"
"Please download the FNV BSA Decompressor package manually:\n"
"1. Visit: https://www.nexusmods.com/newvegas/mods/65854\n"
"2. Download the .mpi file\n"
"3. Select the downloaded file below"
"2. Download the zip package\n"
"3. Select the downloaded archive below"
)
selected_path = manual_file_callback("BSA Decompressor Required", instructions)
if not selected_path or not selected_path.exists():
return False, "BSA Decompressor package not provided"
if selected_path.suffix.lower() not in {'.zip', '.7z', '.mpi'}:
return False, f"Selected file is not a supported BSA package: {selected_path}"
cached_path = self.cache_dir / selected_path.name
shutil.copy2(selected_path, cached_path)
package_path = cached_path
logger.info(f"Using manually selected BSA package: {package_path}")
mpi_path = manual_file_callback("BSA Decompressor Required", instructions)
if not mpi_path or not mpi_path.exists():
return False, "BSA Decompressor MPI file not provided"
# Validate it's an MPI file
if not mpi_path.suffix.lower() == '.mpi':
return False, f"Selected file is not an MPI file: {mpi_path}"
# Copy to cache for future use
cached_path = self.cache_dir / mpi_path.name
shutil.copy2(mpi_path, cached_path)
mpi_path = cached_path
logger.info(f"Using manually selected BSA Decompressor MPI: {mpi_path}")
if package_path.suffix.lower() == ".mpi":
mpi_path = package_path
else:
if progress_callback:
progress_callback("Preparing BSA decompressor package...")
success, mpi_path, msg = self._extract_bsa_package(package_path)
if not success or not mpi_path:
return False, f"Failed to prepare BSA Decompressor package: {msg}"
logger.info(msg)
# Create temp output directory
with tempfile.TemporaryDirectory() as temp_output:
@@ -455,7 +592,6 @@ class VNVPostInstallService:
# Create config file for TTW_Linux_Installer (handles spaces in paths better)
config_file = self.ttw_installer_path.parent / "ttw-config.json"
import json
config_data = {
"FalloutNVRoot": str(self.game_root),
"MpiPackagePath": str(mpi_path),
@@ -467,6 +603,7 @@ class VNVPostInstallService:
# Run via TTW_Linux_Installer
if progress_callback:
progress_callback("Ensuring TTW_Linux_Installer is available...")
progress_callback("Running BSA decompressor...")
cmd = [

View File

@@ -63,6 +63,7 @@ class WabbajackInstallerService:
install_folder: Path,
shortcut_name: str = "Wabbajack",
enable_gog: bool = True,
existing_appid: Optional[int] = None,
progress_callback: Optional[Callable[[str, int], None]] = None,
log_callback: Optional[Callable[[str], None]] = None
) -> Tuple[bool, Optional[int], Optional[str], Optional[int], Optional[str], Optional[str]]:
@@ -128,34 +129,6 @@ class WabbajackInstallerService:
self.handler.create_dotnet_cache(install_folder)
update_progress(".NET cache created", 3, 20)
# Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf)
# We'll do a full restart after creating the shortcut
update_progress("Stopping Steam to modify shortcuts...", 4, 25)
try:
shutdown_env = _get_clean_subprocess_env()
if _is_steam_deck:
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
elif _is_flatpak:
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if check_result.returncode == 0:
subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
update_progress("Steam stopped", 4, 25)
except Exception as e:
update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25)
# Step 5: Create Steam shortcut using NativeSteamService
update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30)
# Generate launch options with STEAM_COMPAT_MOUNTS
launch_options = ""
try:
@@ -170,27 +143,58 @@ class WabbajackInstallerService:
except Exception as e:
update_progress(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}", 5, 30)
success, app_id = self.steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=str(wabbajack_exe),
start_dir=str(wabbajack_exe.parent),
launch_options=launch_options,
tags=["Jackify"],
proton_version=proton_compat_name
)
if not success or app_id is None:
return False, None, None, None, None, "Failed to create Steam shortcut"
update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30)
if existing_appid is None:
# Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf)
# We'll do a full restart after creating the shortcut
update_progress("Stopping Steam to modify shortcuts...", 4, 25)
try:
shutdown_env = _get_clean_subprocess_env()
# Step 5b: Restart Steam (same pattern as modlist workflows)
update_progress("Restarting Steam...", 5, 35)
def restart_callback(msg):
update_progress(msg, 5, 35)
if _is_steam_deck:
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
elif _is_flatpak:
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
if not robust_steam_restart(progress_callback=restart_callback):
update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35)
subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if check_result.returncode == 0:
subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
update_progress("Steam stopped", 4, 25)
except Exception as e:
update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25)
# Step 5: Create Steam shortcut using NativeSteamService
update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30)
success, app_id = self.steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=str(wabbajack_exe),
start_dir=str(wabbajack_exe.parent),
launch_options=launch_options,
tags=["Jackify"],
proton_version=proton_compat_name
)
if not success or app_id is None:
return False, None, None, None, None, "Failed to create Steam shortcut"
update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30)
# Step 5b: Restart Steam (same pattern as modlist workflows)
update_progress("Restarting Steam...", 5, 35)
def restart_callback(msg):
update_progress(msg, 5, 35)
if not robust_steam_restart(progress_callback=restart_callback):
update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35)
else:
update_progress("Steam restarted successfully", 5, 40)
else:
update_progress("Steam restarted successfully", 5, 40)
app_id = int(existing_appid)
update_progress(f"Reusing existing Steam shortcut with AppID: {app_id}", 5, 30)
# Step 6: Initialize Wine prefix (using same method as modlist workflows)
update_progress("Creating Proton prefix...", 6, 45)
@@ -277,4 +281,3 @@ class WabbajackInstallerService:
if log_callback:
log_callback(f"ERROR: {error_msg}")
return False, None, None, None, None, error_msg

View File

@@ -0,0 +1,34 @@
"""
Detects Creation Club / Anniversary Edition content missing errors in engine output.
"""
import re
from typing import Optional
# Matches CC content file names: ccXXXsse001-name.bsa/esm/esl/esp, ccXXXfo4001-name.ba2, etc.
# No leading \b — filenames often appear with a Data_ prefix (Data_ccbgssse019-...)
# where _ is a word char and would prevent \b from matching.
_CC_FILE_RE = re.compile(
r'cc[a-z]{2,8}\d{3,4}[-\w]*\.(?:bsa|esm|esl|esp|ba2)',
re.IGNORECASE
)
_ERROR_WORDS = frozenset((
'missing', 'required', 'failed', 'unable', 'cannot', 'error', 'not found',
))
def is_cc_content_error(line: str) -> bool:
"""Return True if line indicates a missing CC/AE content file in an error context."""
if not line:
return False
normalized = line.strip().lower()
if not _CC_FILE_RE.search(normalized):
return False
return any(w in normalized for w in _ERROR_WORDS)
def extract_cc_filename(line: str) -> Optional[str]:
"""Return the CC filename from a line, or None if not found."""
m = _CC_FILE_RE.search(line)
return m.group(0) if m else None

View File

@@ -1,8 +1,10 @@
import json
import re
from typing import Optional
from jackify.shared.errors import (
JackifyError, InstallError, OAuthError,
oauth_expired, wabbajack_install_failed, format_technical_context,
game_not_found_for_modlist,
)
@@ -12,7 +14,29 @@ def _ctx_detail(ctx: dict) -> Optional[str]:
return format_technical_context(context=ctx)
def _engine_error(msg: str, ctx: dict) -> InstallError:
"""Map generic engine_error payloads to user-visible, actionable InstallError variants."""
text = (msg or "").strip()
match = re.search(r"can't find game\s+([A-Za-z0-9_:-]+)", text, flags=re.IGNORECASE)
if match:
game_name = match.group(1)
return game_not_found_for_modlist(game_name, detail=text)
return InstallError(
"Install Engine Error",
text or "An install engine error occurred.",
suggestion="Review the error message and retry after correcting the reported issue.",
solutions=[
"Check the exact error message shown above and fix the prerequisite it mentions.",
"Retry the install after restarting Steam.",
"If this persists, check Modlist_Install_workflow.log for the same error text.",
],
technical=_ctx_detail(ctx),
)
_TYPE_MAP = {
"engine_error": _engine_error,
"auth_failed": lambda msg, ctx: oauth_expired(),
"premium_required": lambda msg, ctx: InstallError(
"Nexus Premium Required",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@
"targets": {
".NETCoreApp,Version=v8.0": {},
".NETCoreApp,Version=v8.0/linux-x64": {
"jackify-engine/0.4.8": {
"jackify-engine/0.5.0": {
"dependencies": {
"Markdig": "0.40.0",
"Microsoft.Extensions.Configuration.Json": "9.0.1",
@@ -22,16 +22,16 @@
"SixLabors.ImageSharp": "3.1.6",
"System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.CLI.Builder": "0.4.8",
"Wabbajack.Downloaders.Bethesda": "0.4.8",
"Wabbajack.Downloaders.Dispatcher": "0.4.8",
"Wabbajack.Hashing.xxHash64": "0.4.8",
"Wabbajack.Networking.Discord": "0.4.8",
"Wabbajack.Networking.GitHub": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8",
"Wabbajack.Server.Lib": "0.4.8",
"Wabbajack.Services.OSIntegrated": "0.4.8",
"Wabbajack.VFS": "0.4.8",
"Wabbajack.CLI.Builder": "0.5.0",
"Wabbajack.Downloaders.Bethesda": "0.5.0",
"Wabbajack.Downloaders.Dispatcher": "0.5.0",
"Wabbajack.Hashing.xxHash64": "0.5.0",
"Wabbajack.Networking.Discord": "0.5.0",
"Wabbajack.Networking.GitHub": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0",
"Wabbajack.Server.Lib": "0.5.0",
"Wabbajack.Services.OSIntegrated": "0.5.0",
"Wabbajack.VFS": "0.5.0",
"MegaApiClient": "1.0.0.0",
"runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.23"
},
@@ -1781,7 +1781,7 @@
}
}
},
"Wabbajack.CLI.Builder/0.4.8": {
"Wabbajack.CLI.Builder/0.5.0": {
"dependencies": {
"Microsoft.Extensions.Configuration.Json": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -1791,109 +1791,109 @@
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.Paths": "0.4.8"
"Wabbajack.Paths": "0.5.0"
},
"runtime": {
"Wabbajack.CLI.Builder.dll": {}
}
},
"Wabbajack.Common/0.4.8": {
"Wabbajack.Common/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.Reactive": "6.0.1",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Common.dll": {}
}
},
"Wabbajack.Compiler/0.4.8": {
"Wabbajack.Compiler/0.5.0": {
"dependencies": {
"F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Dispatcher": "0.4.8",
"Wabbajack.Installer": "0.4.8",
"Wabbajack.VFS": "0.4.8",
"Wabbajack.Downloaders.Dispatcher": "0.5.0",
"Wabbajack.Installer": "0.5.0",
"Wabbajack.VFS": "0.5.0",
"ini-parser-netstandard": "2.5.2"
},
"runtime": {
"Wabbajack.Compiler.dll": {}
}
},
"Wabbajack.Compression.BSA/0.4.8": {
"Wabbajack.Compression.BSA/0.5.0": {
"dependencies": {
"K4os.Compression.LZ4.Streams": "1.3.8",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.4.8",
"Wabbajack.DTOs": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.DTOs": "0.5.0"
},
"runtime": {
"Wabbajack.Compression.BSA.dll": {}
}
},
"Wabbajack.Compression.Zip/0.4.8": {
"Wabbajack.Compression.Zip/0.5.0": {
"dependencies": {
"Wabbajack.IO.Async": "0.4.8"
"Wabbajack.IO.Async": "0.5.0"
},
"runtime": {
"Wabbajack.Compression.Zip.dll": {}
}
},
"Wabbajack.Configuration/0.4.8": {
"Wabbajack.Configuration/0.5.0": {
"runtime": {
"Wabbajack.Configuration.dll": {}
}
},
"Wabbajack.Downloaders.Bethesda/0.4.8": {
"Wabbajack.Downloaders.Bethesda/0.5.0": {
"dependencies": {
"LibAES-CTR": "1.1.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.BethesdaNet": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.BethesdaNet": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Bethesda.dll": {}
}
},
"Wabbajack.Downloaders.Dispatcher/0.4.8": {
"Wabbajack.Downloaders.Dispatcher/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Bethesda": "0.4.8",
"Wabbajack.Downloaders.GameFile": "0.4.8",
"Wabbajack.Downloaders.GoogleDrive": "0.4.8",
"Wabbajack.Downloaders.Http": "0.4.8",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Downloaders.Manual": "0.4.8",
"Wabbajack.Downloaders.MediaFire": "0.4.8",
"Wabbajack.Downloaders.Mega": "0.4.8",
"Wabbajack.Downloaders.ModDB": "0.4.8",
"Wabbajack.Downloaders.Nexus": "0.4.8",
"Wabbajack.Downloaders.VerificationCache": "0.4.8",
"Wabbajack.Downloaders.WabbajackCDN": "0.4.8",
"Wabbajack.Networking.WabbajackClientApi": "0.4.8"
"Wabbajack.Downloaders.Bethesda": "0.5.0",
"Wabbajack.Downloaders.GameFile": "0.5.0",
"Wabbajack.Downloaders.GoogleDrive": "0.5.0",
"Wabbajack.Downloaders.Http": "0.5.0",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Downloaders.Manual": "0.5.0",
"Wabbajack.Downloaders.MediaFire": "0.5.0",
"Wabbajack.Downloaders.Mega": "0.5.0",
"Wabbajack.Downloaders.ModDB": "0.5.0",
"Wabbajack.Downloaders.Nexus": "0.5.0",
"Wabbajack.Downloaders.VerificationCache": "0.5.0",
"Wabbajack.Downloaders.WabbajackCDN": "0.5.0",
"Wabbajack.Networking.WabbajackClientApi": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Dispatcher.dll": {}
}
},
"Wabbajack.Downloaders.GameFile/0.4.8": {
"Wabbajack.Downloaders.GameFile/0.5.0": {
"dependencies": {
"GameFinder.StoreHandlers.EADesktop": "4.5.0",
"GameFinder.StoreHandlers.EGS": "4.5.0",
@@ -1903,360 +1903,361 @@
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.VFS": "0.4.8"
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.VFS": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.GameFile.dll": {}
}
},
"Wabbajack.Downloaders.GoogleDrive/0.4.8": {
"Wabbajack.Downloaders.GoogleDrive/0.5.0": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.AspNetCore.Http.Extensions": "2.3.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.8",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.GoogleDrive.dll": {}
}
},
"Wabbajack.Downloaders.Http/0.4.8": {
"Wabbajack.Downloaders.Http/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.8",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.BethesdaNet": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.BethesdaNet": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Http.dll": {}
}
},
"Wabbajack.Downloaders.Interfaces/0.4.8": {
"Wabbajack.Downloaders.Interfaces/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Compression.Zip": "0.4.8",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.Compression.Zip": "0.5.0",
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Interfaces.dll": {}
}
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.8": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.0": {
"dependencies": {
"F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {}
}
},
"Wabbajack.Downloaders.Manual/0.4.8": {
"Wabbajack.Downloaders.Manual/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Manual.dll": {}
}
},
"Wabbajack.Downloaders.MediaFire/0.4.8": {
"Wabbajack.Downloaders.MediaFire/0.5.0": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.MediaFire.dll": {}
}
},
"Wabbajack.Downloaders.Mega/0.4.8": {
"Wabbajack.Downloaders.Mega/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Mega.dll": {}
}
},
"Wabbajack.Downloaders.ModDB/0.4.8": {
"Wabbajack.Downloaders.ModDB/0.5.0": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.ModDB.dll": {}
}
},
"Wabbajack.Downloaders.Nexus/0.4.8": {
"Wabbajack.Downloaders.Nexus/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Hashing.xxHash64": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8",
"Wabbajack.Networking.NexusApi": "0.4.8",
"Wabbajack.Paths": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Hashing.xxHash64": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0",
"Wabbajack.Networking.NexusApi": "0.5.0",
"Wabbajack.Paths": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.Nexus.dll": {}
}
},
"Wabbajack.Downloaders.VerificationCache/0.4.8": {
"Wabbajack.Downloaders.VerificationCache/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Stub.System.Data.SQLite.Core.NetStandard": "1.0.119",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.VerificationCache.dll": {}
}
},
"Wabbajack.Downloaders.WabbajackCDN/0.4.8": {
"Wabbajack.Downloaders.WabbajackCDN/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Toolkit.HighPerformance": "7.1.2",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.RateLimiter": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.RateLimiter": "0.5.0"
},
"runtime": {
"Wabbajack.Downloaders.WabbajackCDN.dll": {}
}
},
"Wabbajack.DTOs/0.4.8": {
"Wabbajack.DTOs/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Hashing.xxHash64": "0.4.8",
"Wabbajack.Paths": "0.4.8"
"Wabbajack.Hashing.xxHash64": "0.5.0",
"Wabbajack.Paths": "0.5.0"
},
"runtime": {
"Wabbajack.DTOs.dll": {}
}
},
"Wabbajack.FileExtractor/0.4.8": {
"Wabbajack.FileExtractor/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"OMODFramework": "3.0.1",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Compression.BSA": "0.4.8",
"Wabbajack.Hashing.PHash": "0.4.8",
"Wabbajack.Paths": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Compression.BSA": "0.5.0",
"Wabbajack.Hashing.PHash": "0.5.0",
"Wabbajack.Paths": "0.5.0"
},
"runtime": {
"Wabbajack.FileExtractor.dll": {}
}
},
"Wabbajack.Hashing.PHash/0.4.8": {
"Wabbajack.Hashing.PHash/0.5.0": {
"dependencies": {
"BCnEncoder.Net.ImageSharp": "1.1.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Shipwreck.Phash": "0.5.0",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.4.8",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Paths": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Paths": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Hashing.PHash.dll": {}
}
},
"Wabbajack.Hashing.xxHash64/0.4.8": {
"Wabbajack.Hashing.xxHash64/0.5.0": {
"dependencies": {
"Wabbajack.Paths": "0.4.8",
"Wabbajack.RateLimiter": "0.4.8"
"Wabbajack.Paths": "0.5.0",
"Wabbajack.RateLimiter": "0.5.0"
},
"runtime": {
"Wabbajack.Hashing.xxHash64.dll": {}
}
},
"Wabbajack.Installer/0.4.8": {
"Wabbajack.Installer/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Octopus.Octodiff": "2.0.548",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Downloaders.Dispatcher": "0.4.8",
"Wabbajack.Downloaders.GameFile": "0.4.8",
"Wabbajack.FileExtractor": "0.4.8",
"Wabbajack.Networking.WabbajackClientApi": "0.4.8",
"Wabbajack.Paths": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8",
"Wabbajack.VFS": "0.4.8",
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Downloaders.Dispatcher": "0.5.0",
"Wabbajack.Downloaders.GameFile": "0.5.0",
"Wabbajack.FileExtractor": "0.5.0",
"Wabbajack.Networking.NexusApi": "0.5.0",
"Wabbajack.Networking.WabbajackClientApi": "0.5.0",
"Wabbajack.Paths": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0",
"Wabbajack.VFS": "0.5.0",
"ini-parser-netstandard": "2.5.2"
},
"runtime": {
"Wabbajack.Installer.dll": {}
}
},
"Wabbajack.IO.Async/0.4.8": {
"Wabbajack.IO.Async/0.5.0": {
"runtime": {
"Wabbajack.IO.Async.dll": {}
}
},
"Wabbajack.Networking.BethesdaNet/0.4.8": {
"Wabbajack.Networking.BethesdaNet/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Networking.BethesdaNet.dll": {}
}
},
"Wabbajack.Networking.Discord/0.4.8": {
"Wabbajack.Networking.Discord/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Networking.Discord.dll": {}
}
},
"Wabbajack.Networking.GitHub/0.4.8": {
"Wabbajack.Networking.GitHub/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.Networking.GitHub.dll": {}
}
},
"Wabbajack.Networking.Http/0.4.8": {
"Wabbajack.Networking.Http/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Http": "9.0.1",
"Microsoft.Extensions.Logging": "9.0.1",
"Wabbajack.Configuration": "0.4.8",
"Wabbajack.Downloaders.Interfaces": "0.4.8",
"Wabbajack.Hashing.xxHash64": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8",
"Wabbajack.Paths": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8"
"Wabbajack.Configuration": "0.5.0",
"Wabbajack.Downloaders.Interfaces": "0.5.0",
"Wabbajack.Hashing.xxHash64": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0",
"Wabbajack.Paths": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0"
},
"runtime": {
"Wabbajack.Networking.Http.dll": {}
}
},
"Wabbajack.Networking.Http.Interfaces/0.4.8": {
"Wabbajack.Networking.Http.Interfaces/0.5.0": {
"dependencies": {
"Wabbajack.Hashing.xxHash64": "0.4.8"
"Wabbajack.Hashing.xxHash64": "0.5.0"
},
"runtime": {
"Wabbajack.Networking.Http.Interfaces.dll": {}
}
},
"Wabbajack.Networking.NexusApi/0.4.8": {
"Wabbajack.Networking.NexusApi/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Networking.Http": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8",
"Wabbajack.Networking.WabbajackClientApi": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Networking.Http": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0",
"Wabbajack.Networking.WabbajackClientApi": "0.5.0"
},
"runtime": {
"Wabbajack.Networking.NexusApi.dll": {}
}
},
"Wabbajack.Networking.WabbajackClientApi/0.4.8": {
"Wabbajack.Networking.WabbajackClientApi/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0",
"Wabbajack.Common": "0.4.8",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8",
"Wabbajack.VFS.Interfaces": "0.4.8",
"Wabbajack.Common": "0.5.0",
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0",
"Wabbajack.VFS.Interfaces": "0.5.0",
"YamlDotNet": "16.3.0"
},
"runtime": {
"Wabbajack.Networking.WabbajackClientApi.dll": {}
}
},
"Wabbajack.Paths/0.4.8": {
"Wabbajack.Paths/0.5.0": {
"runtime": {
"Wabbajack.Paths.dll": {}
}
},
"Wabbajack.Paths.IO/0.4.8": {
"Wabbajack.Paths.IO/0.5.0": {
"dependencies": {
"Wabbajack.Paths": "0.4.8",
"Wabbajack.Paths": "0.5.0",
"shortid": "4.0.0"
},
"runtime": {
"Wabbajack.Paths.IO.dll": {}
}
},
"Wabbajack.RateLimiter/0.4.8": {
"Wabbajack.RateLimiter/0.5.0": {
"runtime": {
"Wabbajack.RateLimiter.dll": {}
}
},
"Wabbajack.Server.Lib/0.4.8": {
"Wabbajack.Server.Lib/0.5.0": {
"dependencies": {
"FluentFTP": "52.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -2264,58 +2265,58 @@
"Nettle": "3.0.0",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.4.8",
"Wabbajack.Networking.Http.Interfaces": "0.4.8",
"Wabbajack.Services.OSIntegrated": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.Networking.Http.Interfaces": "0.5.0",
"Wabbajack.Services.OSIntegrated": "0.5.0"
},
"runtime": {
"Wabbajack.Server.Lib.dll": {}
}
},
"Wabbajack.Services.OSIntegrated/0.4.8": {
"Wabbajack.Services.OSIntegrated/0.5.0": {
"dependencies": {
"DeviceId": "6.8.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Compiler": "0.4.8",
"Wabbajack.Downloaders.Dispatcher": "0.4.8",
"Wabbajack.Installer": "0.4.8",
"Wabbajack.Networking.BethesdaNet": "0.4.8",
"Wabbajack.Networking.Discord": "0.4.8",
"Wabbajack.VFS": "0.4.8"
"Wabbajack.Compiler": "0.5.0",
"Wabbajack.Downloaders.Dispatcher": "0.5.0",
"Wabbajack.Installer": "0.5.0",
"Wabbajack.Networking.BethesdaNet": "0.5.0",
"Wabbajack.Networking.Discord": "0.5.0",
"Wabbajack.VFS": "0.5.0"
},
"runtime": {
"Wabbajack.Services.OSIntegrated.dll": {}
}
},
"Wabbajack.VFS/0.4.8": {
"Wabbajack.VFS/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6",
"System.Data.SQLite.Core": "1.0.119",
"Wabbajack.Common": "0.4.8",
"Wabbajack.FileExtractor": "0.4.8",
"Wabbajack.Hashing.PHash": "0.4.8",
"Wabbajack.Hashing.xxHash64": "0.4.8",
"Wabbajack.Paths": "0.4.8",
"Wabbajack.Paths.IO": "0.4.8",
"Wabbajack.VFS.Interfaces": "0.4.8"
"Wabbajack.Common": "0.5.0",
"Wabbajack.FileExtractor": "0.5.0",
"Wabbajack.Hashing.PHash": "0.5.0",
"Wabbajack.Hashing.xxHash64": "0.5.0",
"Wabbajack.Paths": "0.5.0",
"Wabbajack.Paths.IO": "0.5.0",
"Wabbajack.VFS.Interfaces": "0.5.0"
},
"runtime": {
"Wabbajack.VFS.dll": {}
}
},
"Wabbajack.VFS.Interfaces/0.4.8": {
"Wabbajack.VFS.Interfaces/0.5.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.4.8",
"Wabbajack.Hashing.xxHash64": "0.4.8",
"Wabbajack.Paths": "0.4.8"
"Wabbajack.DTOs": "0.5.0",
"Wabbajack.Hashing.xxHash64": "0.5.0",
"Wabbajack.Paths": "0.5.0"
},
"runtime": {
"Wabbajack.VFS.Interfaces.dll": {}
@@ -2332,7 +2333,7 @@
}
},
"libraries": {
"jackify-engine/0.4.8": {
"jackify-engine/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
@@ -3021,202 +3022,202 @@
"path": "yamldotnet/16.3.0",
"hashPath": "yamldotnet.16.3.0.nupkg.sha512"
},
"Wabbajack.CLI.Builder/0.4.8": {
"Wabbajack.CLI.Builder/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Common/0.4.8": {
"Wabbajack.Common/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compiler/0.4.8": {
"Wabbajack.Compiler/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.BSA/0.4.8": {
"Wabbajack.Compression.BSA/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.Zip/0.4.8": {
"Wabbajack.Compression.Zip/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Configuration/0.4.8": {
"Wabbajack.Configuration/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Bethesda/0.4.8": {
"Wabbajack.Downloaders.Bethesda/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Dispatcher/0.4.8": {
"Wabbajack.Downloaders.Dispatcher/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GameFile/0.4.8": {
"Wabbajack.Downloaders.GameFile/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GoogleDrive/0.4.8": {
"Wabbajack.Downloaders.GoogleDrive/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Http/0.4.8": {
"Wabbajack.Downloaders.Http/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Interfaces/0.4.8": {
"Wabbajack.Downloaders.Interfaces/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.4.8": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Manual/0.4.8": {
"Wabbajack.Downloaders.Manual/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.MediaFire/0.4.8": {
"Wabbajack.Downloaders.MediaFire/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Mega/0.4.8": {
"Wabbajack.Downloaders.Mega/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.ModDB/0.4.8": {
"Wabbajack.Downloaders.ModDB/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Nexus/0.4.8": {
"Wabbajack.Downloaders.Nexus/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.VerificationCache/0.4.8": {
"Wabbajack.Downloaders.VerificationCache/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.WabbajackCDN/0.4.8": {
"Wabbajack.Downloaders.WabbajackCDN/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.DTOs/0.4.8": {
"Wabbajack.DTOs/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.FileExtractor/0.4.8": {
"Wabbajack.FileExtractor/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.PHash/0.4.8": {
"Wabbajack.Hashing.PHash/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.xxHash64/0.4.8": {
"Wabbajack.Hashing.xxHash64/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Installer/0.4.8": {
"Wabbajack.Installer/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.IO.Async/0.4.8": {
"Wabbajack.IO.Async/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.BethesdaNet/0.4.8": {
"Wabbajack.Networking.BethesdaNet/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Discord/0.4.8": {
"Wabbajack.Networking.Discord/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.GitHub/0.4.8": {
"Wabbajack.Networking.GitHub/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http/0.4.8": {
"Wabbajack.Networking.Http/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http.Interfaces/0.4.8": {
"Wabbajack.Networking.Http.Interfaces/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.NexusApi/0.4.8": {
"Wabbajack.Networking.NexusApi/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.WabbajackClientApi/0.4.8": {
"Wabbajack.Networking.WabbajackClientApi/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths/0.4.8": {
"Wabbajack.Paths/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths.IO/0.4.8": {
"Wabbajack.Paths.IO/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.RateLimiter/0.4.8": {
"Wabbajack.RateLimiter/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Server.Lib/0.4.8": {
"Wabbajack.Server.Lib/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Services.OSIntegrated/0.4.8": {
"Wabbajack.Services.OSIntegrated/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS/0.4.8": {
"Wabbajack.VFS/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS.Interfaces/0.4.8": {
"Wabbajack.VFS.Interfaces/0.5.0": {
"type": "project",
"serviceable": false,
"sha512": ""

Binary file not shown.

View File

@@ -0,0 +1,479 @@
"""
CLI manual download flow.
Handles the interactive terminal experience when the engine emits a batch of
files requiring manual download. Uses the same backend services as the GUI path
(ManualDownloadManager, DownloadWatcherService, FileValidatorService) but
outputs status to the terminal and reads simple keyboard commands.
"""
import os
import sys
import queue
import shutil
import threading
import logging
import time
from pathlib import Path
from typing import Callable, Optional
from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem
from jackify.backend.services.download_watcher_service import WatcherConfig
from jackify.backend.handlers.config_handler import ConfigHandler
logger = logging.getLogger(__name__)
def _fmt_size(n: int) -> str:
if n <= 0:
return ''
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} TB"
class CliManualDownloadFlow:
"""
Blocking CLI flow for manual downloads. Returns when all items are done
(complete or skipped) and the continue command has been written to the
engine's stdin pipe.
"""
def __init__(
self,
items: list[dict],
loop_iteration: int,
download_dir: Path,
stdin_write: Callable[[str], bool],
output_callback: Optional[Callable[[str], None]] = None,
concurrent_limit: int = 2,
):
self._stdin_write = stdin_write
self._output = output_callback or print
self._done_event = threading.Event()
self._config_handler = ConfigHandler()
self._command_queue: queue.Queue[str] = queue.Queue()
self._last_rendered_snapshot: Optional[str] = None
self._last_render_time = 0.0
self._completed_successfully = False
self._interactive_tty = bool(getattr(sys.__stdout__, "isatty", lambda: False)())
self._terminal = sys.__stdout__ if self._interactive_tty else None
self._screen_lines = 0
self._status_dirty = True
self._notices: list[str] = []
self._startup_render_blocked = False
configured_watch = self._config_handler.get("manual_download_watch_directory", None)
watch_dir = None
if configured_watch:
cfg_path = Path(str(configured_watch)).expanduser()
if cfg_path.is_dir():
watch_dir = cfg_path
if watch_dir is None:
xdg_dl = os.environ.get('XDG_DOWNLOAD_DIR', '')
watch_dir = Path(xdg_dl) if (xdg_dl and Path(xdg_dl).is_dir()) else Path.home() / 'Downloads'
self._manager = ManualDownloadManager(
modlist_download_dir=download_dir,
watch_directory=watch_dir,
concurrent_limit=concurrent_limit,
on_item_updated=self._on_item_updated,
on_send_continue=self._on_all_done,
on_all_done=self._on_all_done_counts,
)
self._manager.load_items(items, loop_iteration)
self._total = len(self._manager.items)
self._watch_dir = watch_dir
def run(self) -> bool:
"""Block until all items are complete/skipped and continue is sent."""
if not self._confirm_start():
self._output("Manual download phase cancelled.")
return False
self._startup_render_blocked = True
try:
self._manager.start()
finally:
self._startup_render_blocked = False
self._render_status(force=True)
if sys.stdin.isatty():
threading.Thread(target=self._read_commands, daemon=True).start()
else:
self._output("[interactive commands unavailable: non-interactive stdin]")
while not self._done_event.is_set():
self._handle_pending_commands()
self._render_status()
self._done_event.wait(timeout=0.25)
self._manager.stop()
return self._completed_successfully
def _on_item_updated(self, item: DownloadItem) -> None:
status = {
'browser_opened': 'browser opened',
'validating': 'validating...',
'complete': '[OK]',
'deferred': '[deferred]',
'skipped': '[skipped]',
'error': '[error]',
}.get(item.status, item.status)
if not self._interactive_tty or item.status in ('error',):
self._output(f" {status:>14} {item.file_name}")
if item.error_message:
self._emit_notice(f"reason: {item.error_message}")
self._status_dirty = True
if self._startup_render_blocked or self._interactive_tty:
return
self._render_status(force=True)
def _on_all_done_counts(self, completed: int, skipped: int) -> None:
self._completed_successfully = completed == self._total and skipped == 0
self._emit_notice(f"All downloads done: {completed} complete, {skipped} skipped")
self._emit_notice("Signalling engine to continue...")
def _on_all_done(self) -> None:
self._stdin_write('{"command":"continue"}')
self._done_event.set()
def _retry_deferred(self) -> None:
retried = 0
with self._manager._lock:
for item in self._manager._items:
if item.status in ('deferred', 'skipped'):
item.status = 'pending'
item.needs_user_retry = False
item.error_message = None
retried += 1
if retried == 0:
self._emit_notice("[no deferred items to retry]")
return
self._emit_notice(f"[retried {retried} deferred item(s)]")
self._manager._open_next_tabs()
def _set_watch_folder(self, raw: str) -> None:
candidate = Path(raw).expanduser()
if not candidate.is_dir():
self._emit_notice(f"[invalid directory: {candidate}]")
return
self._watch_dir = candidate
self._manager._watch_dir = candidate
self._manager._watcher._config.watch_directory = candidate
# Force a fresh scan baseline for the newly-selected directory.
self._manager._watcher._known = {}
try:
self._config_handler.set("manual_download_watch_directory", str(candidate))
self._config_handler.save_config()
except Exception:
logger.debug("Could not persist manual_download_watch_directory", exc_info=True)
self._emit_notice(f"[watch folder set to {candidate}]")
self._status_dirty = True
self._render_status(force=True)
def _set_concurrency(self, raw: str) -> None:
try:
value = int(raw)
except ValueError:
self._emit_notice(f"[invalid number: {raw}]")
return
value = max(1, min(5, value))
self._manager.set_concurrent_limit(value)
try:
self._config_handler.set("manual_download_concurrent_limit", value)
self._config_handler.save_config()
except Exception:
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
self._emit_notice(f"[concurrency set to {value}]")
self._status_dirty = True
self._render_status(force=True)
def _read_commands(self) -> None:
while not self._done_event.is_set():
try:
line = sys.stdin.readline()
except Exception:
return
if not line:
return
self._command_queue.put(line.strip())
def _handle_pending_commands(self) -> None:
while True:
try:
command = self._command_queue.get_nowait()
except queue.Empty:
return
if not command:
continue
if self._handle_command(command):
return
def _handle_command(self, command: str) -> bool:
parts = command.split(maxsplit=1)
action = parts[0].lower()
arg = parts[1] if len(parts) > 1 else ""
if action == 'help':
self._print_help()
elif action in ('list', 'ls', 'status'):
self._render_status(force=True)
elif action == 'open':
self._open_item(arg)
elif action in ('defer', 'skip'):
self._defer_item(arg)
elif action == 'retry':
self._retry_deferred()
elif action == 'watch':
if not arg:
self._emit_notice(f"[watch folder: {self._watch_dir}]")
else:
self._set_watch_folder(arg)
elif action == 'pause':
self._manager.pause()
self._emit_notice("[paused]")
elif action == 'resume':
self._manager.resume()
self._emit_notice("[resumed]")
elif action in ('concurrency', 'tabs'):
if not arg:
self._emit_notice(f"[concurrency: {self._manager._limit}]")
else:
self._set_concurrency(arg)
elif action in ('quit', 'exit'):
self._emit_notice("Stopping - downloaded files are preserved for resume.")
self._manager.stop()
self._done_event.set()
return True
else:
self._emit_notice(f"[unknown command: {command}]")
self._print_help()
return False
def _print_help(self) -> None:
self._output("")
self._output("Commands:")
self._output(" list Show current status")
self._output(" open <index> Re-open a file in the browser")
self._output(" defer <index> Defer an active file")
self._output(" retry Retry all deferred files")
self._output(" watch <path> Change watched download folder")
self._output(" pause | resume Pause or resume auto-open")
self._output(" concurrency <1-5> Set concurrent browser tabs")
self._output(" quit Stop and preserve progress")
self._output("")
def _render_status(self, force: bool = False) -> None:
now = time.monotonic()
if not force and not self._status_dirty and (now - self._last_render_time) < 2.0:
return
with self._manager._lock:
items = list(self._manager._items)
complete = sum(1 for item in items if item.status == 'complete')
deferred = sum(1 for item in items if item.status in ('deferred', 'skipped'))
active = sum(1 for item in items if item.status == 'browser_opened')
validating = sum(1 for item in items if item.status == 'validating')
pending = sum(1 for item in items if item.status == 'pending')
paused = self._manager._paused
remaining = self._total - complete - deferred
snapshot = (
f"Watch: {self._watch_dir} | Complete: {complete}/{self._total} | "
f"Active: {active} | Validating: {validating} | Pending: {pending} | "
f"Deferred: {deferred} | Remaining: {remaining} | "
f"Tabs: {self._manager._limit} | {'Paused' if paused else 'Running'}"
)
if not force and snapshot == self._last_rendered_snapshot:
return
self._last_rendered_snapshot = snapshot
self._last_render_time = now
self._status_dirty = False
recheck_note = None
if self._manager._items:
first = self._manager._items[0]
if first.loop_iteration > 1:
recheck_note = f"Recheck {first.loop_iteration} - still missing: {self._total}"
lines = [
"",
*self._boxed_lines(
"Jackify CLI Download Manager",
[
f"Files required: {self._total}",
f"Concurrent browser tabs: {self._manager._limit}",
f"Watching: {self._watch_dir}",
*( [recheck_note] if recheck_note else [] ),
],
),
*self._boxed_lines(
"Action Required",
[
"Check your browser now.",
"Jackify may have opened Nexus download pages in the background.",
"Type `help` at any time for available commands.",
],
),
*self._boxed_lines("Status", [snapshot]),
*self._boxed_lines(
"Downloads",
[self._format_table_header(), *[self._format_item(item) for item in self._visible_items(items)]],
),
*self._boxed_lines(
"Commands",
["help | list | open <index> | defer <index> | retry | watch <path> | pause | resume | concurrency <1-5> | quit"],
),
*self._boxed_lines("Notices", self._notices[-3:] or ["None"]),
"",
]
if self._interactive_tty and self._terminal is not None:
self._redraw_terminal(lines)
else:
for line in lines:
self._output(line)
def _visible_items(self, items: list[DownloadItem]) -> list[DownloadItem]:
priority = {'browser_opened': 0, 'validating': 1, 'pending': 2, 'deferred': 3, 'skipped': 4, 'error': 5, 'complete': 6}
return sorted(items, key=lambda item: (priority.get(item.status, 9), item.index))[:14]
def _format_item(self, item: DownloadItem) -> str:
status = {
'browser_opened': 'OPEN ',
'validating': 'CHECK',
'pending': 'WAIT ',
'deferred': 'DEFER',
'skipped': 'SKIP ',
'error': 'ERROR',
'complete': 'DONE ',
}.get(item.status, item.status[:5].upper())
size = _fmt_size(item.expected_size).rjust(7) if item.expected_size else ' ' * 7
mod_name = self._truncate(item.mod_name or "", 24).ljust(24)
file_name = self._truncate(item.file_name, 32).ljust(32)
return f"{item.index:>3}/{item.total:<3} {status} {size} {mod_name} {file_name}"
def _find_item(self, raw: str) -> Optional[DownloadItem]:
if not raw:
self._emit_notice("[missing item index]")
return None
if raw.isdigit():
target_index = int(raw)
for item in self._manager.items:
if item.index == target_index:
return item
low = raw.lower()
for item in self._manager.items:
if item.file_name.lower() == low:
return item
self._emit_notice(f"[item not found: {raw}]")
return None
def _open_item(self, raw: str) -> None:
item = self._find_item(raw)
if not item:
return
if self._manager.reopen_item(item.file_name):
self._emit_notice(f"[opened] {item.file_name}")
else:
self._emit_notice(f"[could not open] {item.file_name}")
def _defer_item(self, raw: str) -> None:
item = self._find_item(raw)
if not item:
return
if item.status not in ('browser_opened', 'pending', 'error'):
self._emit_notice(f"[cannot defer item in state: {item.status}]")
return
self._manager.skip_item(item.file_name)
self._emit_notice(f"[deferred] {item.file_name}")
self._status_dirty = True
def _confirm_start(self) -> bool:
self._output("")
for line in self._boxed_lines(
"Non-Premium Manual Download Flow",
[
"Jackify detected that manual Nexus downloads are required.",
"It will open Nexus pages in your browser, watch your download folder,",
"validate files automatically, and resume once everything is present.",
"",
"Key commands:",
"concurrency 1-5 Change simultaneous browser tabs",
"defer <index> Skip an item for now",
"retry Reopen deferred items",
"watch <path> Change monitored download folder",
],
):
self._output(line)
if not sys.stdin.isatty():
return True
self._output("Continue? [Y/n]: ")
try:
response = (sys.stdin.readline() or "").strip().lower()
except Exception:
return False
return response in ("", "y", "yes")
def _redraw_terminal(self, lines: list[str]) -> None:
if self._terminal is None:
return
terminal_width = self._terminal_columns()
if self._screen_lines:
self._terminal.write(f"\x1b[{self._screen_lines}F")
self._terminal.write("\x1b[J")
for line in lines:
self._terminal.write(line[: max(1, terminal_width - 1)] + "\n")
self._terminal.flush()
self._screen_lines = len(lines)
@staticmethod
def _truncate(value: str, width: int) -> str:
if len(value) <= width:
return value
if width <= 3:
return value[:width]
return value[: width - 3] + "..."
@staticmethod
def _format_table_header() -> str:
return "Idx State Size Mod File"
def _boxed_lines(self, title: str, rows: list[str], width: Optional[int] = None) -> list[str]:
width = width or max(60, min(100, self._terminal_columns() - 2))
inner_width = max(20, width - 4)
top = "+" + "-" * (width - 2) + "+"
rendered = [top, f"| {self._truncate(title, inner_width).ljust(inner_width)} |"]
if rows:
rendered.append("|" + "-" * (width - 2) + "|")
for row in rows:
rendered.append(f"| {self._truncate(row, inner_width).ljust(inner_width)} |")
rendered.append(top)
return rendered
def _terminal_columns(self) -> int:
return shutil.get_terminal_size(fallback=(100, 24)).columns
def _emit_notice(self, message: str) -> None:
if self._interactive_tty:
self._notices.append(message)
self._status_dirty = True
return
self._output(message)
def run_cli_manual_download_phase(
events: list[dict],
loop_iteration: int,
download_dir: Path,
stdin_write: Callable[[str], bool],
output_callback: Optional[Callable[[str], None]] = None,
concurrent_limit: int = 2,
) -> bool:
"""
Entry point called from modlist_service_installation when the engine emits
a manual_download_list_complete event. Blocks until done.
"""
flow = CliManualDownloadFlow(
items=events,
loop_iteration=loop_iteration,
download_dir=download_dir,
stdin_write=stdin_write,
output_callback=output_callback,
concurrent_limit=concurrent_limit,
)
return flow.run()

View File

@@ -0,0 +1,140 @@
"""CLI helpers for VNV manual-download handling."""
from pathlib import Path
from typing import Callable, Optional
from jackify.backend.services.nexus_premium_service import NexusPremiumService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.frontends.cli.commands.manual_download_flow import run_cli_manual_download_phase
from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus
def _is_explicitly_non_premium(service: VNVPostInstallService) -> bool:
auth_token = service.auth_service.get_auth_token()
auth_method = service.auth_service.get_auth_method()
if not auth_token or not auth_method:
return False
is_premium, username = NexusPremiumService().check_premium_status(
auth_token,
is_oauth=auth_method == "oauth",
)
return username is not None and not is_premium
def _missing_manual_items(service: VNVPostInstallService) -> list[dict]:
completed = service.check_already_completed()
include_bsa = not completed["bsa_decompressed"] and not (
service._find_cached_bsa_mpi() or service._find_cached_bsa_package()
)
include_4gb = not completed["4gb_patch"] and not service._find_cached_4gb_patcher()
if not include_4gb and not include_bsa:
return []
items = service.get_manual_download_items(include_bsa=include_bsa)
if include_4gb:
return items
return [item for item in items if int(item.get("mod_id", 0)) != service.LINUX_4GB_PATCHER_MOD_ID]
def ensure_vnv_cli_manual_downloads(
service: VNVPostInstallService,
output_callback: Optional[Callable[[str], None]] = None,
) -> bool:
if not _is_explicitly_non_premium(service):
return True
items = _missing_manual_items(service)
if not items:
return True
output = output_callback or print
output("")
output("VNV requires manual Nexus downloads for this account. Opening Jackify CLI Download Manager...")
return run_cli_manual_download_phase(
events=items,
loop_iteration=1,
download_dir=service.cache_dir,
stdin_write=lambda _payload: True,
output_callback=output,
concurrent_limit=2,
)
def build_vnv_cli_manual_file_callback(
service: VNVPostInstallService,
output_callback: Optional[Callable[[str], None]] = None,
):
output = output_callback or print
manual_items = service.get_manual_download_items(include_bsa=True)
def _cached_file_for_title(title: str) -> Optional[Path]:
if "4GB" in title:
return service._find_cached_4gb_patcher()
return service._find_cached_bsa_mpi() or service._find_cached_bsa_package()
def _manual_file_callback(title: str, instructions: str) -> Optional[Path]:
cached = _cached_file_for_title(title)
if cached:
return cached
mod_id = (
service.LINUX_4GB_PATCHER_MOD_ID
if "4GB" in title
else service.FNV_BSA_DECOMPRESSOR_MOD_ID
)
item = next((entry for entry in manual_items if int(entry.get("mod_id", 0)) == mod_id), None)
if not item:
output("")
output(instructions)
return None
output("")
output(f"{title} - opening Jackify CLI Download Manager...")
success = run_cli_manual_download_phase(
events=[item],
loop_iteration=1,
download_dir=service.cache_dir,
stdin_write=lambda _payload: True,
output_callback=output,
concurrent_limit=1,
)
if not success:
return None
return _cached_file_for_title(title)
return _manual_file_callback
def create_vnv_cli_progress_callback(
output_callback: Optional[Callable[[str], None]] = None,
) -> tuple[Callable[[str], None], Callable[[], None]]:
"""Create a CLI progress callback with a pulser for indeterminate VNV stages."""
output = output_callback or print
pulser = CliIndeterminateStatus()
def _should_pulse(message: str) -> bool:
lowered = message.lower()
if "%" in lowered:
return False
if "assets processed:" in lowered:
return False
if "decompressing bsa files:" in lowered:
return False
pulse_markers = (
"running vnv post-install automation",
"running bsa decompressor",
"running 4gb patcher",
"preparing bsa decompressor package",
"extracting bsa package",
"ensuring ttw_linux_installer is available",
"checking for post-install automation",
"finalizing post-install configuration",
)
return any(marker in lowered for marker in pulse_markers)
def _progress(message: str) -> None:
text = (message or "").strip()
if not text:
return
if _should_pulse(text):
pulser.set(text)
return
pulser.stop()
output(text)
return _progress, pulser.close

View File

@@ -150,16 +150,103 @@ class AdditionalMenuHandler:
else:
output_path = Path(output_path).expanduser()
# Check if output directory already has content — mirror GUI behaviour
if output_path.exists() and output_path.is_dir():
try:
has_files = any(output_path.iterdir())
except Exception:
has_files = False
if has_files:
print(f"\n{COLOR_WARNING}The TTW output directory already exists and contains files:{COLOR_RESET}")
print(f" {output_path}")
print(f"{COLOR_WARNING}All files in this directory will be deleted before installation.{COLOR_RESET}")
print(f"{COLOR_WARNING}This action cannot be undone.{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}Delete existing files and continue? (y/N): {COLOR_RESET}").strip().lower()
if confirm not in ('y', 'yes'):
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
import shutil
try:
for item in output_path.iterdir():
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
except Exception as e:
print(f"{COLOR_ERROR}Failed to clear output directory: {e}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Run TTW installation
import re
phase_state = {"current": "Processing", "last_rendered": ""}
progress_line_active = {"value": False}
def _strip_ansi(text: str) -> str:
return re.sub(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])', '', text or '')
def _ttw_output_callback(line: str):
clean = _strip_ansi(line or "").strip()
if not clean:
return
lower = clean.lower()
rendered = ""
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower)
if manifest_match:
current = int(manifest_match.group(1))
total = int(manifest_match.group(2))
phase_state["current"] = "Loading manifest"
percent = int((current / total) * 100) if total > 0 else 0
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
else:
progress_match = re.search(r'\[(\d+)/(\d+)\]', clean)
if progress_match:
current = int(progress_match.group(1))
total = int(progress_match.group(2))
percent = int((current / total) * 100) if total > 0 else 0
rendered = f"[TTW] {phase_state['current']}: {current:,}/{total:,} ({percent}%)"
else:
if 'manifest' in lower:
phase_state["current"] = "Loading manifest"
elif any(t in lower for t in ('extract', 'decompress', 'installing', 'copying', 'merge')):
phase_state["current"] = clean
is_milestone = any(t in lower for t in ('===', 'complete', 'finished', 'starting', 'valid'))
is_error = 'error:' in lower
is_warning = 'warning:' in lower
if is_milestone or is_error or is_warning:
rendered = f"[TTW] {clean}"
if not rendered or rendered == phase_state["last_rendered"]:
return
phase_state["last_rendered"] = rendered
if re.search(r'^\[TTW\] .+?: [\d,]+/[\d,]+ \(\d+%\)$', rendered):
print(f"\r{COLOR_INFO}{rendered}{COLOR_RESET}", end="", flush=True)
progress_line_active["value"] = True
else:
if progress_line_active["value"]:
print()
progress_line_active["value"] = False
print(f"{COLOR_INFO}{rendered}{COLOR_RESET}")
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_backend(mpi_path, output_path)
print(f"{COLOR_INFO}This may take 15-30 minutes.{COLOR_RESET}\n")
success, message = ttw_installer_handler.install_ttw_backend_with_output_stream(
mpi_path, output_path, output_callback=_ttw_output_callback
)
if progress_line_active["value"]:
print()
if success:
print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}")
print(f"{COLOR_INFO}TTW installed to: {output_path}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: ~/Jackify/logs/TTW_Install_workflow.log{COLOR_RESET}")
input("Press Enter to return to menu...")
else:
print(f"\n{COLOR_ERROR}TTW installation failed.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: ~/Jackify/logs/TTW_Install_workflow.log{COLOR_RESET}")
input("Press Enter to return to menu...")
def _execute_nexus_authorization(self, cli_instance):

View File

@@ -0,0 +1,70 @@
"""Single-line CLI pulser for indeterminate background stages."""
from __future__ import annotations
import itertools
import sys
import threading
import time
from typing import Optional
class CliIndeterminateStatus:
"""Render one in-place pulsing status line for long-running CLI steps."""
def __init__(self, output=None, interval: float = 0.12):
self._output = output or sys.stdout
self._interval = interval
self._interactive = bool(getattr(self._output, "isatty", lambda: False)())
self._message: Optional[str] = None
self._printed_message: Optional[str] = None
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._thread: Optional[threading.Thread] = None
def set(self, message: str) -> None:
"""Start or update the active pulsing message."""
cleaned = (message or "").strip()
if not cleaned:
self.stop()
return
if not self._interactive:
if cleaned != self._printed_message:
print(cleaned, file=self._output, flush=True)
self._printed_message = cleaned
return
with self._lock:
self._message = cleaned
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
"""Stop the pulser and clear its terminal line."""
if not self._interactive:
return
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=0.5)
self._thread = None
with self._lock:
self._message = None
self._output.write("\r\033[2K")
self._output.flush()
def close(self) -> None:
self.stop()
def _run(self) -> None:
for frame in itertools.cycle("|/-\\"):
if self._stop_event.wait(self._interval):
return
with self._lock:
message = self._message
if not message:
continue
self._output.write(f"\r\033[2K{message} {frame}")
self._output.flush()

View File

@@ -0,0 +1,204 @@
"""Shared dialog for existing install/shortcut detection decisions."""
from __future__ import annotations
from typing import Optional, Tuple
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
def prompt_existing_setup_dialog(
parent: QWidget,
*,
window_title: str,
heading: str,
body: str,
existing_name: str,
requested_name: str,
install_dir: Optional[str] = None,
field_label: str = "New shortcut name",
reuse_label: str = "Use Existing Setup",
new_label: str = "Create New Shortcut",
cancel_label: str = "Cancel",
) -> Tuple[str, Optional[str]]:
"""
Show the shared existing-setup dialog.
Returns:
("reuse"|"new"|"cancel", new_name_or_none)
"""
dialog = QDialog(parent)
dialog.setWindowTitle(window_title)
dialog.setModal(True)
dialog.setMinimumWidth(760)
dialog.setMinimumHeight(320)
dialog.setStyleSheet(
"""
QDialog {
background: #181818;
color: #ffffff;
border-radius: 12px;
}
QFrame#dialogCard {
background: #23272e;
border: 1px solid #353a40;
border-radius: 12px;
}
QFrame#infoCard {
background: #2a2f36;
border: 1px solid #3b4148;
border-radius: 8px;
}
QLabel {
color: #ffffff;
font-size: 14px;
padding: 0px;
}
QLabel#dialogTitle {
font-size: 22px;
font-weight: 600;
color: #3fb7d6;
}
QLabel#dialogBody {
color: #e0e0e0;
line-height: 1.35;
}
QLabel#infoLabel {
color: #c7d0d8;
font-size: 13px;
line-height: 1.3;
}
QLabel#fieldLabel {
color: #b0b0b0;
font-size: 12px;
}
QLineEdit {
background-color: #404040;
color: #ffffff;
border: 2px solid #555555;
border-radius: 4px;
padding: 8px;
font-size: 14px;
selection-background-color: #3fd0ea;
}
QLineEdit:focus {
border-color: #3fd0ea;
}
QPushButton {
background-color: #404040;
color: #ffffff;
border: 2px solid #555555;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
min-width: 120px;
}
QPushButton:hover {
background-color: #505050;
border-color: #3fd0ea;
}
QPushButton:pressed {
background-color: #303030;
}
"""
)
outer_layout = QVBoxLayout(dialog)
outer_layout.setContentsMargins(24, 20, 24, 20)
outer_layout.setSpacing(0)
card = QFrame(dialog)
card.setObjectName("dialogCard")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(22, 22, 22, 22)
card_layout.setSpacing(14)
title_label = QLabel(heading)
title_label.setObjectName("dialogTitle")
title_label.setAlignment(Qt.AlignCenter)
title_label.setWordWrap(True)
card_layout.addWidget(title_label)
body_label = QLabel(body)
body_label.setObjectName("dialogBody")
body_label.setAlignment(Qt.AlignCenter)
body_label.setWordWrap(True)
card_layout.addWidget(body_label)
info_card = QFrame(card)
info_card.setObjectName("infoCard")
info_layout = QVBoxLayout(info_card)
info_layout.setContentsMargins(14, 12, 14, 12)
info_layout.setSpacing(6)
info_lines = [
f"<b>Existing shortcut:</b> {existing_name}",
f"<b>Requested name:</b> {requested_name or existing_name}",
]
if install_dir:
info_lines.append(f"<b>Install directory:</b> {install_dir}")
info_label = QLabel("<br>".join(info_lines))
info_label.setObjectName("infoLabel")
info_label.setTextFormat(Qt.RichText)
info_label.setWordWrap(True)
info_layout.addWidget(info_label)
card_layout.addWidget(info_card)
field_title = QLabel(field_label)
field_title.setObjectName("fieldLabel")
card_layout.addWidget(field_title)
name_input = QLineEdit(requested_name or existing_name)
name_input.selectAll()
card_layout.addWidget(name_input)
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
reuse_button = QPushButton(reuse_label)
cancel_button = QPushButton(cancel_label)
new_button = QPushButton(new_label)
for button in (reuse_button, cancel_button, new_button):
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
button_layout.addWidget(reuse_button)
button_layout.addWidget(cancel_button)
button_layout.addWidget(new_button)
card_layout.addLayout(button_layout)
outer_layout.addWidget(card)
result = {"action": "cancel", "new_name": None}
def on_reuse():
result["action"] = "reuse"
dialog.accept()
def on_new():
result["action"] = "new"
result["new_name"] = name_input.text().strip()
dialog.accept()
def on_cancel():
result["action"] = "cancel"
dialog.reject()
reuse_button.clicked.connect(on_reuse)
new_button.clicked.connect(on_new)
cancel_button.clicked.connect(on_cancel)
name_input.returnPressed.connect(on_new)
dialog.adjustSize()
dialog.exec()
return result["action"], result["new_name"]

View File

@@ -0,0 +1,466 @@
"""
Manual Download Dialog
Shown when the engine requires manual downloads (non-premium or forced-manual
archives). Displays all pending items in a scrollable table, manages browser
tab concurrency, and coordinates with ManualDownloadManager.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QSpinBox, QFrame, QTableWidget, QTableWidgetItem, QHeaderView,
QProgressBar, QFileDialog, QSizePolicy,
)
from PySide6.QtGui import QFont
from jackify.backend.services.manual_download_manager import ManualDownloadManager, DownloadItem
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
logger = logging.getLogger(__name__)
_STATUS_LABELS = {
'pending': 'Pending',
'browser_opened': 'Browser Opened',
'validating': 'Validating...',
'complete': 'Complete',
'deferred': 'Deferred',
'skipped': 'Skipped',
'error': 'Error',
}
_STATUS_COLOURS = {
'pending': '#808080',
'browser_opened': '#3498db',
'validating': '#f39c12',
'complete': '#27ae60',
'deferred': '#e67e22',
'skipped': '#e67e22',
'error': '#e74c3c',
}
# Column indices
_COL_MOD = 0
_COL_NAME = 1
_COL_SIZE = 2
_COL_STATUS = 3
def _fmt_size(n: int) -> str:
if n <= 0:
return ''
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f"{n:.0f} {unit}"
n /= 1024
return f"{n:.1f} TB"
class _Bridge(QObject):
"""Tiny bridge so worker-thread callbacks can update the Qt table safely."""
item_updated = Signal(object) # DownloadItem
all_done = Signal(int, int) # completed, skipped
class ManualDownloadDialog(QDialog):
"""
Displays all pending manual downloads and coordinates the download workflow.
Non-modal so the install log remains visible.
"""
def __init__(
self,
manager: ManualDownloadManager,
modlist_name: str = '',
watch_directory: Optional[Path] = None,
concurrent_limit: int = 2,
parent=None,
):
super().__init__(parent)
self._manager = manager
self._modlist_name = modlist_name
self._watch_dir = watch_directory or (Path.home() / 'Downloads')
self._paused = False
self._started = False
self._initial_concurrent_limit = max(1, min(5, int(concurrent_limit)))
# Row index by file_name for fast updates
self._row_map: dict[str, int] = {}
# Bridge for thread-safe table updates
self._bridge = _Bridge()
self._bridge.item_updated.connect(self._on_item_updated_slot)
self._bridge.all_done.connect(self._on_all_done_slot)
# Preserve any existing manager callbacks so workflow controllers still
# receive completion events after the dialog updates its own UI.
prev_item_updated = self._manager._on_item_updated
prev_all_done = self._manager._on_all_done
def _emit_item_updated(item):
self._bridge.item_updated.emit(item)
if prev_item_updated:
prev_item_updated(item)
def _emit_all_done(completed: int, skipped: int):
self._bridge.all_done.emit(completed, skipped)
if prev_all_done:
prev_all_done(completed, skipped)
self._manager._on_item_updated = _emit_item_updated
self._manager._on_all_done = _emit_all_done
self.setWindowTitle("Manual Downloads Required")
self.setMinimumSize(760, 500)
self.setModal(False)
self._build_ui()
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def showEvent(self, event) -> None:
super().showEvent(event)
if not self._started:
# Keep the workflow idle until the user explicitly clicks Start.
# Start backend services in paused mode so watcher/precheck are ready
# without opening browser tabs yet.
self._paused = False
self._manager.pause()
self._manager.start()
self._start_pause_btn.setText("Start")
self._progress_label.setText("Ready - click Start to begin opening download tabs")
def load_items(self, items: list[DownloadItem]) -> None:
"""
Populate or refresh the table from a list of DownloadItems.
On subsequent loop iterations the manager passes its full item list
(including previously-completed rows), so we update existing rows and
append only genuinely new ones rather than rebuilding the table.
"""
new_items = [i for i in items if i.file_name not in self._row_map]
existing_items = [i for i in items if i.file_name in self._row_map]
# Update existing rows without disabling updates (usually few on repeat iterations)
for item in existing_items:
self._update_row(self._row_map[item.file_name], item)
# Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints
if new_items:
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.viewport().update()
self._refresh_header()
# If user already started the workflow and engine enters another manual loop,
# continue opening tabs for newly-pending items automatically.
if self._started and not self._paused:
self._manager.resume()
def update_item(self, item: DownloadItem) -> None:
"""Called from any thread - bridges to Qt slot."""
self._bridge.item_updated.emit(item)
# ------------------------------------------------------------------
# Build UI
# ------------------------------------------------------------------
def _build_ui(self) -> None:
root = QVBoxLayout(self)
root.setContentsMargins(16, 16, 16, 16)
root.setSpacing(10)
# Header
hdr = QFrame()
hdr.setFrameShape(QFrame.StyledPanel)
hdr.setStyleSheet("QFrame { background: #1e2228; border-radius: 8px; border: 1px solid #333; }")
hdr_layout = QVBoxLayout(hdr)
hdr_layout.setContentsMargins(12, 10, 12, 10)
hdr_layout.setSpacing(6)
self._title_label = QLabel(f"Modlist: {self._modlist_name or 'Unknown'}")
self._title_label.setStyleSheet("color: #e0e0e0; font-size: 14px; font-weight: 600;")
hdr_layout.addWidget(self._title_label)
self._progress_label = QLabel("Preparing...")
self._progress_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
hdr_layout.addWidget(self._progress_label)
self._progress_bar = QProgressBar()
self._progress_bar.setRange(0, 100)
self._progress_bar.setValue(0)
self._progress_bar.setStyleSheet(
f"QProgressBar {{ border: 1px solid #444; border-radius: 4px; background: #2c2c2c; "
f"height: 12px; color: #d7e3f4; font-weight: 600; }}"
f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}"
)
hdr_layout.addWidget(self._progress_bar)
root.addWidget(hdr)
# Table
self._table = QTableWidget()
self._table.setColumnCount(4)
self._table.setHorizontalHeaderLabels(['Mod', 'File', 'Size', 'Status'])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Fixed)
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed)
self._table.setColumnWidth(2, 90)
self._table.setColumnWidth(3, 130)
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
self._table.cellDoubleClicked.connect(self._on_row_double_clicked)
self._table.setStyleSheet(
"QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; "
"color: #d0d0d0; gridline-color: #333; border: 1px solid #333; border-radius: 4px; }"
"QHeaderView::section { background: #252830; color: #aaa; border: none; "
"padding: 4px; font-size: 11px; }"
)
root.addWidget(self._table)
# Controls row
ctrl = QHBoxLayout()
ctrl.setSpacing(12)
ctrl.addWidget(QLabel("Concurrent tabs:"))
self._concurrent_spin = QSpinBox()
self._concurrent_spin.setRange(1, 5)
self._concurrent_spin.setValue(self._initial_concurrent_limit)
self._concurrent_spin.setFixedWidth(60)
self._concurrent_spin.valueChanged.connect(self._on_concurrent_changed)
ctrl.addWidget(self._concurrent_spin)
ctrl.addSpacing(16)
ctrl.addWidget(QLabel("Watch folder:"))
self._folder_label = QLabel(str(self._watch_dir))
self._folder_label.setStyleSheet("color: #aaa; font-size: 11px;")
self._folder_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
ctrl.addWidget(self._folder_label)
folder_btn = QPushButton("...")
folder_btn.setFixedSize(32, 28)
folder_btn.clicked.connect(self._on_pick_folder)
ctrl.addWidget(folder_btn)
root.addLayout(ctrl)
watch_hint = QLabel(
"Jackify watches this folder for newly downloaded archives, validates them, "
"then moves valid files into your modlist downloads folder automatically. "
"Double-click a row (or use Open Selected) to reopen a URL if you closed a tab."
)
watch_hint.setWordWrap(True)
watch_hint.setStyleSheet("color: #8f98a3; font-size: 11px;")
root.addWidget(watch_hint)
# Action buttons
btn_row = QHBoxLayout()
btn_row.setSpacing(10)
self._retry_btn = QPushButton("Retry Deferred (0)")
self._retry_btn.setEnabled(False)
self._retry_btn.clicked.connect(self._on_retry_skipped)
btn_row.addWidget(self._retry_btn)
self._defer_btn = QPushButton("Defer Selected")
self._defer_btn.clicked.connect(self._on_defer_selected)
btn_row.addWidget(self._defer_btn)
self._open_selected_btn = QPushButton("Open Selected")
self._open_selected_btn.clicked.connect(self._on_open_selected)
btn_row.addWidget(self._open_selected_btn)
btn_row.addStretch()
self._start_pause_btn = QPushButton("Start")
self._start_pause_btn.clicked.connect(self._on_start_pause_clicked)
btn_row.addWidget(self._start_pause_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet(
"QPushButton { background: #7f2020; color: white; border: none; "
"border-radius: 4px; padding: 6px 16px; }"
"QPushButton:hover { background: #9b2828; }"
)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(cancel_btn)
root.addLayout(btn_row)
# ------------------------------------------------------------------
# Table helpers
# ------------------------------------------------------------------
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))
self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name))
self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size)))
colour = _STATUS_COLOURS.get(item.status, '#808080')
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(colour))
if item.error_message:
status_cell.setToolTip(item.error_message)
self._table.setItem(row, _COL_STATUS, status_cell)
def _update_row(self, row: int, item: DownloadItem) -> None:
from PySide6.QtGui import QColor
status_cell = self._table.item(row, _COL_STATUS)
if status_cell:
status_cell.setText(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
status_cell.setToolTip(item.error_message or "")
def _refresh_header(self) -> None:
items = self._manager.items
total = len(items)
complete = sum(1 for i in items if i.status == 'complete')
skipped = sum(1 for i in items if i.status == 'skipped')
remaining = total - complete - skipped
pct = int(complete / total * 100) if total > 0 else 0
self._progress_bar.setValue(pct)
self._progress_label.setText(
f"{complete} of {total} complete | {skipped} deferred | {remaining} remaining"
)
self._retry_btn.setText(f"Retry Deferred ({skipped})")
self._retry_btn.setEnabled(skipped > 0)
# ------------------------------------------------------------------
# Slots
# ------------------------------------------------------------------
def _on_item_updated_slot(self, item: DownloadItem) -> None:
row = self._row_map.get(item.file_name)
if row is not None:
self._update_row(row, item)
self._refresh_header()
def _on_concurrent_changed(self, value: int) -> None:
self._manager.set_concurrent_limit(value)
try:
cfg = ConfigHandler()
cfg.set("manual_download_concurrent_limit", int(value))
cfg.save_config()
except Exception:
logger.debug("Could not persist manual_download_concurrent_limit", exc_info=True)
def _on_pick_folder(self) -> None:
chosen = QFileDialog.getExistingDirectory(self, "Select watch folder", str(self._watch_dir))
if chosen:
from jackify.backend.services.download_watcher_service import WatcherConfig
self._watch_dir = Path(chosen)
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))
cfg.save_config()
except Exception:
logger.debug("Could not persist manual_download_watch_directory", exc_info=True)
def _on_start_pause_clicked(self) -> None:
if not self._started:
self._started = True
self._paused = False
self._start_pause_btn.setText("Pause")
self._manager.resume()
return
if not self._paused:
self._paused = True
self._start_pause_btn.setText("Resume")
self._manager.pause()
else:
self._paused = False
self._start_pause_btn.setText("Pause")
self._manager.resume()
def _on_retry_skipped(self) -> None:
with self._manager._lock:
for item in self._manager._items:
if item.status in ('deferred', 'skipped'):
item.status = 'pending'
item.needs_user_retry = False
row = self._row_map.get(item.file_name)
if row is not None:
self._update_row(row, item)
self._manager._open_next_tabs()
self._refresh_header()
def _on_defer_selected(self) -> None:
row = self._table.currentRow()
if row < 0:
return
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
return
file_name = file_item.text().strip()
if not file_name:
return
self._manager.skip_item(file_name)
def _on_open_selected(self) -> None:
row = self._table.currentRow()
if row < 0:
return
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
return
file_name = file_item.text().strip()
if not file_name:
return
self._manager.reopen_item(file_name)
def _on_row_double_clicked(self, row: int, _column: int) -> None:
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
return
file_name = file_item.text()
if not file_name:
return
self._manager.reopen_item(file_name)
def _on_all_done_slot(self, completed: int, skipped: int) -> None:
from PySide6.QtCore import QTimer
self._progress_label.setText(
f"All downloads complete ({completed} accepted, {skipped} deferred) — closing..."
)
# Raise now while the dialog is still visible so the user sees the completion state
self._raise_main_window()
QTimer.singleShot(2000, self._close_and_refocus)
def _close_and_refocus(self) -> None:
self.close()
# Closing a non-modal dialog can hand focus back to whatever was behind it
self._raise_main_window()
def _raise_main_window(self) -> None:
try:
win = self.window()
if win:
win.raise_()
win.activateWindow()
except Exception:
pass
def closeEvent(self, event) -> None:
# Don't stop the manager on close - install continues
event.accept()

View File

@@ -274,13 +274,20 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.config_handler.set("proton_path", resolved_install_path)
self.config_handler.set("proton_version", resolved_install_version)
else:
# No Proton found - don't write anything, let engine auto-detect
# No Proton found - clear persisted selection so startup normalization
# can auto-heal once a compatible Proton is installed.
logger.warning("Auto Proton selection failed: No Proton versions found")
# Don't modify existing config values
resolved_install_path = None
resolved_install_version = None
self.config_handler.set("proton_path", None)
self.config_handler.set("proton_version", None)
except Exception as e:
# Exception during detection - log it and don't write anything
# Exception during detection - clear persisted selection to avoid stale path usage.
logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True)
# Don't modify existing config values
resolved_install_path = None
resolved_install_version = None
self.config_handler.set("proton_path", None)
self.config_handler.set("proton_version", None)
else:
# User selected specific Proton version
resolved_install_path = selected_install_proton_path
@@ -392,4 +399,3 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
label = QLabel(text)
label.setStyleSheet("font-weight: bold; color: #fff;")
return label

View File

@@ -86,6 +86,8 @@ class SuccessDialog(QDialog):
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
if self.workflow_type == "install":
suffix_text = "installed successfully!"
elif self.workflow_type == "update":
suffix_text = "updated successfully!"
elif self.workflow_type == "configure_new":
suffix_text = "configured successfully!"
elif self.workflow_type == "configure_existing":
@@ -220,6 +222,7 @@ class SuccessDialog(QDialog):
"""
workflow_messages = {
"install": f"{self.modlist_name} installed successfully!",
"update": f"{self.modlist_name} updated successfully!",
"configure_new": f"{self.modlist_name} configured successfully!",
"configure_existing": f"{self.modlist_name} configuration updated successfully!",
"tuxborn": f"Tuxborn installation completed successfully!",
@@ -268,4 +271,4 @@ class SuccessDialog(QDialog):
QApplication.quit()
except Exception as e:
logger.error(f"Error during safe exit: {e}")
QApplication.quit()
QApplication.quit()

View File

@@ -44,6 +44,12 @@ class MainWindowGeometryMixin:
def _is_compact_mode(self) -> bool:
try:
if hasattr(self, 'wabbajack_installer_screen') and hasattr(self.wabbajack_installer_screen, 'show_details_checkbox'):
if self.wabbajack_installer_screen.show_details_checkbox.isChecked():
return False
if hasattr(self, 'install_mo2_screen') and hasattr(self.install_mo2_screen, 'show_details_checkbox'):
if self.install_mo2_screen.show_details_checkbox.isChecked():
return False
if hasattr(self, 'install_modlist_screen') and hasattr(self.install_modlist_screen, 'show_details_checkbox'):
if self.install_modlist_screen.show_details_checkbox.isChecked():
return False

View File

@@ -220,6 +220,10 @@ class MainWindowUIMixin:
stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info
)
self.install_mo2_screen = screen
try:
screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
return screen
def _debug_screen_change(self, index):

View File

@@ -32,6 +32,7 @@ from .configure_existing_modlist_shortcuts import ConfigureExistingModlistShortc
from .configure_existing_modlist_console import ConfigureExistingModlistConsoleMixin
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
class ConfigureExistingModlistScreen(
ScreenBackMixin,
@@ -40,23 +41,35 @@ class ConfigureExistingModlistScreen(
ConfigureExistingModlistWorkflowMixin,
ConfigureExistingModlistShortcutsMixin,
ConfigureExistingModlistConsoleMixin,
PostInstallFeedbackMixin,
QWidget,
):
resize_request = Signal(str)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
# Stop CPU tracking if active
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
# Clean up configuration thread if running
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(1000)
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
try:
if isinstance(value, QThread) and value.isRunning():
try:
value.finished_signal.disconnect()
except Exception:
pass
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
except Exception:
pass
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
self.cleanup_processes()
self.collapse_show_details_before_leave()
self.go_back()
@@ -65,16 +78,8 @@ class ConfigureExistingModlistScreen(
"""Called when the widget becomes visible - ensure collapsed state"""
super().showEvent(event)
# Ensure initial collapsed layout first so UI is stable before async load
try:
from PySide6.QtCore import Qt as _Qt
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
self._toggle_console_visibility(False)
# Only set minimum size - DO NOT RESIZE
self.force_collapsed_details_state()
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
@@ -118,8 +123,15 @@ class ConfigureExistingModlistScreen(
return
# Check for VNV post-install automation after configuration
if install_dir:
self._check_and_run_vnv_automation(modlist_name, install_dir)
if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir):
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'workflow_type': 'configure_existing',
'time_taken': self._calculate_time_taken(),
'game_name': getattr(self, '_current_game_name', None),
'enb_detected': enb_detected,
}
return
# Calculate time taken
time_taken = self._calculate_time_taken()
@@ -202,10 +214,15 @@ class ConfigureExistingModlistScreen(
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
self.force_collapsed_details_state()
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
# Clean up config thread if running
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():

View File

@@ -51,6 +51,12 @@ class ConfigureExistingModlistUIMixin:
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready to configure", 0)
self.file_progress_list = FileProgressList()
self._post_install_sequence = self._build_post_install_sequence()
self._post_install_total_steps = len(self._post_install_sequence)
self._post_install_current_step = 0
self._post_install_active = False
self._post_install_last_label = ""
self._bsa_hold_deadline = 0.0
# Create "Show Details" checkbox
self.show_details_checkbox = QCheckBox("Show details")
@@ -539,4 +545,3 @@ class ConfigureExistingModlistUIMixin:
self.process_monitor.setPlainText('\n'.join(filtered))
except Exception as e:
self.process_monitor.setPlainText(f"[process info unavailable: {e}]")

View File

@@ -1,10 +1,11 @@
"""Workflow management for ConfigureExistingModlistScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import QMessageBox
import os
import time
import logging
from pathlib import Path
from typing import Optional
from jackify.shared.resolution_utils import get_resolution_fallback
from jackify.shared.errors import configuration_failed
@@ -188,7 +189,10 @@ class ConfigureExistingModlistWorkflowMixin:
)
if not success:
self.error_occurred.emit("Configuration failed - check logs for details")
self.error_occurred.emit(
"Configuration did not complete successfully. "
"Review the latest workflow output above for the failing step."
)
except Exception as e:
import traceback
@@ -206,89 +210,64 @@ class ConfigureExistingModlistWorkflowMixin:
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
MessageService.show_error(self, configuration_failed(str(e)))
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
"""Check if VNV automation should run and execute if applicable
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.
Args:
modlist_name: Name of the installed modlist
install_dir: Installation directory path
Returns:
True if VNV automation is starting (caller should defer success dialog)
False if no VNV needed (show success dialog immediately)
"""
try:
from pathlib import Path
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.handlers.path_handler import PathHandler
from ..services.vnv_automation_controller import VNVAutomationController
# Get paths first (needed for VNV detection)
install_path = Path(install_dir)
# Quick check before importing more (pass install location for ModOrganizer.ini check)
if not should_offer_vnv_automation(modlist_name, install_path):
return
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
self._vnv_controller = VNVAutomationController()
return self._vnv_controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
on_progress=self._safe_append_text,
on_complete=self._on_vnv_complete,
begin_feedback=self._begin_post_install_feedback,
handle_feedback=self._handle_post_install_progress,
)
if not game_root:
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
return
# Confirmation callback - show dialog to user
def confirmation_callback(description: str) -> bool:
from ..services.message_service import MessageService
reply = MessageService.question(
self,
"VNV Post-Install Automation",
description,
critical=False,
safety_level="medium"
)
return reply == QMessageBox.Yes
# Manual file callback for non-Premium users
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
from PySide6.QtWidgets import QFileDialog
from ..services.message_service import MessageService
# Show instructions
MessageService.information(self, title, instructions)
# Open file picker
file_path, _ = QFileDialog.getOpenFileName(
self,
title,
str(Path.home() / "Downloads"),
"All Files (*.*)"
)
if file_path:
return Path(file_path).resolve()
return None
# Run automation
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=None, # GUI doesn't need progress updates for post-install
manual_file_callback=manual_file_callback,
confirmation_callback=confirmation_callback
def _on_vnv_complete(self, success: bool, error: str):
"""Handle VNV automation completion and show deferred success dialog."""
self._end_post_install_feedback(not bool(error))
if not success and error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
elif success:
self._safe_append_text("VNV post-install automation completed successfully.")
if error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
except Exception as e:
logger.debug(f"ERROR: Failed to run VNV automation: {e}")
import traceback
logger.debug(f"Traceback: {traceback.format_exc()}")
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,
)
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"
@@ -372,4 +351,3 @@ class ConfigureExistingModlistWorkflowMixin:
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_seconds_remainder} seconds"

View File

@@ -35,14 +35,18 @@ from .configure_new_modlist_workflow import ConfigureNewModlistWorkflowMixin
from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, ModlistFetchThread, SelectionDialog
from .screen_back_mixin import ScreenBackMixin
from .install_modlist_ttw import TTWIntegrationMixin
from .install_modlist_postinstall import PostInstallFeedbackMixin
logger = logging.getLogger(__name__)
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, QWidget):
class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
resize_request = Signal(str)
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
self.cleanup_processes()
self.collapse_show_details_before_leave()
self.go_back()
@@ -50,23 +54,7 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
def showEvent(self, event):
"""Called when the widget becomes visible - ensure collapsed state"""
super().showEvent(event)
# Ensure initial collapsed layout each time this screen is opened
try:
from PySide6.QtCore import Qt as _Qt
# Ensure checkbox is unchecked without emitting signals
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
# Force collapsed state
# Set console to hidden state without emitting signals
self.console.setVisible(False)
self.resize_request.emit("compact")
except Exception as e:
# If initial collapse fails, log but don't crash
print(f"Warning: Failed to set initial collapsed state: {e}")
self.force_collapsed_details_state()
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion (same as Tuxborn)"""
@@ -88,8 +76,15 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
return
# Check for VNV post-install automation after configuration
if install_dir:
self._check_and_run_vnv_automation(modlist_name, install_dir)
if install_dir and self._check_and_run_vnv_automation(modlist_name, install_dir):
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'workflow_type': 'configure_new',
'time_taken': self._calculate_time_taken(),
'game_name': getattr(self, '_current_game_name', None),
'enb_detected': enb_detected,
}
return
# Calculate time taken
time_taken = self._calculate_time_taken()
@@ -97,7 +92,6 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
# 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_new",
@@ -106,16 +100,14 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
parent=self
)
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
enb_dialog.exec()
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
logger.warning(f"Failed to show ENB dialog: {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)))
@@ -169,10 +161,15 @@ class ConfigureNewModlistScreen(ScreenBackMixin, TTWIntegrationMixin, ConfigureN
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
self.force_collapsed_details_state()
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
logger.debug("DEBUG: cleanup called - cleaning up threads")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
# Clean up automated prefix thread if running
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread and self.automated_prefix_thread.isRunning():

View File

@@ -3,13 +3,14 @@ import os
import re
import time
from PySide6.QtCore import QTimer
from PySide6.QtCore import QTimer, Qt
from PySide6.QtWidgets import QFileDialog
from jackify.shared.progress_models import FileProgress, OperationType
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
class ConfigureNewModlistConsoleMixin:
class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
"""Mixin providing console output management for ConfigureNewModlistScreen."""
def _handle_progress_update(self, text):
@@ -26,10 +27,12 @@ class ConfigureNewModlistConsoleMixin:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Restarting Steam...", 20)
self.file_progress_list.update_or_add_item("__phase__", "Restarting Steam...", 0.0)
elif "steam restart" in message_lower and "success" in message_lower:
elif "steam started successfully" in message_lower or ("steam restart" in message_lower and "success" in message_lower):
self._stop_component_install_pulse()
self.progress_indicator.set_status("Steam restarted successfully", 30)
self.file_progress_list.update_or_add_item("__phase__", "Steam restarted", 0.0)
elif STEAM_RESTART_SENTINEL in text:
self._start_focus_reclaim_retries()
elif "creating proton prefix" in message_lower or "prefix creation" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Creating Proton prefix...", 50)
@@ -169,4 +172,3 @@ class ConfigureNewModlistConsoleMixin:
if file:
self.install_dir_edit.setText(os.path.realpath(file))

View File

@@ -1,10 +1,12 @@
"""Dialog management for ConfigureNewModlistScreen (Mixin)."""
import os
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFileDialog, QMessageBox, QApplication, QListWidget, QListWidgetItem
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QTextCursor
from pathlib import Path
from typing import Optional
import subprocess
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.errors import manual_steps_incomplete
import logging
@@ -75,131 +77,85 @@ class SelectionDialog(QDialog):
class ConfigureNewModlistDialogsMixin:
"""Mixin providing dialog management for ConfigureNewModlistScreen."""
def _restore_controls_after_shortcut_dialog_abort(self):
"""Return Configure New to an editable state when shortcut resolution is aborted."""
try:
self._enable_controls_after_operation()
except Exception:
pass
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
# Stop CPU tracking if active
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
# Clean up automated prefix thread if running
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning():
self.automated_prefix_thread.terminate()
self.automated_prefix_thread.wait(1000)
# Clean up configuration thread if running
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(1000)
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
try:
if isinstance(value, QThread) and value.isRunning():
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
except Exception:
pass
def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to resolve shortcut name conflicts"""
"""Show dialog to reuse an existing shortcut or choose a new name."""
conflict_names = [c['name'] for c in conflicts]
conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'"
existing_name = conflict_names[0]
modlist_name = self.modlist_name_edit.text().strip()
# Create dialog with Jackify styling
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout
from PySide6.QtCore import Qt
dialog = QDialog(self)
dialog.setWindowTitle("Steam Shortcut Conflict")
dialog.setModal(True)
dialog.resize(450, 180)
# Apply Jackify dark theme styling
dialog.setStyleSheet("""
QDialog {
background-color: #2b2b2b;
color: #ffffff;
}
QLabel {
color: #ffffff;
font-size: 14px;
padding: 10px 0px;
}
QLineEdit {
background-color: #404040;
color: #ffffff;
border: 2px solid #555555;
border-radius: 4px;
padding: 8px;
font-size: 14px;
selection-background-color: #3fd0ea;
}
QLineEdit:focus {
border-color: #3fd0ea;
}
QPushButton {
background-color: #404040;
color: #ffffff;
border: 2px solid #555555;
border-radius: 4px;
padding: 8px 16px;
font-size: 14px;
min-width: 120px;
}
QPushButton:hover {
background-color: #505050;
border-color: #3fd0ea;
}
QPushButton:pressed {
background-color: #303030;
}
""")
layout = QVBoxLayout(dialog)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
# Conflict message
conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:")
layout.addWidget(conflict_label)
# Text input for new name
name_input = QLineEdit(modlist_name)
name_input.selectAll()
layout.addWidget(name_input)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
create_button = QPushButton("Create with New Name")
cancel_button = QPushButton("Cancel")
button_layout.addStretch()
button_layout.addWidget(cancel_button)
button_layout.addWidget(create_button)
layout.addLayout(button_layout)
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
action, new_name = prompt_existing_setup_dialog(
self,
window_title="Existing Modlist Setup Detected",
heading="Modlist Update or New Install",
body=(
"Jackify detected an existing Steam shortcut for this setup.\n\n"
"If you are updating an existing modlist or reconfiguring it, choose "
"'Use Existing Setup'. If you want a separate Steam entry, enter a different "
"name and choose 'Create New Shortcut'."
),
existing_name=existing_name,
requested_name=modlist_name,
install_dir=install_dir,
field_label="New shortcut name",
reuse_label="Use Existing Setup",
new_label="Create New Shortcut",
cancel_label="Cancel",
)
# Connect signals
def on_create():
new_name = name_input.text().strip()
if action == "new":
if new_name and new_name != modlist_name:
dialog.accept()
# Retry workflow with new name
self.retry_automated_workflow_with_new_name(new_name)
elif new_name == modlist_name:
# Same name - show warning
from jackify.backend.services.message_service import MessageService
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
self._restore_controls_after_shortcut_dialog_abort()
else:
# Empty name
from jackify.backend.services.message_service import MessageService
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
def on_cancel():
dialog.reject()
self._restore_controls_after_shortcut_dialog_abort()
elif action == "reuse":
existing_appid = conflicts[0].get('appid')
if not existing_appid:
MessageService.warning(
self,
"Existing Setup Not Found",
"Jackify could not determine the Steam AppID for the existing shortcut.",
)
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(
str(existing_appid),
existing_name,
install_dir,
None,
)
else:
self._safe_append_text("Shortcut creation cancelled by user")
create_button.clicked.connect(on_create)
cancel_button.clicked.connect(on_cancel)
# Make Enter key work
name_input.returnPressed.connect(on_create)
dialog.exec()
self._restore_controls_after_shortcut_dialog_abort()
def retry_automated_workflow_with_new_name(self, new_name):
"""Retry the automated workflow with a new shortcut name"""
@@ -228,89 +184,64 @@ class ConfigureNewModlistDialogsMixin:
MessageService.show_error(self, manual_steps_incomplete())
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip())
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
"""Check if VNV automation should run and execute if applicable
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.
Args:
modlist_name: Name of the installed modlist
install_dir: Installation directory path
Returns:
True if VNV automation is starting (caller should defer success dialog)
False if no VNV needed (show success dialog immediately)
"""
try:
from pathlib import Path
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.handlers.path_handler import PathHandler
from ..services.vnv_automation_controller import VNVAutomationController
# Get paths first (needed for VNV detection)
install_path = Path(install_dir)
# Quick check before importing more (pass install location for ModOrganizer.ini check)
if not should_offer_vnv_automation(modlist_name, install_path):
return
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
self._vnv_controller = VNVAutomationController()
return self._vnv_controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
on_progress=self._safe_append_text,
on_complete=self._on_vnv_complete,
begin_feedback=self._begin_post_install_feedback,
handle_feedback=self._handle_post_install_progress,
)
if not game_root:
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
return
# Confirmation callback - show dialog to user
def confirmation_callback(description: str) -> bool:
from ..services.message_service import MessageService
reply = MessageService.question(
self,
"VNV Post-Install Automation",
description,
critical=False,
safety_level="medium"
)
return reply == QMessageBox.Yes
# Manual file callback for non-Premium users
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
from PySide6.QtWidgets import QFileDialog
from ..services.message_service import MessageService
# Show instructions
MessageService.information(self, title, instructions)
# Open file picker
file_path, _ = QFileDialog.getOpenFileName(
self,
title,
str(Path.home() / "Downloads"),
"All Files (*.*)"
)
if file_path:
return Path(file_path).resolve()
return None
# Run automation
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=None, # GUI doesn't need progress updates for post-install
manual_file_callback=manual_file_callback,
confirmation_callback=confirmation_callback
def _on_vnv_complete(self, success: bool, error: str):
"""Handle VNV automation completion and show deferred success dialog."""
self._end_post_install_feedback(not bool(error))
if not success and error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
elif success:
self._safe_append_text("VNV post-install automation completed successfully.")
if error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params
except Exception as e:
logger.debug(f"ERROR: Failed to run VNV automation: {e}")
import traceback
logger.debug(f"Traceback: {traceback.format_exc()}")
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,
)
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)
@@ -335,4 +266,3 @@ class ConfigureNewModlistDialogsMixin:
btn_return.clicked.connect(on_return)
btn_exit.clicked.connect(on_exit)
dlg.exec()

View File

@@ -55,6 +55,12 @@ class ConfigureNewModlistUISetupMixin:
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready to configure", 0)
self.file_progress_list = FileProgressList()
self._post_install_sequence = self._build_post_install_sequence()
self._post_install_total_steps = len(self._post_install_sequence)
self._post_install_current_step = 0
self._post_install_active = False
self._post_install_last_label = ""
self._bsa_hold_deadline = 0.0
# Create "Show Details" checkbox
self.show_details_checkbox = QCheckBox("Show details")
@@ -601,4 +607,3 @@ class ConfigureNewModlistUISetupMixin:
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
return True # Continue anyway

View File

@@ -145,7 +145,6 @@ class ConfigureNewModlistWorkflowMixin:
progress_update = Signal(str)
workflow_complete = Signal(object) # Will emit the result tuple
error_occurred = Signal(object) # error (JackifyError or str)
def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart):
super().__init__()
self.modlist_name = modlist_name
@@ -153,27 +152,23 @@ class ConfigureNewModlistWorkflowMixin:
self.mo2_exe_path = mo2_exe_path
self.steamdeck = steamdeck
self.auto_restart = auto_restart
def run(self):
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
# Initialize the automated prefix service
prefix_service = AutomatedPrefixService()
# Define progress callback for GUI updates
def progress_callback(message):
self.progress_update.emit(message)
# Run the automated workflow (this contains the blocking operations)
result = prefix_service.run_working_workflow(
self.modlist_name, self.install_dir, self.mo2_exe_path,
self.modlist_name, self.install_dir, self.mo2_exe_path,
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart
)
# Emit the result
self.workflow_complete.emit(result)
except Exception as e:
from jackify.shared.errors import JackifyError, prefix_creation_failed
if not isinstance(e, JackifyError):
@@ -474,7 +469,10 @@ class ConfigureNewModlistWorkflowMixin:
)
if not success:
self.error_occurred.emit("Configuration failed - check logs for details")
self.error_occurred.emit(
"Configuration did not complete successfully. "
"Review the latest workflow output above for the failing step."
)
except Exception as e:
import traceback
@@ -509,4 +507,3 @@ class ConfigureNewModlistWorkflowMixin:
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_seconds_remainder} seconds"

View File

@@ -7,6 +7,7 @@ MO2SetupService. No Wabbajack modlist required.
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -18,11 +19,14 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QThread, Signal, QSize
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.shared.errors import mo2_setup_failed
from jackify.shared.progress_models import FileProgress, OperationType
from ..dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..widgets.progress_indicator import OverallProgressIndicator
from ..widgets.file_progress_list import FileProgressList
from .screen_back_mixin import ScreenBackMixin
@@ -37,10 +41,11 @@ class MO2SetupWorker(QThread):
log_output = Signal(str)
setup_complete = Signal(bool, object, str) # success, app_id (int|None), error_msg
def __init__(self, install_dir: Path, shortcut_name: str):
def __init__(self, install_dir: Path, shortcut_name: str, existing_appid: int | None = None):
super().__init__()
self.install_dir = install_dir
self.shortcut_name = shortcut_name
self.existing_appid = existing_appid
def run(self):
from jackify.backend.services.mo2_setup_service import MO2SetupService
@@ -56,6 +61,7 @@ class MO2SetupWorker(QThread):
success, app_id, error_msg = service.setup_mo2(
install_dir=self.install_dir,
shortcut_name=self.shortcut_name,
existing_appid=self.existing_appid,
progress_callback=_progress,
should_cancel=self.isInterruptionRequested,
)
@@ -68,7 +74,7 @@ class MO2SetupWorker(QThread):
self.setup_complete.emit(False, None, str(e))
class InstallMO2Screen(ScreenBackMixin, QWidget):
class InstallMO2Screen(ScreenBackMixin, FocusReclaimMixin, QWidget):
"""Standalone MO2 setup screen"""
resize_request = Signal(str)
@@ -90,6 +96,10 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
self._user_manually_scrolled = False
self._was_at_bottom = True
from jackify.shared.paths import get_jackify_logs_dir
self.log_path = get_jackify_logs_dir() / "MO2_Install_workflow.log"
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
self.progress_indicator = OverallProgressIndicator(show_progress_bar=False)
self.progress_indicator.set_status("Ready", 0)
@@ -281,7 +291,16 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
def _on_show_details_toggled(self, checked):
self.console.setVisible(checked)
self.resize_request.emit("expand" if checked else "collapse")
if checked:
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.resize_request.emit("expand")
else:
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
self.resize_request.emit("compact")
def _browse_folder(self):
folder = QFileDialog.getExistingDirectory(
@@ -339,15 +358,75 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
if confirm != QMessageBox.Yes:
return
existing_appid = None
candidate_exe = install_dir / "ModOrganizer.exe"
prefix_service = AutomatedPrefixService()
conflict_result = prefix_service.handle_existing_shortcut_conflict(
shortcut_name,
str(candidate_exe),
str(install_dir),
)
if isinstance(conflict_result, list):
action, new_name = prompt_existing_setup_dialog(
self,
window_title="Existing Modlist Setup Detected",
heading="Use Existing Setup or Create a New Shortcut",
body=(
"Jackify found an existing Steam shortcut for this Mod Organizer 2 setup.\n\n"
"Choose 'Use Existing Setup' to reuse the current Steam shortcut, or enter a "
"different name to create a separate shortcut."
),
existing_name=conflict_result[0].get("name", shortcut_name),
requested_name=shortcut_name,
install_dir=str(install_dir),
field_label="New shortcut name",
reuse_label="Use Existing Setup",
new_label="Create New Shortcut",
cancel_label="Cancel",
)
if action == "reuse":
existing_appid = conflict_result[0].get("appid")
if not existing_appid:
MessageService.warning(self, "Existing Setup Not Found", "Jackify could not determine the Steam AppID for the existing shortcut.")
return
self.console.append(f"Reusing existing Steam shortcut '{shortcut_name}'.")
elif action == "new":
if not new_name:
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
return
if new_name == shortcut_name:
MessageService.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.")
return
shortcut_name = new_name
self.shortcut_name_edit.setText(new_name)
else:
self.console.append("Shortcut creation cancelled by user")
return
self.console.clear()
self.file_progress_list.clear()
self.file_progress_list.start_cpu_tracking()
from jackify.backend.handlers.logging_handler import LoggingHandler
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(self.log_path, backup_count=5)
self._write_to_log_file("=" * 60)
self._write_to_log_file("MO2 Setup Started")
self._write_to_log_file(f"Install directory: {install_dir}")
self._write_to_log_file(f"Shortcut name: {shortcut_name}")
if existing_appid:
self._write_to_log_file(f"Existing AppID: {existing_appid}")
self._write_to_log_file("=" * 60)
self.start_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
self.cancel_btn.setEnabled(True)
self.cancel_btn.setText("Cancel Setup")
self.shortcut_name_edit.setEnabled(False)
self.install_dir_edit.setEnabled(False)
self.progress_indicator.set_status("Starting...", 0)
self.worker = MO2SetupWorker(install_dir, shortcut_name)
self.worker = MO2SetupWorker(install_dir, shortcut_name, int(existing_appid) if existing_appid else None)
self.worker.progress_update.connect(self._on_progress_update)
self.worker.progress_update.connect(self._on_activity_progress)
self.worker.log_output.connect(self._on_log_output)
@@ -356,14 +435,25 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
def _on_progress_update(self, message: str):
self.progress_indicator.set_status(message, 0)
if STEAM_RESTART_SENTINEL in message:
self._start_focus_reclaim_retries()
def _on_log_output(self, message: str):
self._write_to_log_file(message)
scrollbar = self.console.verticalScrollBar()
was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
self.console.append(message)
if was_at_bottom and not self._user_manually_scrolled:
scrollbar.setValue(scrollbar.maximum())
def _write_to_log_file(self, message: str):
try:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(f"[{timestamp}] {message}\n")
except Exception:
pass
def _on_setup_complete(self, success: bool, app_id, error_msg: str):
self.file_progress_list.stop_cpu_tracking()
@@ -384,6 +474,9 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
self.start_btn.setEnabled(True)
self.cancel_btn.setEnabled(True)
self.cancel_btn.setText("Cancel")
self.shortcut_name_edit.setEnabled(True)
self.install_dir_edit.setEnabled(True)
if self.worker is not None:
try:
self.worker.deleteLater()
@@ -429,22 +522,11 @@ class InstallMO2Screen(ScreenBackMixin, QWidget):
self.file_progress_list.clear()
self.console.clear()
self.progress_indicator.set_status("Ready", 0)
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
self.console.setVisible(False)
self.resize_request.emit("collapse")
self.force_collapsed_details_state()
def showEvent(self, event):
super().showEvent(event)
# Keep MO2 screen consistent with other workflows: details collapsed by default.
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
self.console.setVisible(False)
self.resize_request.emit("collapse")
self.force_collapsed_details_state()
try:
main_window = self.window()
if main_window:

View File

@@ -415,6 +415,10 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
"""Clean up any running processes when the window closes or is cancelled"""
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
if getattr(self, '_vnv_controller', None) is not None:
self._vnv_controller.cleanup()
self._vnv_controller = None
def _stop_thread(attr_name: str, cancel_method: Optional[str] = None, cooperative_ms: int = 5000, force_ms: int = 10000):
thread = getattr(self, attr_name, None)
if thread is None:
@@ -470,12 +474,19 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
pass
setattr(self, attr_name, None)
# Always stop installer thread first; this is the most likely source of QThread teardown aborts.
# Always stop installer thread first; it needs cancel() not terminate().
_stop_thread('install_thread', cancel_method='cancel', cooperative_ms=15000, force_ms=10000)
# Stop remaining worker threads.
for thread_name in ('prefix_thread', 'config_thread', 'fetch_thread', '_gallery_cache_preload_thread'):
_stop_thread(thread_name)
# Stop any remaining QThread instances on this object, regardless of attribute name.
from PySide6.QtCore import QThread
for attr_name, value in list(vars(self).items()):
if attr_name == 'install_thread':
continue
try:
if isinstance(value, QThread):
_stop_thread(attr_name)
except Exception:
pass
def cancel_installation(self):
"""Cancel the currently running installation"""
@@ -499,6 +510,29 @@ class InstallModlistScreen(ScreenBackMixin, InstallModlistUISetupMixin, ConsoleO
if hasattr(self, 'progress_indicator'):
self.progress_indicator.set_status("Cancelled", None)
# Stop manual download manager and close dialog if active
if getattr(self, '_manual_dl_manager', None) is not None:
try:
self._manual_dl_manager.stop()
except Exception:
pass
self._manual_dl_manager = None
if getattr(self, '_manual_dl_dialog', None) is not None:
try:
self._manual_dl_dialog.close()
except Exception:
pass
self._manual_dl_dialog = None
if getattr(self, '_non_premium_info_dlg', None) is not None:
try:
self._non_premium_info_dlg.close()
except Exception:
pass
self._non_premium_info_dlg = None
self._non_premium_gate_enabled = False
self._non_premium_info_acknowledged = False
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()

View File

@@ -1,6 +1,7 @@
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import QMessageBox, QProgressDialog
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtCore import Qt, QThread, Signal, QTimer
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
@@ -16,7 +17,7 @@ import logging
logger = logging.getLogger(__name__)
from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin
class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMixin):
"""Mixin providing configuration phase workflow and dialog management for InstallModlistScreen."""
def on_configuration_progress(self, progress_msg):
@@ -43,14 +44,9 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
pass
finally:
self.steam_restart_progress = None
# Controls are managed by the proper control management system
# Delay focus reclaim so Steam's window finishes painting before we steal it back
try:
from PySide6.QtCore import QTimer
win = self.window()
QTimer.singleShot(10000, lambda: (win.raise_(), win.activateWindow()))
except Exception:
pass
# Controls are managed by the proper control management system.
# Reclaim focus with bounded retries because Steam restart timing varies.
self._start_focus_reclaim_retries()
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
@@ -167,13 +163,20 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
# No VNV automation - end post-install feedback now
self._end_post_install_feedback(True)
if getattr(self, "_is_update_install", False):
try:
self._verify_update_ini_after_configuration(install_dir)
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="install",
workflow_type=workflow_type,
time_taken=time_str,
game_name=game_name,
parent=self
@@ -196,7 +199,19 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
else:
# Configuration failed for other reasons
self._end_post_install_feedback(False)
MessageService.show_error(self, configuration_failed("Post-install configuration failed."))
MessageService.show_error(
self,
configuration_failed(
"Post-install configuration failed.",
context={
"operation": "install_modlist",
"step": "post_install_configuration",
"modlist_name": modlist_name,
"install_dir": install_dir,
"workflow_type": "update" if getattr(self, "_is_update_install", False) else "install",
},
),
)
except Exception as e:
# Ensure controls are re-enabled even on unexpected errors
self._enable_controls_after_operation()
@@ -206,7 +221,19 @@ class ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
def on_configuration_error(self, error_message):
"""Handle configuration error on main thread"""
self._safe_append_text(f"Configuration failed with error: {error_message}")
MessageService.show_error(self, configuration_failed(str(error_message)))
MessageService.show_error(
self,
configuration_failed(
str(error_message),
context={
"operation": "install_modlist",
"step": "post_install_configuration",
"modlist_name": self.modlist_name_edit.text().strip(),
"install_dir": self.install_dir_edit.text().strip(),
"workflow_type": "update" if getattr(self, "_is_update_install", False) else "install",
},
),
)
# Re-enable all controls on error
self._enable_controls_after_operation()

View File

@@ -155,49 +155,6 @@ class ConsoleOutputMixin:
self._write_to_log_file(message)
return
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
token_error_keywords = [
'token has expired',
'token expired',
'oauth token',
'authentication failed',
'unauthorized',
'401',
'403',
'refresh token',
'authorization failed',
'nexus.*premium.*required',
'premium.*required',
]
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
if is_token_error:
if not self._token_error_notified:
self._token_error_notified = True
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"
)
# Also show in console
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)
# Force console to be visible so user can see the error
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
# Detect known engine bugs and provide helpful guidance
if 'destination array was not long enough' in msg_lower or \
('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower):

View File

@@ -3,6 +3,7 @@ InstallerThread: QThread subclass for running jackify-engine install.
Signals are defined at class level (required for Qt signal/slot).
"""
import json
import os
import re
import threading
@@ -12,7 +13,8 @@ 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.shared.errors import JackifyError
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename
from jackify.shared.errors import JackifyError, cc_content_missing
logger = logging.getLogger(__name__)
@@ -24,6 +26,12 @@ class InstallerThread(QThread):
progress_updated = Signal(object)
installation_finished = Signal(bool, str)
premium_required_detected = Signal(str)
# Emitted when engine outputs a full batch of manual download items.
# Payload: list of dicts with keys: file_name, nexus_url/download_url/url,
# expected_size, mod_name, mod_id, file_id, index, total, loop_iteration
manual_download_list_received = Signal(list)
manual_download_phase_complete = Signal()
non_premium_detected = Signal()
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):
@@ -40,16 +48,81 @@ class InstallerThread(QThread):
self.auth_service = auth_service
self.oauth_info = oauth_info
self._premium_signal_sent = False
self._non_premium_info_sent = False
self._engine_output_buffer = []
self._buffer_size = 10
self.last_error: Optional[JackifyError] = None
self._raw_stderr_lines: list = [] # bounded ring buffer for non-JSON stderr
self._raw_stdout_lines: list = [] # bounded ring buffer for non-JSON stdout
self._pending_manual_downloads: list = [] # accumulates items until list_complete
self._resource_limit_hint: Optional[str] = None
@staticmethod
def _is_generic_failure_text(message: Optional[str]) -> bool:
text = (message or "").strip().lower()
if not text:
return True
generic_markers = (
"did not complete successfully",
"unknown failure",
"an install engine error occurred",
"installation failed due to an engine error",
)
return any(marker in text for marker in generic_markers)
def cancel(self):
self.cancelled = True
if self.process_manager:
self.process_manager.cancel()
def send_continue(self):
"""Send the continue command to the engine after manual downloads are ready."""
if self.process_manager:
sent = self.process_manager.write_stdin('{"command":"continue"}')
if sent:
logger.info("[MDL-1014] Manual download continue command accepted by process stdin")
else:
logger.error("[MDL-9010] Failed to send continue command to engine (stdin unavailable or process exited)")
def _handle_engine_event(self, line: str) -> bool:
"""
Try to parse a stdout line as an engine workflow event.
Returns True if the line was an event (caller should not emit it as output).
"""
stripped = line.strip()
if not stripped.startswith('{'):
return False
try:
obj = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
return False
event = obj.get('event')
if not event:
return False
if event == 'manual_download_required':
self._pending_manual_downloads.append(obj)
return True
if event == 'manual_download_list_complete':
loop_iter = obj.get('loop_iteration', 1)
items = list(self._pending_manual_downloads)
self._pending_manual_downloads.clear()
for item in items:
item['loop_iteration'] = loop_iter
if items:
logger.info(f"[MDL-1000] Engine manual download list complete | loop_iteration={loop_iter} items={len(items)}")
self.manual_download_list_received.emit(items)
return True
if event == 'manual_download_phase_complete':
logger.info("[MDL-1015] Engine reported manual download phase complete")
self.manual_download_phase_complete.emit()
return True
return False
def _read_stderr(self):
try:
for raw in self.process_manager.proc.stderr:
@@ -57,16 +130,116 @@ class InstallerThread(QThread):
if not line:
continue
logger.debug(f"Engine stderr: {line}")
self._raw_stderr_lines.append(line)
if len(self._raw_stderr_lines) > 40:
self._raw_stderr_lines.pop(0)
error = parse_engine_error_line(line)
if error and self.last_error is None:
self.last_error = error
else:
self._raw_stderr_lines.append(line)
if len(self._raw_stderr_lines) > 20:
self._raw_stderr_lines.pop(0)
if self.last_error is None and is_cc_content_error(line):
self.last_error = cc_content_missing(extract_cc_filename(line) or "")
except Exception as e:
logger.debug(f"Stderr reader error: {e}")
def _remember_stdout_line(self, line: str) -> None:
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
cleaned = (line or "").strip()
if not cleaned:
return
if cleaned.startswith("{"):
return
if cleaned.startswith("Installing files ") or cleaned.startswith("Extracting files "):
return
self._raw_stdout_lines.append(cleaned)
if len(self._raw_stdout_lines) > 60:
self._raw_stdout_lines.pop(0)
def _extract_root_cause_line(self) -> Optional[str]:
"""Extract the most actionable error line from stderr/stdout tails."""
combined = list(reversed(self._raw_stderr_lines)) + list(reversed(self._raw_stdout_lines))
if not combined:
return None
ignore_fragments = (
"installation failed",
"install failed",
"exit code",
"building bsa",
"generating debug caches",
)
priority_fragments = (
"too many open files",
"file descriptor",
"resource temporarily unavailable",
"cannot increase file descriptor limit",
"permission denied",
"no space left on device",
"traceback",
"fatal",
"exception",
"error",
"failed",
"could not",
"unable to",
)
for raw in combined:
lowered = raw.lower()
if any(fragment in lowered for fragment in ignore_fragments):
continue
if any(fragment in lowered for fragment in priority_fragments):
return raw
for raw in combined:
lowered = raw.lower()
if any(fragment in lowered for fragment in ignore_fragments):
continue
return raw
return None
def _build_failure_message(self, returncode: int) -> str:
"""Build a user-facing failure message with the best available root cause."""
root_cause = self._extract_root_cause_line()
if root_cause:
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
recent_lines = []
for line in list(reversed(self._raw_stderr_lines)) + list(reversed(self._raw_stdout_lines)):
cleaned = (line or "").strip()
if not cleaned:
continue
lowered = cleaned.lower()
if (
"install failed" in lowered
or "installation failed" in lowered
or "exit code" in lowered
or "building bsa" in lowered
or "generating debug caches" in lowered
):
continue
if cleaned not in recent_lines:
recent_lines.append(cleaned)
if len(recent_lines) >= 3:
break
if recent_lines:
recent_block = "\n- ".join(recent_lines)
return (
"Install engine reported errors.\n\n"
f"Most recent engine output:\n- {recent_block}"
)
if self._resource_limit_hint:
return self._resource_limit_hint
return (
"Install failed, but the engine did not provide a specific error line."
)
def run(self):
try:
from jackify.backend.core.modlist_operations import get_jackify_engine_path
@@ -101,8 +274,25 @@ class InstallerThread(QThread):
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
env = get_clean_subprocess_env(env_vars)
# Install-time resource preflight: keep this visible in workflow output so
# users/support see hard-limit constraints even without debug logging.
try:
from jackify.backend.services.resource_manager import ResourceManager
resource_manager = ResourceManager()
status = resource_manager.get_limit_status()
if status.get('current_hard', 0) < status.get('target_limit', 0):
self._resource_limit_hint = (
f"File descriptor hard limit is {status['current_hard']} "
f"(target {status['target_limit']}); this can cause install failures. "
"Increase ulimit and retry."
)
self.output_received.emit(f"[WARN] {self._resource_limit_hint}\n")
except Exception as e:
logger.debug(f"Resource preflight check failed: {e}")
from jackify.backend.handlers.subprocess_utils import ProcessManager
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True)
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;?]*[ -/]*[@-~]')
@@ -161,6 +351,8 @@ class InstallerThread(QThread):
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.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
@@ -179,7 +371,7 @@ class InstallerThread(QThread):
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 = is_non_premium_indicator(decoded)
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)
@@ -213,9 +405,14 @@ class InstallerThread(QThread):
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 "")
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if self.progress_state_manager:
@@ -225,6 +422,10 @@ class InstallerThread(QThread):
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
self._remember_stdout_line(decoded)
if '[FILE_PROGRESS]' in decoded:
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
@@ -246,6 +447,7 @@ class InstallerThread(QThread):
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
else:
self._remember_stdout_line(decoded)
self.output_received.emit(decoded)
stderr_thread.join(timeout=5)
returncode = self.process_manager.wait()
@@ -265,14 +467,18 @@ class InstallerThread(QThread):
except Exception as e:
logger.debug(f"DEBUG: Error reading remaining output: {e}")
if returncode != 0 and not self.cancelled and self.last_error is None:
stderr_detail = "\n".join(self._raw_stderr_lines[-10:]) if self._raw_stderr_lines else ""
detail = f"Exit code {returncode}.\n\nEngine output:\n{stderr_detail}" if stderr_detail else f"Exit code {returncode}."
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 []
combined_tail = stderr_tail + stdout_tail
tail_text = "\n".join(combined_tail)
detail = f"Exit code {returncode}.\n\nEngine output:\n{tail_text}" if tail_text else f"Exit code {returncode}."
fallback = error_from_exit_code(
returncode,
detail,
context={
"exit_code": returncode,
"stderr_tail_lines": len(self._raw_stderr_lines[-10:]),
"stderr_tail_lines": len(stderr_tail),
"stdout_tail_lines": len(stdout_tail),
},
)
if fallback:
@@ -283,8 +489,14 @@ class InstallerThread(QThread):
elif returncode == 0:
self.installation_finished.emit(True, "Installation completed successfully")
else:
error_msg = f"Installation failed (exit code {returncode})"
logger.debug(f"DEBUG: Engine exited with code {returncode}")
if self.last_error:
error_msg = self.last_error.message or ""
if self._is_generic_failure_text(error_msg):
error_msg = self._build_failure_message(returncode)
self.last_error.message = error_msg
else:
error_msg = self._build_failure_message(returncode)
logger.error(f"Engine install failed | exit_code={returncode} summary={error_msg}")
self.installation_finished.emit(False, error_msg)
except Exception as e:
self.installation_finished.emit(False, f"Installation error: {str(e)}")

View File

@@ -1,6 +1,6 @@
"""Nexus authentication methods for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QProgressDialog, QApplication
from PySide6.QtCore import Qt, QTimer, QThread, Signal
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QApplication
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QDesktopServices, QGuiApplication
import logging
import webbrowser
@@ -47,7 +47,6 @@ class NexusAuthMixin:
layout = QVBoxLayout()
layout.setSpacing(15)
# Explanation label
info_label = QLabel(
"Could not open browser automatically.\n\n"
"Please copy the URL below and paste it into your browser:"
@@ -56,11 +55,10 @@ class NexusAuthMixin:
info_label.setStyleSheet("color: #ccc; font-size: 12px;")
layout.addWidget(info_label)
# URL input (read-only but selectable)
url_input = QLineEdit()
url_input.setText(url)
url_input.setReadOnly(True)
url_input.selectAll() # Pre-select text for easy copying
url_input.selectAll()
url_input.setStyleSheet("""
QLineEdit {
background-color: #1a1a1a;
@@ -74,11 +72,9 @@ class NexusAuthMixin:
""")
layout.addWidget(url_input)
# Button row
button_layout = QHBoxLayout()
button_layout.addStretch()
# Copy button
copy_btn = QPushButton("Copy URL")
copy_btn.setStyleSheet("""
QPushButton {
@@ -101,7 +97,6 @@ class NexusAuthMixin:
copy_btn.clicked.connect(copy_to_clipboard)
button_layout.addWidget(copy_btn)
# Close button
close_btn = QPushButton("Close")
close_btn.setStyleSheet("""
QPushButton {
@@ -119,7 +114,113 @@ class NexusAuthMixin:
button_layout.addWidget(close_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
dialog.exec()
def _show_oauth_paste_dialog(self):
"""Show dialog for pasting jackify:// callback URL as manual fallback."""
import urllib.parse
from pathlib import Path
dialog = QDialog(self)
dialog.setWindowTitle("Paste Callback URL")
dialog.setModal(True)
dialog.setMinimumWidth(560)
layout = QVBoxLayout()
layout.setSpacing(12)
layout.setContentsMargins(20, 20, 20, 20)
info_label = QLabel(
"If your browser did not complete the flow automatically:\n\n"
"1. Click Continue in your browser if you have not already.\n"
"2. If a URL starting with jackify:// appears in your browser\n"
" address bar, copy it and paste it below."
)
info_label.setWordWrap(True)
info_label.setStyleSheet("color: #ccc; font-size: 12px;")
layout.addWidget(info_label)
url_input = QLineEdit()
url_input.setPlaceholderText("jackify://oauth/callback?code=...&state=...")
url_input.setStyleSheet("""
QLineEdit {
background-color: #1a1a1a;
color: #3fd0ea;
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 11px;
}
""")
layout.addWidget(url_input)
error_label = QLabel("")
error_label.setStyleSheet("color: #f44336; font-size: 11px;")
error_label.setWordWrap(True)
layout.addWidget(error_label)
btn_layout = QHBoxLayout()
btn_layout.addStretch()
submit_btn = QPushButton("Submit")
submit_btn.setStyleSheet("""
QPushButton {
background-color: #3fd0ea;
color: #000;
border: none;
border-radius: 4px;
padding: 8px 20px;
font-weight: bold;
}
QPushButton:hover {
background-color: #5fdfff;
}
""")
def on_submit():
url = url_input.text().strip()
if not url.startswith('jackify://oauth/callback'):
error_label.setText("URL must start with jackify://oauth/callback")
return
parsed = urllib.parse.urlparse(url)
params = urllib.parse.parse_qs(parsed.query)
code = params.get('code', [None])[0]
state = params.get('state', [None])[0]
if not code or not state:
error_label.setText("URL is missing required code or state parameter.")
return
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
try:
callback_file.parent.mkdir(parents=True, exist_ok=True)
callback_file.write_text(f"{code}\n{state}")
logger.info("OAuth callback written via manual paste")
dialog.accept()
except Exception as e:
error_label.setText(f"Failed to write callback: {e}")
submit_btn.clicked.connect(on_submit)
url_input.returnPressed.connect(on_submit)
btn_layout.addWidget(submit_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet("""
QPushButton {
background-color: #444;
color: #ccc;
border: none;
border-radius: 4px;
padding: 8px 20px;
}
QPushButton:hover {
background-color: #555;
}
""")
cancel_btn.clicked.connect(dialog.reject)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
dialog.setLayout(layout)
dialog.exec()
@@ -129,13 +230,11 @@ class NexusAuthMixin:
authenticated, method, _ = self.auth_service.get_auth_status()
if authenticated and method == 'oauth':
# OAuth is active - offer to revoke
reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low")
if reply == QMessageBox.Yes:
self.auth_service.revoke_oauth()
self._update_nexus_status()
else:
# Not authorised or using API key - offer to authorise with OAuth
reply = MessageService.question(self, "Authorise with Nexus",
"Your browser will open for Nexus authorisation.\n\n"
"Note: Your browser may ask permission to open 'xdg-open'\n"
@@ -146,33 +245,82 @@ class NexusAuthMixin:
if reply != QMessageBox.Yes:
return
# Create progress dialog
progress = QProgressDialog(
"Waiting for authorisation...\n\nPlease check your browser.",
"Cancel",
0, 0,
self
)
progress.setWindowTitle("Nexus OAuth")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setMinimumWidth(400)
# Build waiting dialog with paste fallback always accessible
wait_dialog = QDialog(self)
wait_dialog.setWindowTitle("Nexus OAuth")
wait_dialog.setWindowModality(Qt.WindowModal)
wait_dialog.setMinimumWidth(420)
wait_layout = QVBoxLayout()
wait_layout.setSpacing(12)
wait_layout.setContentsMargins(20, 20, 20, 20)
wait_label = QLabel(
"Waiting for authorisation...\n\n"
"Please complete authorisation in your browser.\n\n"
"Your browser may ask permission to open Jackify — click Open or Allow."
)
wait_label.setWordWrap(True)
wait_label.setStyleSheet("color: #ccc; font-size: 12px;")
wait_layout.addWidget(wait_label)
wait_layout.addStretch()
btn_layout = QHBoxLayout()
paste_btn = QPushButton("Paste callback URL")
paste_btn.setToolTip(
"If your browser shows a jackify:// URL after clicking Continue, paste it here."
)
paste_btn.setStyleSheet("""
QPushButton {
background-color: #333;
color: #aaa;
border: 1px solid #555;
border-radius: 4px;
padding: 8px 16px;
}
QPushButton:hover {
background-color: #444;
color: #ccc;
}
""")
paste_btn.clicked.connect(self._show_oauth_paste_dialog)
btn_layout.addWidget(paste_btn)
btn_layout.addStretch()
# Track cancellation
oauth_cancelled = [False]
def on_cancel():
cancel_btn = QPushButton("Cancel")
cancel_btn.setStyleSheet("""
QPushButton {
background-color: #444;
color: #ccc;
border: none;
border-radius: 4px;
padding: 8px 16px;
}
QPushButton:hover {
background-color: #555;
}
""")
def on_cancel_click():
oauth_cancelled[0] = True
wait_dialog.close()
cancel_btn.clicked.connect(on_cancel_click)
btn_layout.addWidget(cancel_btn)
progress.canceled.connect(on_cancel)
progress.show()
wait_layout.addLayout(btn_layout)
wait_dialog.setLayout(wait_layout)
wait_dialog.show()
QApplication.processEvents()
# Create OAuth thread to prevent GUI freeze
class OAuthThread(QThread):
finished_signal = Signal(bool)
message_signal = Signal(str)
manual_url_signal = Signal(str) # Signal when browser fails to open
manual_url_signal = Signal(str)
def __init__(self, auth_service, parent=None):
super().__init__(parent)
@@ -180,9 +328,7 @@ class NexusAuthMixin:
def run(self):
def show_message(msg):
# Check if this is a "browser failed" message with URL
if "Could not open browser" in msg and "Please open this URL manually:" in msg:
# Extract URL from message
url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:")
url = msg[url_start:].strip()
self.manual_url_signal.emit(url)
@@ -194,23 +340,20 @@ class NexusAuthMixin:
oauth_thread = OAuthThread(self.auth_service, self)
# Connect message signal to update progress dialog
def update_progress_message(msg):
if not oauth_cancelled[0]:
progress.setLabelText(f"Waiting for authorisation...\n\n{msg}")
wait_label.setText(f"Waiting for authorisation...\n\n{msg}")
QApplication.processEvents()
# Connect manual URL signal to show copyable dialog
def show_manual_url_dialog(url):
if not oauth_cancelled[0]:
progress.hide() # Hide progress dialog temporarily
wait_dialog.hide()
self._show_copyable_url_dialog(url)
progress.show()
wait_dialog.show()
oauth_thread.message_signal.connect(update_progress_message)
oauth_thread.manual_url_signal.connect(show_manual_url_dialog)
# Wait for thread completion
oauth_success = [False]
def on_oauth_finished(success):
oauth_success[0] = success
@@ -218,25 +361,21 @@ class NexusAuthMixin:
oauth_thread.finished_signal.connect(on_oauth_finished)
oauth_thread.start()
# Wait for thread to finish (non-blocking event loop)
while oauth_thread.isRunning():
QApplication.processEvents()
oauth_thread.wait(100) # Check every 100ms
oauth_thread.wait(100)
if oauth_cancelled[0]:
# User cancelled - thread will still complete but we ignore result
oauth_thread.wait(2000)
if oauth_thread.isRunning():
oauth_thread.terminate()
break
progress.close()
wait_dialog.close()
QApplication.processEvents()
self._update_nexus_status()
self._enable_controls_after_operation()
# Check success first - if OAuth succeeded, ignore cancellation flag
# (progress dialog close can trigger cancel handler even on success)
if oauth_success[0]:
_, _, username = self.auth_service.get_auth_status()
if username:
@@ -250,11 +389,10 @@ class NexusAuthMixin:
MessageService.warning(
self,
"Authorisation Failed",
"OAuth authorisation failed.\n\n"
"If your browser showed a blank page (e.g. Firefox on Steam Deck),\n"
"try again and use 'Paste callback URL' to paste the URL from the address bar.\n\n"
"If you see 'redirect URI mismatch', the OAuth redirect URI must be configured by Nexus.\n\n"
"You can configure an API key in Settings as a fallback.",
"OAuth authorisation timed out.\n\n"
"If your browser shows a URL starting with jackify:// after\n"
"clicking Continue, try again and use 'Paste callback URL'\n"
"during the wait to complete authorisation manually.\n\n"
"If the issue persists, an API key can be configured in Settings.",
safety_level="medium"
)

View File

@@ -229,6 +229,8 @@ class PostInstallFeedbackMixin:
prev_step = self._post_install_sequence[self._post_install_current_step - 1]
if prev_step['id'] == 'wine_components' and step['id'] != 'wine_components':
self._stop_component_install_pulse()
if prev_step['id'] == 'vnv_bsa_decompress' and step['id'] != 'vnv_bsa_decompress':
self._stop_bsa_decompress_pulse()
self._post_install_current_step = idx
self._post_install_last_label = step['label']
@@ -250,6 +252,9 @@ class PostInstallFeedbackMixin:
self._start_component_install_pulse_with_components(comp_list)
break
if step['id'] == 'vnv_bsa_decompress':
self._start_bsa_decompress_pulse()
# Keep Activity window in sync with progress banner
# If we're already in wine_components step, check for component list updates
# Skip _update_post_install_ui() for wine_components - pulser manages Activity window directly
@@ -402,6 +407,7 @@ class PostInstallFeedbackMixin:
if not self._post_install_active:
return
self._stop_component_install_pulse()
self._stop_bsa_decompress_pulse()
total = max(1, self._post_install_total_steps)
final_step = total if success else max(0, self._post_install_current_step)
label = "Post-installation complete" if success else "Post-installation stopped"
@@ -468,3 +474,18 @@ class PostInstallFeedbackMixin:
if hasattr(self, '_component_install_list'):
del self._component_install_list
def _start_bsa_decompress_pulse(self):
"""Keep the Activity window alive during long BSA decompression runs."""
self.file_progress_list.update_or_add_item("__vnv_bsa__", "VNV: Decompressing BSA files...", 0.0)
if not getattr(self, '_bsa_decompress_timer', None):
self._bsa_decompress_timer = QTimer(self)
self._bsa_decompress_timer.timeout.connect(self._bsa_decompress_heartbeat)
self._bsa_decompress_timer.start(250)
def _bsa_decompress_heartbeat(self):
self.file_progress_list.update_or_add_item("__vnv_bsa__", "VNV: Decompressing BSA files...", 0.0)
def _stop_bsa_decompress_pulse(self):
if hasattr(self, '_bsa_decompress_timer') and self._bsa_decompress_timer:
self._bsa_decompress_timer.stop()
self._bsa_decompress_timer = None

Some files were not shown because too many files have changed in this diff Show More