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

@@ -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

View File

@@ -8,6 +8,7 @@ from jackify.shared.progress_models import InstallationPhase, OperationType, Ins
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
import time
import logging
import os
logger = logging.getLogger(__name__)
class ProgressHandlersMixin:
@@ -53,6 +54,52 @@ class ProgressHandlersMixin:
if hasattr(self, 'install_thread') and self.install_thread:
self.install_thread.cancel()
def on_non_premium_detected(self):
"""Gate the manual-download dialog until non-premium info has been acknowledged."""
self._non_premium_gate_enabled = True
self._non_premium_info_acknowledged = False
logger.info("[MDL-1002] Non-premium flow detected; info dialog will show when manual downloads arrive")
def _show_non_premium_info_dialog(self):
"""Show the non-premium information dialog. Blocks (nested event loop) until user clicks OK.
Called from on_manual_download_list_received, so it only appears when files actually
need manual downloading. The engine is paused waiting for a continue signal at that
point, so process_finished will not fire and close the dialog prematurely.
"""
from PySide6.QtCore import Qt
if getattr(self, '_non_premium_info_dlg', None) is not None:
return
if getattr(self, '_non_premium_info_acknowledged', False):
return
box = QMessageBox(self)
box.setWindowTitle("Non-Premium Account Detected")
box.setIcon(QMessageBox.Information)
box.setWindowModality(Qt.WindowModal)
box.setTextFormat(Qt.RichText)
box.setText(
"<b>Jackify has detected that your Nexus account does not have Premium.</b>"
"<br><br>"
"The install will proceed in the following stages:"
"<ol>"
"<li>Automatically download any mods available from non-Nexus sources</li>"
"<li>After you click OK here, open a manual download dialog listing all remaining manual archives</li>"
"</ol>"
"When your browser opens a Nexus page, click <b>\"Slow Download\"</b>."
" For non-Nexus manual links, follow the site instructions shown in the page.<br><br>"
"<b>Watch folder:</b> Jackify watches the folder shown in that dialog for newly downloaded files. "
"Files detected there are validated and moved automatically into your modlist downloads folder — "
"you do not need to move files manually. If your browser saves to a different location, "
"please set the Watch Folder to that directory before starting the download of mod archives."
)
box.setStandardButtons(QMessageBox.Ok)
self._non_premium_info_dlg = box
box.exec()
self._non_premium_info_dlg = None
self._non_premium_info_acknowledged = True
logger.info("[MDL-1003] Non-premium information dialog acknowledged by user")
def on_progress_updated(self, progress_state):
"""R&D: Handle structured progress updates from parser"""
# Calculate proper overall progress during BSA building
@@ -321,11 +368,16 @@ class ProgressHandlersMixin:
from jackify.backend.utils.modlist_meta import write_modlist_meta
thread = getattr(self, 'install_thread', None)
if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'modlist_name', None):
modlist_version = None
if getattr(thread, 'install_mode', 'online') == 'online':
info = getattr(self, 'selected_modlist_info', None) or {}
modlist_version = info.get('version')
write_modlist_meta(
thread.install_dir,
thread.modlist_name,
getattr(self, '_current_game_type', None),
install_mode=getattr(thread, 'install_mode', 'online'),
modlist_version=modlist_version,
)
except Exception as _meta_err:
logger.debug(f"Modlist meta write skipped: {_meta_err}")
@@ -337,6 +389,19 @@ class ProgressHandlersMixin:
else:
# Reset to initial state on failure
self.progress_indicator.reset()
cancellation_detected = (
(isinstance(message, str) and "cancelled by user" in message.lower())
or bool(getattr(self, '_cancellation_requested', False))
)
if cancellation_detected:
self._installation_cancelled = True
logger.info("Installation cancelled by user")
if self.show_details_checkbox.isChecked():
self._safe_append_text("\nInstallation cancelled by user.")
# Use a distinct non-success code and let process_finished route this
# through the cancellation UX path (not failure path).
self.process_finished(130, QProcess.NormalExit)
return
if self._premium_failure_active:
message = "Installation stopped because Nexus Premium is required for automated downloads."
@@ -359,9 +424,38 @@ class ProgressHandlersMixin:
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug("DEBUG: Button states reset in process_finished")
# Stop manual download manager if it is still running (e.g. install failed mid-phase)
if getattr(self, '_manual_dl_manager', None) is not None:
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
if exit_code == 0:
if getattr(self, "_is_update_install", False):
try:
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
self._record_post_engine_ini_snapshot_and_diff(install_dir)
except Exception as e:
logger.warning("Update mode: failed post-engine MO2 snapshot/diff: %s", e)
# Check if this was an unsupported game
game_type = getattr(self, '_current_game_type', None)
game_name = getattr(self, '_current_game_name', None)
@@ -395,9 +489,22 @@ class ProgressHandlersMixin:
)
if reply == QMessageBox.Yes:
# --- Create Steam shortcut BEFORE restarting Steam ---
# Proceed directly to automated prefix creation
self.start_automated_prefix_workflow()
if getattr(self, "_is_update_install", False) and getattr(self, "_existing_shortcut_appid", None):
# Update workflow: reuse existing shortcut and skip shortcut creation/restart path.
modlist_name = self.modlist_name_edit.text().strip()
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
self._safe_append_text(
f"Update mode: reusing existing Steam shortcut AppID {self._existing_shortcut_appid}."
)
self.continue_configuration_after_automated_prefix(
self._existing_shortcut_appid,
modlist_name,
install_dir,
None,
)
else:
# New install workflow: create shortcut and run automated prefix flow.
self.start_automated_prefix_workflow()
else:
# User selected "No" - show completion message and keep GUI open
self._safe_append_text("\nModlist installation completed successfully!")
@@ -424,6 +531,10 @@ class ProgressHandlersMixin:
logger.warning("Install stopped: Nexus Premium required")
self._safe_append_text("\nInstall stopped: Nexus Premium required.")
self._premium_failure_active = False
elif getattr(self, '_installation_cancelled', False):
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
self._installation_cancelled = False
self._cancellation_requested = False
elif hasattr(self, '_cancellation_requested') and self._cancellation_requested:
# User explicitly cancelled via cancel button
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
@@ -434,14 +545,39 @@ class ProgressHandlersMixin:
if "cancelled by user" in last_output.lower():
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
else:
logger.error(f"Install failed (exit code {exit_code})")
engine_error = getattr(self, '_engine_error', None)
if engine_error:
self._engine_error = None
logger.error(
"Install failed | exit_code=%s error=%s",
exit_code,
engine_error.message,
)
MessageService.show_error(self, engine_error)
self._safe_append_text(f"\nInstall failed: {engine_error.message}")
else:
failure_msg = getattr(self, '_failure_message', None) or f"Exit code {exit_code}."
failure_msg = (
getattr(self, '_failure_message', None)
or "Install failed, but no specific error details were captured from engine output."
)
self._failure_message = None
MessageService.show_error(self, wabbajack_install_failed(failure_msg))
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
logger.error(
"Install failed | exit_code=%s summary=%s",
exit_code,
failure_msg,
)
MessageService.show_error(
self,
wabbajack_install_failed(
failure_msg,
context={
"operation": "install_modlist",
"step": "engine_install",
"exit_code": exit_code,
"modlist_name": self.modlist_name_edit.text().strip(),
"install_dir": self.install_dir_edit.text().strip(),
},
),
)
self._safe_append_text(f"\nInstall failed: {failure_msg}")
self.console.moveCursor(QTextCursor.End)

View File

@@ -1,111 +1,83 @@
"""Steam shortcut conflict dialog and retry workflow for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QHBoxLayout,
)
"""Steam shortcut conflict handling for InstallModlistScreen (Mixin)."""
import os
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from jackify.frontends.gui.services.message_service import MessageService
class InstallModlistShortcutDialogMixin:
"""Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen."""
def _restore_controls_after_shortcut_dialog_abort(self):
"""Return Install Modlist to a usable state when shortcut resolution is aborted."""
if hasattr(self, "_abort_install_validation"):
try:
self._abort_install_validation()
return
except Exception:
pass
try:
self._enable_controls_after_operation()
except Exception:
pass
try:
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
except Exception:
pass
def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to resolve shortcut name conflicts."""
conflict_names = [c['name'] for c in conflicts]
conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'"
"""Show dialog to resolve existing install / shortcut conflicts."""
existing_name = conflicts[0].get("name") or self.modlist_name_edit.text().strip()
modlist_name = self.modlist_name_edit.text().strip()
install_dir = os.path.realpath(self.install_dir_edit.text().strip())
dialog = QDialog(self)
dialog.setWindowTitle("Steam Shortcut Conflict")
dialog.setModal(True)
dialog.resize(450, 180)
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 modlist setup.\n\n"
"If you are updating, repairing, or reconfiguring an existing install, 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",
)
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;
}
""")
if 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(int(existing_appid), modlist_name, install_dir, None)
return
layout = QVBoxLayout(dialog)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(15)
conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:")
layout.addWidget(conflict_label)
name_input = QLineEdit(modlist_name)
name_input.selectAll()
layout.addWidget(name_input)
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)
def on_create():
new_name = name_input.text().strip()
if action == "new":
if new_name and new_name != modlist_name:
dialog.accept()
self.retry_automated_workflow_with_new_name(new_name)
elif new_name == modlist_name:
return
if new_name == modlist_name:
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
else:
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
self._restore_controls_after_shortcut_dialog_abort()
return
def on_cancel():
dialog.reject()
self._safe_append_text("Shortcut creation cancelled by user")
create_button.clicked.connect(on_create)
cancel_button.clicked.connect(on_cancel)
name_input.returnPressed.connect(on_create)
dialog.exec()
self._safe_append_text("Shortcut creation cancelled by user")
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."""

View File

@@ -69,6 +69,11 @@ class InstallModlistUISetupMixin:
self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed)
self._premium_notice_shown = False
self._premium_failure_active = False
self._installation_cancelled = False
self._non_premium_gate_enabled = False
self._non_premium_info_acknowledged = False
self._pending_manual_download_events = None
self._non_premium_info_dlg = None
self._stalled_download_start_time = None
self._stalled_download_notified = False
self._stalled_data_snapshot = 0
@@ -509,4 +514,3 @@ class InstallModlistUISetupMixin:
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()

View File

@@ -1,9 +1,9 @@
"""VNV automation methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer
"""VNV automation methods for InstallModlistScreen (Mixin).
Delegates to VNVAutomationController for the actual workflow.
"""
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
@@ -12,156 +12,28 @@ class VNVAutomationMixin:
"""Mixin providing VNV automation methods for InstallModlistScreen."""
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
"""Check if VNV automation should run and execute if applicable in background thread
Args:
modlist_name: Name of the installed modlist
install_dir: Installation directory path
"""Check if VNV automation should run and start it if applicable.
Returns:
True if VNV automation is starting (success dialog should be deferred)
False if no VNV automation needed (show success dialog immediately)
"""
try:
from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
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 False
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
if not game_root:
logger.debug("DEBUG: VNV automation skipped - FNV game root not found")
return False
# Initialize service to check completion status
vnv_service = VNVPostInstallService(
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path()
)
# Check what's already done
completed = vnv_service.check_already_completed()
# Only skip if ALL three steps are completed
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
logger.info("VNV automation steps already completed")
return False
# Get automation description for confirmation
description = vnv_service.get_automation_description()
# Show confirmation dialog ON MAIN THREAD (not in worker thread!)
from ..services.message_service import MessageService
from PySide6.QtWidgets import QMessageBox
reply = MessageService.question(
self,
"VNV Post-Install Automation",
description,
critical=False,
safety_level="medium"
)
if reply != QMessageBox.Yes:
logger.info("User declined VNV automation")
return False
# Enable post-install progress tracking for VNV automation
self._begin_post_install_feedback()
# User confirmed - start automation in background thread
# Note: manual_file_callback is not passed because Qt GUI operations
# cannot be called from a background thread. If downloads fail,
# the service will return instructions for manual download instead.
self._run_vnv_automation_threaded(
modlist_name,
install_path,
game_root
)
return True # VNV automation is running, defer success dialog
except Exception as e:
logger.debug(f"ERROR: Failed to start VNV automation: {e}")
import traceback
logger.debug(f"Traceback: {traceback.format_exc()}")
return False # Error - show success dialog anyway
def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root):
"""Run VNV automation in a background thread with progress updates
Note: User confirmation should already be obtained before calling this method.
Manual file selection is not supported from background threads - if downloads
fail, the service will return instructions for manual download.
"""
from PySide6.QtCore import QThread, Signal
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
class VNVAutomationWorker(QThread):
progress_update = Signal(str)
completed = Signal(bool, str) # (success, error_message)
def __init__(self, modlist_name, install_path, game_root, ttw_installer_path):
super().__init__()
self.modlist_name = modlist_name
self.install_path = install_path
self.game_root = game_root
self.ttw_installer_path = ttw_installer_path
def run(self):
try:
# User already confirmed, pass lambda that always returns True
# manual_file_callback is None - downloads that fail will return
# instructions for manual download instead of showing Qt dialogs
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=self.modlist_name,
modlist_install_location=self.install_path,
game_root=self.game_root,
ttw_installer_path=self.ttw_installer_path,
progress_callback=self.progress_update.emit,
manual_file_callback=None,
confirmation_callback=lambda desc: True # Already confirmed on main thread
)
self.completed.emit(error is None, error or "")
except Exception as e:
import traceback
self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}")
# Create and start worker
self.vnv_worker = VNVAutomationWorker(
modlist_name,
install_path,
game_root,
AutomatedPrefixService.get_ttw_installer_path()
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,
)
# Connect signals
self.vnv_worker.progress_update.connect(self._on_vnv_progress)
self.vnv_worker.completed.connect(self._on_vnv_complete)
self.vnv_worker.finished.connect(self.vnv_worker.deleteLater)
# Start worker
self.vnv_worker.start()
def _on_vnv_progress(self, message: str):
"""Handle VNV automation progress updates"""
self._safe_append_text(message)
# Also update progress indicator, Activity window, and Details window
self._handle_post_install_progress(message)
def _on_vnv_complete(self, success: bool, error: str):
"""Handle VNV automation completion and show deferred success dialog"""
# End post-install feedback now that VNV automation is complete
self._end_post_install_feedback(True)
"""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
@@ -175,32 +47,26 @@ class VNVAutomationMixin:
elif success:
self._safe_append_text("VNV post-install automation completed successfully")
# Show the deferred success dialog now that VNV automation is complete
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params # Clean up
del self._pending_success_dialog_params
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show success dialog
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=params['modlist_name'],
workflow_type="install",
time_taken=params['time_taken'],
game_name=params['game_name'],
parent=self
parent=self,
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected
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() # 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)

View File

@@ -1,359 +1,367 @@
"""Installation workflow methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QMessageBox
import logging
import os
import re
import shutil
import time
from .install_modlist_installer_thread import InstallerThread
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
from .install_modlist_output_mixin import InstallModlistOutputMixin
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
from jackify.shared.errors import install_dir_create_failed
from .install_modlist_workflow_execution import InstallWorkflowExecutionMixin
logger = logging.getLogger(__name__)
class InstallWorkflowMixin(InstallModlistOutputMixin):
class InstallWorkflowMixin(InstallWorkflowExecutionMixin, InstallModlistOutputMixin):
"""Mixin providing installation workflow methods for InstallModlistScreen."""
def validate_and_start_install(self):
import time
self._install_workflow_start_time = time.time()
logger.debug('DEBUG: validate_and_start_install called')
@staticmethod
def _normalize_version_token(value: str | None) -> str | None:
"""Return a normalized version token for lightweight equality checks."""
if value is None:
return None
token = str(value).strip()
if not token:
return None
token = token.lstrip("vV")
return token.lower()
# Immediately show "Initialising" status to provide feedback
self.progress_indicator.set_status("Initialising...", 0)
from PySide6.QtWidgets import QApplication
QApplication.processEvents() # Force UI update
@staticmethod
def _normalize_modlist_name(value: str | None) -> str:
return " ".join((value or "").strip().lower().split())
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
def _get_requested_modlist_version(self, install_mode: str) -> str | None:
"""Return selected modlist version from gallery metadata when available."""
if install_mode != "online":
return None
info = getattr(self, "selected_modlist_info", None) or {}
return self._normalize_version_token(info.get("version"))
# Check protontricks before proceeding
if not self._check_protontricks():
self.progress_indicator.reset()
def _evaluate_update_candidate(
self,
modlist_name: str,
install_dir: str,
install_mode: str,
existing_appid: str | None,
) -> tuple[bool, dict]:
"""
Decide whether update-mode prompt should be shown.
Policy:
- Require existing shortcut AppID and jackify_meta.json.
- Require modlist identity match (requested name == installed meta name).
- Version relation is informational:
- `different` when both requested/installed versions are available and differ.
- `same` when both are available and equal.
- `unknown` when either side is missing.
"""
from jackify.backend.utils.modlist_meta import read_modlist_meta
result = {
"eligible": False,
"reason": "unknown",
"requested_version": None,
"installed_version": None,
"version_relation": "unknown",
"installed_name": None,
}
if not existing_appid:
result["reason"] = "missing_shortcut_appid"
return False, result
meta = read_modlist_meta(install_dir)
if not meta:
result["reason"] = "missing_meta"
return False, result
installed_name = (meta.get("modlist_name") or "").strip()
result["installed_name"] = installed_name
if self._normalize_modlist_name(installed_name) != self._normalize_modlist_name(modlist_name):
result["reason"] = "modlist_name_mismatch"
return False, result
requested_version = self._get_requested_modlist_version(install_mode)
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 _resolve_modorganizer_ini_path(self, install_dir: str) -> str | None:
"""Return ModOrganizer.ini path for standard/special layouts."""
candidates = [
os.path.join(install_dir, "ModOrganizer.ini"),
os.path.join(install_dir, "files", "ModOrganizer.ini"),
]
for candidate in candidates:
if os.path.isfile(candidate):
return candidate
return None
def _capture_mo2_path_state(self, ini_path: str) -> dict[str, str]:
"""Capture path-critical keys from ModOrganizer.ini for update comparison."""
state: dict[str, str] = {}
section = "root"
try:
with open(ini_path, "r", encoding="utf-8", errors="ignore") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith(("#", ";")):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip() or "root"
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
key_lower = key.lower()
if (
key_lower in {"gamepath", "download_directory"}
or key_lower.startswith("binary")
or key_lower.startswith("workingdirectory")
):
state[f"{section}.{key}"] = value
except Exception as e:
logger.warning("Failed to capture MO2 path state from %s: %s", ini_path, e)
return state
def _create_update_ini_backup(self, ini_path: str, label: str) -> str | None:
"""Create timestamped backup of ModOrganizer.ini for update traceability."""
try:
timestamp = time.strftime("%Y%m%d_%H%M%S")
backup_path = f"{ini_path}.{label}_{timestamp}.bak"
shutil.copy2(ini_path, backup_path)
return backup_path
except Exception as e:
logger.warning("Failed to create %s backup for %s: %s", label, ini_path, e)
return None
def _record_pre_update_ini_snapshot(self, install_dir: str) -> None:
"""Capture pre-engine MO2 ini snapshot/backup for update-mode comparison."""
ini_path = self._resolve_modorganizer_ini_path(install_dir)
if not ini_path:
self._update_pre_engine_ini_path = None
self._update_pre_engine_ini_state = {}
logger.warning("Update mode: ModOrganizer.ini not found before engine phase")
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
try:
tab_index = self.source_tabs.currentIndex()
install_mode = 'online'
if tab_index == 1: # .wabbajack File tab
modlist = self.file_edit.text().strip()
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
self._abort_with_message(
"warning",
"Invalid Modlist",
"Please select a valid .wabbajack file."
)
return
install_mode = 'file'
else:
# For online modlists, ALWAYS use machine_url from selected_modlist_info
# Button text is now the display name (title), NOT the machine URL
if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info:
self._abort_with_message(
"warning",
"Invalid Modlist",
"Modlist information is missing. Please select the modlist again from the gallery."
)
return
machine_url = self.selected_modlist_info.get('machine_url')
if not machine_url:
self._abort_with_message(
"warning",
"Invalid Modlist",
"Modlist information is incomplete. Please select the modlist again from the gallery."
)
return
# CRITICAL: Use machine_url, NOT button text
modlist = machine_url
install_dir = self.install_dir_edit.text().strip()
downloads_dir = self.downloads_dir_edit.text().strip()
# Get authentication token (OAuth or API key) with automatic refresh
api_key, oauth_info = self.auth_service.get_auth_for_engine()
if not api_key:
self._abort_with_message(
"warning",
"Authorisation Required",
"Please authorise with Nexus Mods before installing modlists.\n\n"
"Click the 'Authorise' button above to log in with OAuth,\n"
"or configure an API key in Settings.",
safety_level="medium"
)
return
# Log authentication status at install start (Issue #111 diagnostics)
auth_method = self.auth_service.get_auth_method()
logger.info("=" * 60)
logger.info("Authentication Status at Install Start")
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
logger.info(f"Token length: {len(api_key)} chars")
if len(api_key) >= 8:
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
if auth_method == 'oauth':
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
if 'expires_in_minutes' in token_info:
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
if token_info.get('refresh_token_likely_expired'):
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
logger.info("=" * 60)
modlist_name = self.modlist_name_edit.text().strip()
missing_fields = []
if not modlist_name:
missing_fields.append("Modlist Name")
if not install_dir:
missing_fields.append("Install Directory")
if not downloads_dir:
missing_fields.append("Downloads Directory")
if missing_fields:
self._abort_with_message(
"warning",
"Missing Required Fields",
"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)
)
return
from jackify.backend.handlers.validation_handler import ValidationHandler
validation_handler = ValidationHandler()
is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir))
if not is_safe:
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
dlg = WarningDialog(reason, parent=self)
result = dlg.exec()
if not result or not dlg.confirmed:
self._abort_install_validation()
return
if not os.path.isdir(install_dir):
from ..services.message_service import MessageService
create = MessageService.question(self, "Create Directory?",
f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?",
critical=False # Non-critical, won't steal focus
)
if create == QMessageBox.Yes:
try:
os.makedirs(install_dir, exist_ok=True)
except Exception as e:
MessageService.show_error(self, install_dir_create_failed(install_dir, str(e)))
self._abort_install_validation()
return
else:
self._abort_install_validation()
return
if not os.path.isdir(downloads_dir):
from ..services.message_service import MessageService
create = MessageService.question(self, "Create Directory?",
f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?",
critical=False # Non-critical, won't steal focus
)
if create == QMessageBox.Yes:
try:
os.makedirs(downloads_dir, exist_ok=True)
except Exception as e:
MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e)))
self._abort_install_validation()
return
else:
self._abort_install_validation()
return
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
else:
logger.debug("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
logger.debug("DEBUG: Saved resolution cleared")
ensure_flatpak_steam_filesystem_access(Path(install_dir))
# Handle parent directory saving
self._save_parent_directories(install_dir, downloads_dir)
# Detect game type and check support
game_type = None
game_name = None
if install_mode == 'file':
# Parse .wabbajack file to get game type
wabbajack_path = Path(modlist)
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
# Get display name for the game
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
if game_type == 'unknown' and raw_game_type:
game_name = raw_game_type
else:
game_name = display_names.get(game_type, game_type)
else:
game_type = result
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
game_name = display_names.get(game_type, game_type)
else:
# For online modlists, try to get game type from selected modlist
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
game_name = self.selected_modlist_info.get('game', '')
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
# Map game name to game type
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'enderal': 'enderal',
'enderal special edition': 'enderal'
}
game_type = game_mapping.get(game_name.lower())
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
if not game_type:
game_type = 'unknown'
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
else:
logger.debug(f"DEBUG: No selected_modlist_info found")
game_type = 'unknown'
# Store game type and name for later use
self._current_game_type = game_type
self._current_game_name = game_name
# Check if game is supported
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
if game_type and not is_supported:
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
# Show unsupported game dialog
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
dialog = UnsupportedGameDialog(self, game_name)
if not dialog.show_dialog(self, game_name):
self._abort_install_validation()
return
self.console.clear()
self.process_monitor.clear()
# R&D: Reset progress indicator for new installation
self.progress_indicator.reset()
self.progress_state_manager.reset()
self.file_progress_list.clear()
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
self._premium_notice_shown = False
self._stalled_download_start_time = None
self._stalled_download_notified = False
self._stalled_data_snapshot = 0
self._token_error_notified = False # Reset token error notification
self._premium_failure_active = False
self._post_install_active = False
self._post_install_current_step = 0
# Activity tab is always visible (tabs handle visibility automatically)
# Update button states for installation
self.start_btn.setEnabled(False)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
# CRITICAL: Final safety check - ensure online modlists use machine_url
if install_mode == 'online':
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
expected_machine_url = self.selected_modlist_info.get('machine_url')
if expected_machine_url:
modlist = expected_machine_url # Force use machine_url
else:
self._abort_with_message(
"critical",
"Installation Error",
"Cannot determine modlist machine URL. Please select the modlist again."
)
return
else:
self._abort_with_message(
"critical",
"Installation Error",
"Modlist information is missing. Please select the modlist again from the gallery."
)
return
logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
except Exception as e:
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
import traceback
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
# Re-enable all controls after exception
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# Clear console for fresh installation output
self.console.clear()
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("Starting modlist installation with custom progress handling...")
# Update UI state for installation
self.start_btn.setEnabled(False)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
self.install_thread = InstallerThread(
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info # Pass OAuth state for auto-refresh
self._update_pre_engine_ini_path = ini_path
self._update_pre_engine_ini_state = self._capture_mo2_path_state(ini_path)
self._update_pre_engine_ini_backup = self._create_update_ini_backup(ini_path, "pre_update")
logger.info(
"Update mode: captured pre-engine MO2 state | ini=%s backup=%s keys=%d",
ini_path,
self._update_pre_engine_ini_backup,
len(self._update_pre_engine_ini_state),
)
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
self.install_thread.installation_finished.connect(self.on_installation_finished)
self.install_thread.premium_required_detected.connect(self.on_premium_required_detected)
# R&D: Pass progress state manager to thread
self.install_thread.progress_state_manager = self.progress_state_manager
self.install_thread.start()
def _record_post_engine_ini_snapshot_and_diff(self, install_dir: str) -> None:
"""Capture post-engine MO2 snapshot and log path-key drift vs pre-engine state."""
ini_path = self._resolve_modorganizer_ini_path(install_dir)
if not ini_path:
logger.warning("Update mode: ModOrganizer.ini not found after engine phase")
return
post_state = self._capture_mo2_path_state(ini_path)
post_backup = self._create_update_ini_backup(ini_path, "post_engine")
pre_state = getattr(self, "_update_pre_engine_ini_state", {}) or {}
changed: list[str] = []
for key in sorted(set(pre_state) | set(post_state)):
before = pre_state.get(key)
after = post_state.get(key)
if before != after:
changed.append(f"{key}: '{before}' -> '{after}'")
self._update_ini_path_drift_detected = bool(changed)
self._update_post_engine_ini_state = post_state
self._update_post_engine_ini_path = ini_path
logger.info(
"Update mode: captured post-engine MO2 state | ini=%s backup=%s keys=%d changed=%d",
ini_path,
post_backup,
len(post_state),
len(changed),
)
if changed:
logger.warning("Update mode: MO2 path-key changes detected after engine phase")
for change in changed:
logger.warning("Update mode INI diff | %s", change)
else:
logger.info("Update mode: no path-key changes detected in ModOrganizer.ini after engine phase")
def _verify_update_ini_after_configuration(self, install_dir: str) -> None:
"""Log-only verification of path-critical ModOrganizer.ini keys after update configuration."""
summary = self._evaluate_update_ini_verification(install_dir)
if not summary.get("ini_found"):
logger.warning("Update mode verify: ModOrganizer.ini not found after configuration")
return
logger.info(
"Update mode verify: MO2 ini post-config summary | ini=%s critical_keys=%d empty_critical=%d changed_vs_post_engine=%d changed_vs_pre_engine=%d",
summary["ini_path"],
summary["critical_key_count"],
summary["empty_critical_count"],
summary["changed_vs_post_engine_count"],
summary["changed_vs_pre_engine_count"],
)
if summary["empty_critical_keys"]:
logger.warning("Update mode verify: empty critical MO2 keys detected")
for key in summary["empty_critical_keys"]:
logger.warning("Update mode verify | empty key: %s", key)
def _evaluate_update_ini_verification(self, install_dir: str) -> dict:
"""
Evaluate post-config MO2 path-key integrity for update-mode installs.
Returns a summary dictionary that can be consumed by logging or tests.
"""
ini_path = self._resolve_modorganizer_ini_path(install_dir)
if not ini_path:
return {
"ini_found": False,
"ini_path": None,
"critical_key_count": 0,
"empty_critical_count": 0,
"empty_critical_keys": [],
"changed_vs_post_engine_count": 0,
"changed_vs_pre_engine_count": 0,
"changed_vs_post_engine_keys": [],
"changed_vs_pre_engine_keys": [],
}
final_state = self._capture_mo2_path_state(ini_path)
pre_state = getattr(self, "_update_pre_engine_ini_state", {}) or {}
post_engine_state = getattr(self, "_update_post_engine_ini_state", {}) or {}
critical_items = {
k: v
for k, v in final_state.items()
if (
k.lower().endswith(".gamepath")
or ".binary" in k.lower()
or ".workingdirectory" in k.lower()
or k.lower().endswith(".download_directory")
)
}
empty_critical = [k for k, v in critical_items.items() if not (v or "").strip()]
changed_vs_post_engine = [
k
for k in sorted(set(post_engine_state) | set(final_state))
if post_engine_state.get(k) != final_state.get(k)
]
changed_vs_pre_engine = [
k
for k in sorted(set(pre_state) | set(final_state))
if pre_state.get(k) != final_state.get(k)
]
return {
"ini_found": True,
"ini_path": ini_path,
"critical_key_count": len(critical_items),
"empty_critical_count": len(empty_critical),
"empty_critical_keys": empty_critical,
"changed_vs_post_engine_count": len(changed_vs_post_engine),
"changed_vs_pre_engine_count": len(changed_vs_pre_engine),
"changed_vs_post_engine_keys": changed_vs_post_engine,
"changed_vs_pre_engine_keys": changed_vs_pre_engine,
}
def _find_existing_shortcut_appid(self, modlist_name: str, install_dir: str) -> str | None:
"""Return existing Steam shortcut AppID for this install dir/name when present."""
try:
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
install_real = os.path.realpath(install_dir)
candidate_exes = [
os.path.join(install_real, "ModOrganizer.exe"),
os.path.join(install_real, "files", "ModOrganizer.exe"), # Somnium layout
]
for exe_path in candidate_exes:
if not os.path.exists(exe_path):
continue
appid = shortcut_handler.get_appid_from_vdf(modlist_name, exe_path)
if appid:
return appid
# Fallback: match by name + start dir from shortcuts.vdf even if exe moved
for shortcut in shortcut_handler.find_shortcuts_by_exe("ModOrganizer.exe"):
if (
(shortcut.get("AppName", "").strip() == modlist_name.strip())
and os.path.realpath(shortcut.get("StartDir", "")) == install_real
):
raw_appid = shortcut.get("appid")
if raw_appid is not None:
return str(int(raw_appid) & 0xFFFFFFFF)
except Exception as e:
logger.warning("Update detection: failed shortcut lookup: %s", e)
return None
def _prompt_update_or_new_install(
self,
modlist_name: str,
install_dir: str,
update_meta: dict | None = None,
) -> str:
"""Prompt user when update conditions are met. Returns: 'update'|'new'|'cancel'."""
version_note = ""
if update_meta:
relation = update_meta.get("version_relation")
req = update_meta.get("requested_version")
inst = update_meta.get("installed_version")
if relation == "different":
version_note = (
f"\n\nDetected version change: installed v{inst} -> selected v{req}."
)
elif relation == "same" and inst:
version_note = (
f"\n\nDetected same version (v{inst}). "
"Use the existing setup if you are repairing or reconfiguring this install."
)
body = (
"Jackify detected an existing modlist installation in the selected directory.\n\n"
"Choose 'Use Existing Setup' to continue with the current install and Steam shortcut. "
"Choose 'Create New Shortcut' only if you want a separate Steam entry with a different name."
f"{version_note}"
)
action, new_name = prompt_existing_setup_dialog(
self,
window_title="Existing Modlist Setup Detected",
heading="Use Existing Setup or Create a New Shortcut",
body=body,
existing_name=modlist_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",
)
if action == "reuse":
return "update"
if action == "new":
if not new_name:
MessageBox = QMessageBox # keep local usage explicit
MessageBox.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
return "cancel"
if new_name == modlist_name:
QMessageBox.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.")
return "cancel"
self.modlist_name_edit.setText(new_name)
return "new"
return "cancel"

View File

@@ -0,0 +1,516 @@
"""Execution workflow methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtWidgets import QMessageBox
import logging
import os
from .install_modlist_installer_thread import InstallerThread
from jackify.backend.services.steam_restart_service import ensure_flatpak_steam_filesystem_access
from jackify.shared.errors import install_dir_create_failed
logger = logging.getLogger(__name__)
class InstallWorkflowExecutionMixin:
"""Mixin containing install-run and manual-download dialog execution methods."""
def validate_and_start_install(self):
import time
self._install_workflow_start_time = time.time()
logger.debug('DEBUG: validate_and_start_install called')
# Immediately show "Initialising" status to provide feedback
self.progress_indicator.set_status("Initialising...", 0)
from PySide6.QtWidgets import QApplication
QApplication.processEvents() # Force UI update
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
self.progress_indicator.reset()
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
try:
tab_index = self.source_tabs.currentIndex()
install_mode = 'online'
if tab_index == 1: # .wabbajack File tab
modlist = self.file_edit.text().strip()
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
self._abort_with_message(
"warning",
"Invalid Modlist",
"Please select a valid .wabbajack file."
)
return
install_mode = 'file'
else:
# For online modlists, ALWAYS use machine_url from selected_modlist_info
# Button text is now the display name (title), NOT the machine URL
if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info:
self._abort_with_message(
"warning",
"Invalid Modlist",
"Modlist information is missing. Please select the modlist again from the gallery."
)
return
machine_url = self.selected_modlist_info.get('machine_url')
if not machine_url:
self._abort_with_message(
"warning",
"Invalid Modlist",
"Modlist information is incomplete. Please select the modlist again from the gallery."
)
return
# CRITICAL: Use machine_url, NOT button text
modlist = machine_url
install_dir = self.install_dir_edit.text().strip()
downloads_dir = self.downloads_dir_edit.text().strip()
# Get authentication token (OAuth or API key) with automatic refresh
api_key, oauth_info = self.auth_service.get_auth_for_engine()
if not api_key:
self._abort_with_message(
"warning",
"Authorisation Required",
"Please authorise with Nexus Mods before installing modlists.\n\n"
"Click the 'Authorise' button above to log in with OAuth,\n"
"or configure an API key in Settings.",
safety_level="medium"
)
return
# Log authentication status at install start (Issue #111 diagnostics)
auth_method = self.auth_service.get_auth_method()
logger.info("=" * 60)
logger.info("Authentication Status at Install Start")
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
logger.info(f"Token length: {len(api_key)} chars")
if len(api_key) >= 8:
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
if auth_method == 'oauth':
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
if 'expires_in_minutes' in token_info:
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
if token_info.get('refresh_token_likely_expired'):
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
logger.info("=" * 60)
modlist_name = self.modlist_name_edit.text().strip()
missing_fields = []
if not modlist_name:
missing_fields.append("Modlist Name")
if not install_dir:
missing_fields.append("Install Directory")
if not downloads_dir:
missing_fields.append("Downloads Directory")
if missing_fields:
self._abort_with_message(
"warning",
"Missing Required Fields",
"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)
)
return
from jackify.backend.handlers.validation_handler import ValidationHandler
validation_handler = ValidationHandler()
is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir))
if not is_safe:
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
dlg = WarningDialog(reason, parent=self)
result = dlg.exec()
if not result or not dlg.confirmed:
self._abort_install_validation()
return
if not os.path.isdir(install_dir):
from ..services.message_service import MessageService
create = MessageService.question(self, "Create Directory?",
f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?",
critical=False # Non-critical, won't steal focus
)
if create == QMessageBox.Yes:
try:
os.makedirs(install_dir, exist_ok=True)
except Exception as e:
MessageService.show_error(self, install_dir_create_failed(install_dir, str(e)))
self._abort_install_validation()
return
else:
self._abort_install_validation()
return
if not os.path.isdir(downloads_dir):
from ..services.message_service import MessageService
create = MessageService.question(self, "Create Directory?",
f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?",
critical=False # Non-critical, won't steal focus
)
if create == QMessageBox.Yes:
try:
os.makedirs(downloads_dir, exist_ok=True)
except Exception as e:
MessageService.show_error(self, install_dir_create_failed(downloads_dir, str(e)))
self._abort_install_validation()
return
else:
self._abort_install_validation()
return
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
else:
logger.debug("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
logger.debug("DEBUG: Saved resolution cleared")
ensure_flatpak_steam_filesystem_access(Path(install_dir))
# Handle parent directory saving
self._save_parent_directories(install_dir, downloads_dir)
# Detect game type and check support
game_type = None
game_name = None
if install_mode == 'file':
# Parse .wabbajack file to get game type
wabbajack_path = Path(modlist)
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
# Get display name for the game
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
if game_type == 'unknown' and raw_game_type:
game_name = raw_game_type
else:
game_name = display_names.get(game_type, game_type)
else:
game_type = result
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
game_name = display_names.get(game_type, game_type)
else:
# For online modlists, try to get game type from selected modlist
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
game_name = self.selected_modlist_info.get('game', '')
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
# Map game name to game type
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'enderal': 'enderal',
'enderal special edition': 'enderal'
}
game_type = game_mapping.get(game_name.lower())
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
if not game_type:
game_type = 'unknown'
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
else:
logger.debug(f"DEBUG: No selected_modlist_info found")
game_type = 'unknown'
# Store game type and name for later use
self._current_game_type = game_type
self._current_game_name = game_name
# Check if game is supported
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
if game_type and not is_supported:
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
# Show unsupported game dialog
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
dialog = UnsupportedGameDialog(self, game_name)
if not dialog.show_dialog(self, game_name):
self._abort_install_validation()
return
self.console.clear()
self.process_monitor.clear()
# Collapse Show Details if it was left open by the previous run.
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
from PySide6.QtCore import Qt as _Qt
self._toggle_console_visibility(_Qt.Unchecked)
# R&D: Reset progress indicator for new installation
self.progress_indicator.reset()
self.progress_state_manager.reset()
self.file_progress_list.clear()
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
self._is_update_install = False
self._existing_shortcut_appid = None
self._premium_notice_shown = False
self._stalled_download_start_time = None
self._stalled_download_notified = False
self._stalled_data_snapshot = 0
self._token_error_notified = False # Reset token error notification
self._premium_failure_active = False
self._installation_cancelled = False
self._non_premium_gate_enabled = False
self._non_premium_info_acknowledged = False
self._pending_manual_download_events = None
self._post_install_active = False
self._post_install_current_step = 0
# Activity tab is always visible (tabs handle visibility automatically)
# Update button states for installation
self.start_btn.setEnabled(False)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
# Detect update-vs-new workflow before starting engine install.
from jackify.backend.utils.modlist_meta import JACKIFY_META_FILE
install_real = os.path.realpath(install_dir)
meta_exists = (Path(install_real) / JACKIFY_META_FILE).exists()
existing_appid = self._find_existing_shortcut_appid(modlist_name, install_real)
if meta_exists and existing_appid:
eligible, update_meta = self._evaluate_update_candidate(
modlist_name,
install_real,
install_mode,
existing_appid,
)
if not eligible:
logger.info(
"Update mode not offered | reason=%s requested_name=%s installed_name=%s",
update_meta.get("reason"),
modlist_name,
update_meta.get("installed_name"),
)
else:
logger.info(
"Update mode candidate | version_relation=%s requested_version=%s installed_version=%s",
update_meta.get("version_relation"),
update_meta.get("requested_version"),
update_meta.get("installed_version"),
)
decision = self._prompt_update_or_new_install(modlist_name, install_real, update_meta)
if decision == "cancel":
self._abort_install_validation()
return
if decision == "new":
from ..services.message_service import MessageService
MessageService.warning(
self,
"Shortcut Name Already Exists",
"A Steam shortcut with this name already points to this install directory.\n\n"
"For a new install, choose a different Modlist Name before starting.",
safety_level="medium",
)
self._abort_install_validation()
return
# update
self._is_update_install = True
self._existing_shortcut_appid = existing_appid
self._safe_append_text(
f"Update mode selected. Reusing existing Steam shortcut AppID {existing_appid}."
)
self._record_pre_update_ini_snapshot(install_real)
# CRITICAL: Final safety check - ensure online modlists use machine_url
if install_mode == 'online':
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
expected_machine_url = self.selected_modlist_info.get('machine_url')
if expected_machine_url:
modlist = expected_machine_url # Force use machine_url
else:
self._abort_with_message(
"critical",
"Installation Error",
"Cannot determine modlist machine URL. Please select the modlist again."
)
return
else:
self._abort_with_message(
"critical",
"Installation Error",
"Modlist information is missing. Please select the modlist again from the gallery."
)
return
logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
except Exception as e:
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
import traceback
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
# Re-enable all controls after exception
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# Clear console for fresh installation output
self.console.clear()
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("Starting modlist installation with custom progress handling...")
# Update UI state for installation
self.start_btn.setEnabled(False)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
self._downloads_dir = downloads_dir
self.install_thread = InstallerThread(
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info # Pass OAuth state for auto-refresh
)
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
self.install_thread.installation_finished.connect(self.on_installation_finished)
self.install_thread.premium_required_detected.connect(self.on_premium_required_detected)
self.install_thread.non_premium_detected.connect(self.on_non_premium_detected)
self.install_thread.manual_download_list_received.connect(self.on_manual_download_list_received)
# R&D: Pass progress state manager to thread
self.install_thread.progress_state_manager = self.progress_state_manager
self.install_thread.finished.connect(self.install_thread.deleteLater)
self.install_thread.start()
def on_manual_download_list_received(self, events: list) -> None:
"""Show the manual download dialog when the engine emits a batch of missing files."""
try:
# Show non-premium info dialog synchronously before the file list.
# The engine is paused waiting for a continue signal at this point,
# so process_finished will not fire during exec() and close it prematurely.
if getattr(self, '_non_premium_gate_enabled', False) and not getattr(self, '_non_premium_info_acknowledged', False):
self._show_non_premium_info_dialog()
logger.info(f"[MDL-1005] Showing manual download dialog for batch | items={len(events)}")
self._show_manual_download_dialog(events)
except Exception as exc:
logger.error(f"Manual download dialog setup failed: {exc}", exc_info=True)
self._safe_append_text(f"\n[ERROR] Manual download dialog failed to open: {exc}\n")
def _flush_pending_manual_download_events(self) -> None:
events = getattr(self, '_pending_manual_download_events', None)
if not events:
return
self._pending_manual_download_events = None
logger.info(f"[MDL-1007] Releasing queued manual download batch after acknowledgement | items={len(events)}")
self._show_manual_download_dialog(events)
def _show_manual_download_dialog(self, events: list) -> None:
from pathlib import Path as _Path
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.services.manual_download_manager import ManualDownloadManager
from jackify.frontends.gui.dialogs.manual_download_dialog import ManualDownloadDialog
cfg_watch = ConfigHandler().get("manual_download_watch_directory", None)
watch_dir = None
if cfg_watch:
cfg_path = _Path(str(cfg_watch)).expanduser()
if cfg_path.is_dir():
watch_dir = cfg_path
if watch_dir is None:
xdg_dl = Path(os.environ.get('XDG_DOWNLOAD_DIR', '')) if os.environ.get('XDG_DOWNLOAD_DIR') else None
watch_dir = xdg_dl if (xdg_dl and xdg_dl.is_dir()) else _Path.home() / 'Downloads'
dl_dir = _Path(self._downloads_dir) if hasattr(self, '_downloads_dir') else watch_dir
loop_iteration = events[0].get('loop_iteration', 1) if events else 1
count = len(events)
raw_limit = ConfigHandler().get('manual_download_concurrent_limit', 2)
try:
concurrent_limit = int(raw_limit)
except (TypeError, ValueError):
concurrent_limit = 2
concurrent_limit = max(1, min(5, concurrent_limit))
self._safe_append_text(
f"\n[Manual Download Required] {count} file(s) need manual download.\n"
f"Opening download dialog — check your taskbar if it does not appear in front.\n"
)
logger.info(
f"[MDL-1006] Manual download protocol initialized | count={count} "
f"loop_iteration={loop_iteration} watch_dir={watch_dir} downloads_dir={dl_dir}"
)
# New install run: start with a fresh manager/dialog to avoid stale statuses from prior runs.
if loop_iteration == 1:
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 not hasattr(self, '_manual_dl_manager') or self._manual_dl_manager is None:
self._manual_dl_manager = ManualDownloadManager(
modlist_download_dir=dl_dir,
watch_directory=watch_dir,
concurrent_limit=concurrent_limit,
on_send_continue=self.install_thread.send_continue,
)
self._manual_dl_dialog = ManualDownloadDialog(
manager=self._manual_dl_manager,
modlist_name=self.modlist_name_edit.text().strip() if hasattr(self, 'modlist_name_edit') else '',
watch_directory=watch_dir,
concurrent_limit=concurrent_limit,
parent=self,
)
self._manual_dl_manager.load_items(events, loop_iteration)
self._manual_dl_dialog.load_items(self._manual_dl_manager.items)
if not self._manual_dl_dialog.isVisible():
self._manual_dl_dialog.show()

View File

@@ -304,25 +304,28 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
"""Clean up any running processes when the window closes or is cancelled"""
logger.debug("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes")
# Clean up InstallationThread if running
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
# install_thread uses cancel() for cooperative shutdown before terminate.
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
logger.debug("DEBUG: Cancelling running InstallationThread")
self.install_thread.cancel()
self.install_thread.wait(3000) # Wait up to 3 seconds
self.install_thread.wait(3000)
if self.install_thread.isRunning():
self.install_thread.terminate()
# Clean up other threads
threads = [
'prefix_thread', 'config_thread', 'fetch_thread'
]
for thread_name in threads:
if hasattr(self, thread_name):
thread = getattr(self, thread_name)
if thread and thread.isRunning():
logger.debug(f"DEBUG: Terminating {thread_name}")
thread.terminate()
thread.wait(1000) # Wait up to 1 second
self.install_thread.wait(2000)
self.install_thread = None
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) and value.isRunning():
logger.debug(f"DEBUG: Terminating {attr_name}")
value.terminate()
value.wait(2000)
setattr(self, attr_name, None)
except Exception:
pass
def cancel_installation(self):
"""Cancel the currently running installation"""
@@ -353,7 +356,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
""")
# Cancel the installation thread if it exists
if hasattr(self, 'install_thread') and self.install_thread.isRunning():
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
self.install_thread.cancel()
self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.install_thread.isRunning():
@@ -361,7 +364,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
self.install_thread.wait(1000)
# Cancel the automated prefix thread if it exists
if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning():
if hasattr(self, 'prefix_thread') and self.prefix_thread and self.prefix_thread.isRunning():
self.prefix_thread.terminate()
self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.prefix_thread.isRunning():
@@ -369,7 +372,7 @@ class InstallTTWScreen(ScreenBackMixin, TTWUISetupMixin, TTWIntegrationMixin, TT
self.prefix_thread.wait(1000)
# Cancel the configuration thread if it exists
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown
if self.config_thread.isRunning():

View File

@@ -51,6 +51,9 @@ class TTWOutputMixin:
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
if is_error and 'cannot get directory path for location type' in lower_cleaned:
self._ttw_unclean_game_dir_detected = True
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
if should_show:

View File

@@ -143,7 +143,11 @@ class TTWInstallationThread(QThread):
elif returncode == 0:
self.installation_finished.emit(True, "TTW installation completed successfully!")
else:
self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}")
self.installation_finished.emit(
False,
f"TTW installer exited unexpectedly (code {returncode}). "
"Review the recent console output for the failing step."
)
except Exception as e:
import traceback

View File

@@ -209,6 +209,14 @@ class TTWWorkflowMixin:
font-size: 13px;
""")
self._safe_append_text(f"\nError: {message}")
if getattr(self, '_ttw_unclean_game_dir_detected', False):
self._safe_append_text(
"\nLikely cause: Your Fallout New Vegas game directory is not clean vanilla.\n"
"TTW requires an unmodified FNV installation to patch correctly.\n"
"If you have previously installed an FNV modlist that modifies the game directory,\n"
"verify or reinstall FNV via Steam to restore vanilla files, then try again."
)
self._last_install_message = message
self.process_finished(1, QProcess.CrashExit)
def process_finished(self, exit_code, exit_status):
@@ -247,9 +255,11 @@ class TTWWorkflowMixin:
)
else:
last_output = self.console.toPlainText()
if "cancelled by user" in last_output.lower():
failure_msg = (getattr(self, '_last_install_message', '') or "").strip()
if "cancelled by user" in last_output.lower() or "cancelled by user" in failure_msg.lower():
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
else:
MessageService.show_error(self, wabbajack_install_failed(f"Exit code {exit_code}. Check the console output for details."))
self._safe_append_text(f"\nInstall failed (exit code {exit_code}).")
user_summary = failure_msg or "TTW installation failed. Review recent console output for the failing step."
MessageService.show_error(self, wabbajack_install_failed(user_summary))
self._safe_append_text(f"\nInstall failed: {user_summary}")
self.console.moveCursor(QTextCursor.End)

View File

@@ -6,6 +6,7 @@ should use this mixin so the main window consistently collapses when leaving.
"""
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QSizePolicy
from ..utils import set_responsive_minimum
@@ -48,3 +49,48 @@ class ScreenBackMixin:
self.show_details_checkbox.blockSignals(False)
if not is_steamdeck and hasattr(self, "_toggle_console_visibility"):
self._toggle_console_visibility(Qt.Unchecked)
def force_collapsed_details_state(self, resize_mode: str = "compact"):
"""
Normalize Show Details state when a screen is opened/reset.
Some screens still manage console visibility locally instead of through a
single shared widget module. This helper forces the collapsed state in a
way that is safe across those implementations.
"""
try:
if hasattr(self, "show_details_checkbox"):
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
except Exception:
pass
try:
if hasattr(self, "console"):
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
except Exception:
pass
try:
if hasattr(self, "console_and_buttons_widget"):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.console_and_buttons_widget.setFixedHeight(50)
self.console_and_buttons_widget.updateGeometry()
except Exception:
pass
try:
if hasattr(self, "main_overall_vbox") and hasattr(self, "console"):
self.main_overall_vbox.setStretchFactor(self.console, 0)
except Exception:
pass
try:
if hasattr(self, "resize_request"):
self.resize_request.emit(resize_mode)
except Exception:
pass

View File

@@ -0,0 +1,58 @@
"""Shared mixin for reclaiming window focus after Steam restart."""
import logging
from PySide6.QtCore import QTimer, Qt
logger = logging.getLogger(__name__)
STEAM_RESTART_SENTINEL = "[Jackify] Steam restart complete"
class FocusReclaimMixin:
"""Mixin providing post-Steam-restart focus reclaim for any screen.
Usage: inherit this mixin and call _start_focus_reclaim_retries() when
Steam restart is detected. Detection is typically done by checking
progress messages for STEAM_RESTART_SENTINEL.
"""
def _start_focus_reclaim_retries(self):
try:
if hasattr(self, "_focus_reclaim_timer") and self._focus_reclaim_timer:
self._focus_reclaim_timer.stop()
self._focus_reclaim_timer.deleteLater()
except Exception:
pass
self._focus_reclaim_attempt = 0
self._focus_reclaim_max_attempts = 12 # ~24 seconds total
self._focus_reclaim_timer = QTimer(self)
self._focus_reclaim_timer.setInterval(2000)
self._focus_reclaim_timer.timeout.connect(self._focus_reclaim_tick)
self._focus_reclaim_timer.start()
self._focus_reclaim_tick()
def _focus_reclaim_tick(self):
try:
win = self.window()
if win is None:
return
self._focus_reclaim_attempt += 1
win.raise_()
win.activateWindow()
win.setWindowState(win.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
if win.isActiveWindow():
logger.info("Foreground focus reclaimed after Steam restart")
self._focus_reclaim_timer.stop()
return
if self._focus_reclaim_attempt >= self._focus_reclaim_max_attempts:
logger.warning("Foreground focus reclaim timed out after Steam restart")
self._focus_reclaim_timer.stop()
except Exception as e:
logger.debug(f"Focus reclaim tick failed: {e}")
try:
self._focus_reclaim_timer.stop()
except Exception:
pass

View File

@@ -20,9 +20,12 @@ from PySide6.QtCore import Qt, QThread, Signal, QSize
from PySide6.QtGui import QTextCursor
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.shared.errors import wabbajack_install_failed
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 .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
from ..utils import set_responsive_minimum
from ..widgets.file_progress_list import FileProgressList
from ..widgets.progress_indicator import OverallProgressIndicator
@@ -39,11 +42,12 @@ class WabbajackInstallerWorker(QThread):
log_output = Signal(str) # Console log output
installation_complete = Signal(bool, str, str, str, str) # Success, message, launch_options, app_id, time_taken
def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True):
def __init__(self, install_folder: Path, shortcut_name: str = "Wabbajack", enable_gog: bool = True, existing_appid: int | None = None):
super().__init__()
self.install_folder = install_folder
self.shortcut_name = shortcut_name
self.enable_gog = enable_gog
self.existing_appid = existing_appid
self.launch_options = "" # Store launch options for success message
self.start_time = None # Track installation start time
@@ -73,6 +77,7 @@ class WabbajackInstallerWorker(QThread):
install_folder=self.install_folder,
shortcut_name=self.shortcut_name,
enable_gog=self.enable_gog,
existing_appid=self.existing_appid,
progress_callback=progress_callback,
log_callback=log_callback
)
@@ -84,7 +89,7 @@ class WabbajackInstallerWorker(QThread):
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
class WabbajackInstallerScreen(ScreenBackMixin, FocusReclaimMixin, QWidget):
"""Wabbajack installer GUI screen following standard Jackify layout"""
resize_request = Signal(str)
@@ -347,10 +352,17 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
def _on_show_details_toggled(self, checked):
"""Handle Show details checkbox toggle"""
self.console.setVisible(checked)
if checked:
self.console.setVisible(True)
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.resize_request.emit("expand")
else:
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
self.resize_request.emit("compact")
def _browse_folder(self):
@@ -398,6 +410,51 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
if confirm != QMessageBox.Yes:
return
existing_appid = None
candidate_exe = self.install_folder / "Wabbajack.exe"
prefix_service = AutomatedPrefixService()
conflict_result = prefix_service.handle_existing_shortcut_conflict(
self.shortcut_name,
str(candidate_exe),
str(self.install_folder),
)
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 Wabbajack 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", self.shortcut_name),
requested_name=self.shortcut_name,
install_dir=str(self.install_folder),
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._write_to_log_file(f"Reusing existing Steam shortcut '{self.shortcut_name}' with AppID {existing_appid}")
elif action == "new":
if not new_name:
MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.")
return
if new_name == self.shortcut_name:
MessageService.warning(self, "Same Name", "Please enter a different name to create a separate shortcut.")
return
self.shortcut_name = new_name
self.shortcut_name_edit.setText(new_name)
else:
self._write_to_log_file("Shortcut creation cancelled by user")
return
# Clear displays
self.console.clear()
self.file_progress_list.clear()
@@ -420,7 +477,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
self.progress_indicator.set_status("Starting installation...", 0)
# Start worker thread
self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True)
self.worker = WabbajackInstallerWorker(self.install_folder, shortcut_name=self.shortcut_name, enable_gog=True, existing_appid=int(existing_appid) if existing_appid else None)
self.worker.progress_update.connect(self._on_progress_update)
self.worker.activity_update.connect(self._on_activity_update)
self.worker.log_output.connect(self._on_log_output)
@@ -428,8 +485,9 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
self.worker.start()
def _on_progress_update(self, message: str, percentage: int):
"""Handle progress updates"""
self.progress_indicator.set_status(message, percentage)
if STEAM_RESTART_SENTINEL in message:
self._start_focus_reclaim_retries()
def _on_activity_update(self, label: str, current: int, total: int):
"""Handle activity tab updates"""
@@ -569,6 +627,7 @@ class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
def showEvent(self, event):
"""Called when widget becomes visible"""
super().showEvent(event)
self.force_collapsed_details_state()
try:
main_window = self.window()
if main_window:

View File

@@ -0,0 +1,402 @@
"""
Shared VNV post-install automation controller for all GUI workflows.
Handles VNV detection, user confirmation, premium/non-premium download paths,
worker thread management, and completion callbacks.
"""
import logging
from pathlib import Path
from typing import Callable, Optional
from PySide6.QtCore import QThread, Signal, Slot, QObject
from PySide6.QtWidgets import QMessageBox, QWidget
logger = logging.getLogger(__name__)
class _VNVWorker(QThread):
"""Background thread for VNV automation."""
progress_update = Signal(str)
completed = Signal(bool, str) # (success, error_message)
def __init__(self, modlist_name, install_path, game_root, ttw_installer_path):
super().__init__()
self._modlist_name = modlist_name
self._install_path = install_path
self._game_root = game_root
self._ttw_installer_path = ttw_installer_path
def run(self):
try:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=self._modlist_name,
modlist_install_location=self._install_path,
game_root=self._game_root,
ttw_installer_path=self._ttw_installer_path,
progress_callback=self.progress_update.emit,
manual_file_callback=None,
confirmation_callback=lambda desc: True,
)
self.completed.emit(error is None, error or "")
except Exception as e:
import traceback
self.completed.emit(False, f"{e}\n{traceback.format_exc()}")
class VNVAutomationController(QObject):
"""
Single entry point for VNV post-install automation across all GUI workflows.
Usage in any screen's on_configuration_complete:
from ..services.vnv_automation_controller import VNVAutomationController
controller = VNVAutomationController()
if controller.attempt(
parent=self,
modlist_name=modlist_name,
install_dir=install_dir,
on_progress=self._safe_append_text,
on_complete=lambda success, error: self._on_vnv_done(success, error),
):
# VNV is running, defer success dialog
return
# No VNV, show success dialog now
"""
# Emitted from the watcher background thread; delivered on main thread
# via auto-queued connection because this object lives on the main thread.
_worker_start_requested = Signal()
def __init__(self):
super().__init__()
self._worker: Optional[_VNVWorker] = None
self._manual_manager = None
self._manual_dialog = None
self._pending_worker_start: Optional[Callable] = None
self._on_progress_cb: Optional[Callable] = None
self._on_complete_cb: Optional[Callable] = None
self._handle_feedback_cb: Optional[Callable] = None
self._worker_start_requested.connect(self._dispatch_worker_start)
def attempt(
self,
parent: QWidget,
modlist_name: str,
install_dir: str,
on_progress: Callable[[str], None],
on_complete: Callable[[bool, str], None],
begin_feedback: Optional[Callable[[], None]] = None,
handle_feedback: Optional[Callable[[str], None]] = None,
) -> bool:
"""Check for VNV eligibility and start automation if applicable.
Args:
parent: Parent QWidget for dialogs
modlist_name: Name of the modlist
install_dir: Installation directory path
on_progress: Called with progress text messages
on_complete: Called with (success, error_message) when done
begin_feedback: Optional - start post-install progress UI
handle_feedback: Optional - update post-install progress UI
Returns:
True if VNV automation is starting (caller should defer success dialog)
False if no VNV needed (caller should show success dialog immediately)
"""
try:
from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
install_path = Path(install_dir)
if not should_offer_vnv_automation(modlist_name, install_path):
return False
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
if not game_root:
logger.debug("VNV automation skipped - FNV game root not found")
on_progress("VNV automation skipped: Fallout New Vegas path not found")
return False
# Check completion status
vnv_service = VNVPostInstallService(
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
logger.info("VNV automation steps already completed")
return False
# Confirmation dialog
from .message_service import MessageService
reply = MessageService.question(
parent,
"VNV Post-Install Automation",
vnv_service.get_automation_description(),
critical=False,
safety_level="medium",
)
if reply != QMessageBox.Yes:
logger.info("User declined VNV automation")
on_progress("VNV automation skipped by user")
return False
ttw_installer_path = AutomatedPrefixService.get_ttw_installer_path()
# Non-premium path: route 4GB patcher through ManualDownloadManager
from jackify.backend.services.nexus_auth_service import NexusAuthService
from jackify.backend.services.nexus_premium_service import NexusPremiumService
auth_svc = NexusAuthService()
token = auth_svc.get_auth_token()
is_premium = False
if token:
is_premium, _ = NexusPremiumService().check_premium_status(
token, is_oauth=(auth_svc.get_auth_method() == "oauth")
)
if not is_premium:
has_4gb_cache = vnv_service._find_cached_4gb_patcher() is not None
has_bsa_cache = (
vnv_service._find_cached_bsa_mpi() is not None or
vnv_service._find_cached_bsa_package() is not None
)
if has_4gb_cache and has_bsa_cache:
logger.debug("VNV non-premium: required VNV tools already cached, proceeding to worker")
else:
tool_events = vnv_service.get_manual_download_items(include_bsa=not has_bsa_cache)
logger.debug("VNV non-premium: tool_events=%d, cache_dir=%s", len(tool_events), vnv_service.cache_dir)
if tool_events:
if begin_feedback:
begin_feedback()
self._show_tool_download_dialog(
parent, tool_events, vnv_service.cache_dir,
modlist_name, install_path, game_root, ttw_installer_path,
on_progress, on_complete, handle_feedback,
)
return True
else:
# Nexus API unavailable — can't auto-track the download.
# Open the mod page so the user can get it manually and inform
# them where to place it so the worker finds it next time.
logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager")
try:
import subprocess
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'])
except Exception:
pass
from .message_service import MessageService
MessageService.information(
parent,
"VNV Tools — Manual Download Required",
"Jackify could not query the Nexus download URL(s) (check your Nexus login in Settings).\n\n"
"Your modlist has been installed successfully.\n\n"
"To complete VNV post-install setup, please:\n"
"1. Download the '4GB Patcher (Linux/Proton)' from:\n"
" nexusmods.com/newvegas/mods/62552\n\n"
"2. Download the BSA Decompressor package from:\n"
" nexusmods.com/newvegas/mods/65854\n\n"
f"3. Place the archive(s) in:\n {vnv_service.cache_dir}\n\n"
"4. Re-configure the modlist — Jackify will detect the files automatically.",
)
return False
# Premium or all tools already cached - start worker directly
if begin_feedback:
begin_feedback()
self._start_worker(
parent, modlist_name, install_path, game_root,
ttw_installer_path, on_progress, on_complete, handle_feedback,
)
return True
except Exception as e:
logger.error("Failed to start VNV automation: %s", e)
import traceback
logger.error("Traceback: %s", traceback.format_exc())
return False
def _dispatch_worker_start(self):
"""Slot — always runs on the main thread due to queued signal delivery."""
if self._pending_worker_start:
fn = self._pending_worker_start
self._pending_worker_start = None
fn()
def _show_tool_download_dialog(
self, parent, tool_events, cache_dir,
modlist_name, install_path, game_root, ttw_installer_path,
on_progress, on_complete, handle_feedback,
):
"""Show ManualDownloadDialog for VNV tools that need manual download."""
from jackify.backend.services.manual_download_manager import ManualDownloadManager
from jackify.frontends.gui.dialogs.manual_download_dialog import ManualDownloadDialog
from jackify.backend.handlers.config_handler import ConfigHandler
cfg_watch = ConfigHandler().get("manual_download_watch_directory", None)
watch_dir = None
if cfg_watch:
p = Path(str(cfg_watch)).expanduser()
if p.is_dir():
watch_dir = p
if watch_dir is None:
import os
xdg = os.environ.get('XDG_DOWNLOAD_DIR', '')
xdg_path = Path(xdg).expanduser() if xdg else None
watch_dir = xdg_path if (xdg_path and xdg_path.is_dir()) else Path.home() / 'Downloads'
def _on_all_done(_completed, _skipped):
# _check_all_done() runs in the watcher background thread (Python
# threading.Thread — no Qt event loop). QTimer.singleShot is
# unreliable from non-Qt threads. Instead, emit a signal: because
# VNVAutomationController was created on the main thread, Qt uses a
# queued connection automatically and delivers the slot on the main thread.
self._pending_worker_start = lambda: self._finish_manual_download_flow(
state,
parent,
modlist_name,
install_path,
game_root,
ttw_installer_path,
on_progress,
on_complete,
handle_feedback,
)
self._worker_start_requested.emit()
state = {"done": False}
manager = ManualDownloadManager(
modlist_download_dir=cache_dir,
watch_directory=watch_dir,
concurrent_limit=2,
on_all_done=_on_all_done,
)
self._manual_manager = manager
manager.load_items(tool_events, loop_iteration=1)
dialog = ManualDownloadDialog(
manager=manager,
modlist_name="VNV Post-Install Tools",
watch_directory=watch_dir,
concurrent_limit=2,
parent=parent,
)
self._manual_dialog = dialog
dialog.load_items(manager.items)
dialog.finished.connect(lambda _result: self._cancel_manual_download_flow(on_complete, state))
dialog.show()
def _cancel_manual_download_flow(self, on_complete, state: dict) -> None:
if state["done"]:
return
state["done"] = True
self._stop_manual_download_flow()
on_complete(False, "")
def _finish_manual_download_flow(
self,
state: dict,
parent,
modlist_name,
install_path,
game_root,
ttw_installer_path,
on_progress,
on_complete,
handle_feedback,
) -> None:
if state["done"]:
return
state["done"] = True
self._stop_manual_download_flow()
self._start_worker(
parent,
modlist_name,
install_path,
game_root,
ttw_installer_path,
on_progress,
on_complete,
handle_feedback,
)
def _stop_manual_download_flow(self) -> None:
dialog = self._manual_dialog
manager = self._manual_manager
self._manual_dialog = None
self._manual_manager = None
if dialog is not None:
try:
dialog.finished.disconnect()
except Exception:
pass
try:
dialog.close()
except Exception:
pass
if manager is not None:
try:
manager.stop()
except Exception:
pass
def _start_worker(
self, parent, modlist_name, install_path, game_root,
ttw_installer_path, on_progress, on_complete, handle_feedback,
):
"""Create and start VNV worker thread.
Signals are connected to @Slot methods on this QObject (main thread).
Because VNVAutomationController lives on the main thread, Qt automatically
uses queued connections for signals emitted from the worker thread,
guaranteeing that _on_worker_progress and _on_worker_done execute on
the main thread regardless of which thread the worker emits from.
"""
self._on_progress_cb = on_progress
self._on_complete_cb = on_complete
self._handle_feedback_cb = handle_feedback
self._worker = _VNVWorker(
modlist_name, install_path, game_root, ttw_installer_path,
)
self._worker.progress_update.connect(self._on_worker_progress)
self._worker.completed.connect(self._on_worker_done)
self._worker.finished.connect(self._worker.deleteLater)
self._worker.start()
@Slot(str)
def _on_worker_progress(self, message: str):
if self._on_progress_cb:
self._on_progress_cb(message)
if self._handle_feedback_cb:
self._handle_feedback_cb(message)
@Slot(bool, str)
def _on_worker_done(self, success: bool, error: str):
self._worker = None
cb = self._on_complete_cb
self._on_complete_cb = None
self._on_progress_cb = None
self._handle_feedback_cb = None
if cb:
cb(success, error)
def cleanup(self):
"""Stop worker if running. Call from screen cleanup/hideEvent."""
self._on_complete_cb = None
self._on_progress_cb = None
self._handle_feedback_cb = None
self._pending_worker_start = None
self._stop_manual_download_flow()
if self._worker and self._worker.isRunning():
self._worker.terminate()
self._worker.wait(2000)
self._worker = None

View File

@@ -342,7 +342,9 @@ class FileProgressList(QWidget):
self._cpu_timer.stop()
if self._cpu_worker and self._cpu_worker.isRunning():
self._cpu_worker.quit()
self._cpu_worker.wait(500)
if not self._cpu_worker.wait(500):
self._cpu_worker.terminate()
self._cpu_worker.wait(1000)
self._cpu_worker = None
def _start_cpu_worker(self):