mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 03:13:44 +02:00
Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support
This commit is contained in:
@@ -10,6 +10,10 @@ from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1].startswith('nxm://'):
|
||||
handle_nxm_url(sys.argv[1])
|
||||
return
|
||||
|
||||
# Check if launched with jackify:// protocol URL (OAuth callback)
|
||||
if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'):
|
||||
handle_protocol_url(sys.argv[1])
|
||||
@@ -20,6 +24,42 @@ def main():
|
||||
gui_main()
|
||||
|
||||
|
||||
def handle_nxm_url(url: str) -> None:
|
||||
"""Handle an nxm:// launch: hand off to running instance or open the app."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
from jackify.backend.services.nxm_ipc import send_to_running_instance
|
||||
if send_to_running_instance(url):
|
||||
return
|
||||
|
||||
# No running instance - check for installed modlists before launching
|
||||
try:
|
||||
from jackify.tools.verify_install import discover_installed_modlists
|
||||
modlists = discover_installed_modlists()
|
||||
except Exception:
|
||||
modlists = []
|
||||
|
||||
if not modlists:
|
||||
from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error
|
||||
show_no_modlists_error()
|
||||
return
|
||||
|
||||
from jackify.backend.services.nxm_url import parse_nxm_url
|
||||
from jackify.backend.services import nxm_session
|
||||
from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog
|
||||
|
||||
nxm = parse_nxm_url(url)
|
||||
auto_start = nxm_session.detect_active_mo2_modlist(modlists)
|
||||
if auto_start is None:
|
||||
remembered = nxm_session.get_remembered_modlist()
|
||||
auto_start = next((m for m in modlists if m["name"] == remembered), None) if remembered else None
|
||||
|
||||
dlg = NxmDownloadDialog(nxm, modlists, auto_start_modlist=auto_start)
|
||||
dlg.show()
|
||||
app.exec()
|
||||
|
||||
|
||||
def handle_protocol_url(url: str):
|
||||
"""Handle jackify:// protocol URL (OAuth callback)."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
@@ -6,5 +6,6 @@ Custom dialogs for the Jackify GUI application.
|
||||
|
||||
from .completion_dialog import NextStepsDialog
|
||||
from .success_dialog import SuccessDialog
|
||||
from .verification_results_dialog import VerificationResultsDialog
|
||||
|
||||
__all__ = ['NextStepsDialog', 'SuccessDialog']
|
||||
__all__ = ['NextStepsDialog', 'SuccessDialog', 'VerificationResultsDialog']
|
||||
@@ -112,8 +112,8 @@ class NextStepsDialog(QDialog):
|
||||
content_text.setReadOnly(True)
|
||||
content_text.setStyleSheet(
|
||||
"QTextEdit { "
|
||||
" background-color: #f8f9fa; "
|
||||
" border: 1px solid #dee2e6; "
|
||||
" background-color: #2a2a2a; "
|
||||
" border: 1px solid #3a3a3a; "
|
||||
" border-radius: 6px; "
|
||||
" padding: 12px; "
|
||||
" font-family: 'Segoe UI', Arial, sans-serif; "
|
||||
@@ -139,7 +139,7 @@ class NextStepsDialog(QDialog):
|
||||
return_btn.clicked.connect(self.accept) # This will close dialog and return to menu
|
||||
return_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #3498db; "
|
||||
" background-color: #1a5fa8; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 4px; "
|
||||
@@ -147,10 +147,10 @@ class NextStepsDialog(QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #2980b9; "
|
||||
" background-color: #2470b0; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #21618c; "
|
||||
" background-color: #3fd0ea; "
|
||||
"}"
|
||||
)
|
||||
button_layout.addWidget(return_btn)
|
||||
@@ -163,7 +163,7 @@ class NextStepsDialog(QDialog):
|
||||
exit_btn.clicked.connect(self.reject) # This will close dialog and potentially exit app
|
||||
exit_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #95a5a6; "
|
||||
" background-color: #4a5568; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 4px; "
|
||||
@@ -171,10 +171,10 @@ class NextStepsDialog(QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #7f8c8d; "
|
||||
" background-color: #5a6578; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #6c7b7d; "
|
||||
" background-color: #3fd0ea; "
|
||||
"}"
|
||||
)
|
||||
button_layout.addWidget(exit_btn)
|
||||
@@ -194,15 +194,12 @@ class NextStepsDialog(QDialog):
|
||||
completion_title = "Modlist Configuration complete!" if is_existing else "Modlist Install and Configuration complete!"
|
||||
completion_log = "Configure_Existing_Modlist_workflow.log" if is_existing else "Configure_New_Modlist_workflow.log"
|
||||
|
||||
completion_text = f"""✓ Configuration completed successfully!
|
||||
completion_text = f"""Configuration completed successfully!
|
||||
|
||||
{completion_title}
|
||||
|
||||
• You should now be able to Launch '{self.modlist_name}' through Steam.
|
||||
• Congratulations and enjoy the game!
|
||||
|
||||
NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of
|
||||
Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).
|
||||
- You should now be able to Launch '{self.modlist_name}' through Steam.
|
||||
- Congratulations and enjoy the game!
|
||||
|
||||
Detailed log available at: {get_jackify_logs_dir()}/{completion_log}"""
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class ENBProtonDialog(QDialog):
|
||||
"QFrame#enbCard { "
|
||||
" background: #23272e; "
|
||||
" border-radius: 12px; "
|
||||
" border: 2px solid #e67e22; " # Orange border for warning
|
||||
" border: 2px solid #f0c040;" # Orange border for warning
|
||||
"}"
|
||||
)
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
|
||||
@@ -67,17 +67,26 @@ class ENBProtonDialog(QDialog):
|
||||
"QLabel { "
|
||||
" font-size: 24px; "
|
||||
" font-weight: 700; "
|
||||
" color: #e67e22; " # Orange warning color
|
||||
" color: #f0c040;" # Orange warning color
|
||||
" margin-bottom: 4px; "
|
||||
"}"
|
||||
)
|
||||
card_layout.addWidget(title_label)
|
||||
|
||||
# Main warning message
|
||||
warning_text = (
|
||||
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
|
||||
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
|
||||
)
|
||||
from jackify.backend.data.modlist_proton_requirements import get_proton_requirement
|
||||
_proton_req = get_proton_requirement(self.modlist_name)
|
||||
|
||||
if _proton_req:
|
||||
warning_text = (
|
||||
f"<span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span> "
|
||||
f"requires a specific Proton version for ENB compatibility:"
|
||||
)
|
||||
else:
|
||||
warning_text = (
|
||||
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
|
||||
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
|
||||
)
|
||||
|
||||
warning_label = QLabel(warning_text)
|
||||
warning_label.setAlignment(Qt.AlignCenter)
|
||||
warning_label.setWordWrap(True)
|
||||
@@ -93,17 +102,28 @@ class ENBProtonDialog(QDialog):
|
||||
warning_label.setTextFormat(Qt.RichText)
|
||||
card_layout.addWidget(warning_label)
|
||||
|
||||
# Proton version list (in order of recommendation)
|
||||
versions_text = (
|
||||
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
|
||||
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
|
||||
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
|
||||
"• <b style='color: #2ecc71;'>Proton-CachyOS</b><br/>"
|
||||
"• <b style='color: #3498db;'>GE-Proton 10-14</b> or <b style='color: #3498db;'>lower</b><br/>"
|
||||
"• <b style='color: #f39c12;'>Proton 9</b> from Valve"
|
||||
"</div>"
|
||||
"</div>"
|
||||
)
|
||||
if _proton_req:
|
||||
versions_text = (
|
||||
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
|
||||
f"<div style='font-size: 16px; color: #3fd0ea; font-weight: 700; margin-bottom: 8px;'>{_proton_req['required']}</div>"
|
||||
f"<div style='font-size: 13px; color: #b0b0b0;'>{_proton_req['note']}</div>"
|
||||
"</div>"
|
||||
)
|
||||
else:
|
||||
versions_text = (
|
||||
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
|
||||
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
|
||||
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
|
||||
"- <b style='color: #3fd0ea;'>Proton-CachyOS</b><br/>"
|
||||
"- <b style='color: #aaa;'>GE-Proton</b><br/>"
|
||||
"- <b style='color: #777;'>Proton 9</b> from Valve"
|
||||
"</div>"
|
||||
"<div style='font-size: 12px; color: #777; font-style: italic; margin-top: 10px;'>"
|
||||
"Valve Proton 10 has known ENB compatibility issues."
|
||||
"</div>"
|
||||
"</div>"
|
||||
)
|
||||
|
||||
versions_label = QLabel(versions_text)
|
||||
versions_label.setAlignment(Qt.AlignLeft)
|
||||
versions_label.setWordWrap(True)
|
||||
@@ -118,26 +138,6 @@ class ENBProtonDialog(QDialog):
|
||||
versions_label.setTextFormat(Qt.RichText)
|
||||
card_layout.addWidget(versions_label)
|
||||
|
||||
# Additional note
|
||||
note_text = (
|
||||
"<div style='font-size: 12px; color: #95a5a6; font-style: italic; margin-top: 8px;'>"
|
||||
"Note: Valve's Proton 10 has known ENB compatibility issues."
|
||||
"</div>"
|
||||
)
|
||||
note_label = QLabel(note_text)
|
||||
note_label.setAlignment(Qt.AlignCenter)
|
||||
note_label.setWordWrap(True)
|
||||
note_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 12px; "
|
||||
" color: #95a5a6; "
|
||||
" font-style: italic; "
|
||||
" margin-top: 8px; "
|
||||
"}"
|
||||
)
|
||||
note_label.setTextFormat(Qt.RichText)
|
||||
card_layout.addWidget(note_label)
|
||||
|
||||
layout.addStretch()
|
||||
layout.addWidget(card, alignment=Qt.AlignCenter)
|
||||
layout.addSpacing(20) # Add spacing between card and button
|
||||
|
||||
@@ -36,12 +36,12 @@ _STATUS_LABELS = {
|
||||
|
||||
_STATUS_COLOURS = {
|
||||
'pending': '#808080',
|
||||
'browser_opened': '#3498db',
|
||||
'validating': '#f39c12',
|
||||
'complete': '#27ae60',
|
||||
'deferred': '#e67e22',
|
||||
'skipped': '#e67e22',
|
||||
'error': '#e74c3c',
|
||||
'browser_opened': '#3fd0ea',
|
||||
'validating': '#f0c040',
|
||||
'complete': '#3fd0ea',
|
||||
'deferred': '#f0c040',
|
||||
'skipped': '#888',
|
||||
'error': '#e05050',
|
||||
}
|
||||
|
||||
# Column indices
|
||||
@@ -61,6 +61,15 @@ def _fmt_size(n: int) -> str:
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
class _SizeTableItem(QTableWidgetItem):
|
||||
"""QTableWidgetItem that sorts by raw byte count stored in UserRole."""
|
||||
def __lt__(self, other: 'QTableWidgetItem') -> bool:
|
||||
try:
|
||||
return int(self.data(Qt.UserRole) or 0) < int(other.data(Qt.UserRole) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return super().__lt__(other)
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Tiny bridge so worker-thread callbacks can update the Qt table safely."""
|
||||
item_updated = Signal(object) # DownloadItem
|
||||
@@ -152,15 +161,17 @@ class ManualDownloadDialog(QDialog):
|
||||
|
||||
# Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints
|
||||
if new_items:
|
||||
self._table.setSortingEnabled(False)
|
||||
self._table.setUpdatesEnabled(False)
|
||||
try:
|
||||
start_row = self._table.rowCount()
|
||||
self._table.setRowCount(start_row + len(new_items))
|
||||
for i, item in enumerate(new_items):
|
||||
self._fill_row(start_row + i, item)
|
||||
self._row_map[item.file_name] = start_row + i
|
||||
finally:
|
||||
self._table.setUpdatesEnabled(True)
|
||||
self._table.setSortingEnabled(True)
|
||||
self._rebuild_row_map()
|
||||
self._table.viewport().update()
|
||||
|
||||
self._refresh_header()
|
||||
@@ -222,7 +233,9 @@ class ManualDownloadDialog(QDialog):
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.setSortingEnabled(True)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.horizontalHeader().sectionClicked.connect(self._on_header_clicked)
|
||||
self._table.cellDoubleClicked.connect(self._on_row_double_clicked)
|
||||
self._table.setStyleSheet(
|
||||
"QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; "
|
||||
@@ -284,6 +297,14 @@ class ManualDownloadDialog(QDialog):
|
||||
self._open_selected_btn.clicked.connect(self._on_open_selected)
|
||||
btn_row.addWidget(self._open_selected_btn)
|
||||
|
||||
self._scan_now_btn = QPushButton("Scan Now")
|
||||
self._scan_now_btn.setToolTip(
|
||||
"Re-scan the watch directory for already-downloaded files.\n"
|
||||
"Use this if a file downloaded successfully but was not detected automatically."
|
||||
)
|
||||
self._scan_now_btn.clicked.connect(self._on_scan_now)
|
||||
btn_row.addWidget(self._scan_now_btn)
|
||||
|
||||
btn_row.addStretch()
|
||||
|
||||
self._start_pause_btn = QPushButton("Start")
|
||||
@@ -308,9 +329,13 @@ class ManualDownloadDialog(QDialog):
|
||||
def _fill_row(self, row: int, item: DownloadItem) -> None:
|
||||
"""Populate cells for a pre-allocated row (row must already exist in the table)."""
|
||||
from PySide6.QtGui import QColor
|
||||
self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name))
|
||||
mod_cell = QTableWidgetItem(item.mod_name)
|
||||
mod_cell.setData(Qt.UserRole, item.file_name) # stable ID for row lookups after sort
|
||||
self._table.setItem(row, _COL_MOD, mod_cell)
|
||||
self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name))
|
||||
self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size)))
|
||||
size_cell = _SizeTableItem(_fmt_size(item.expected_size))
|
||||
size_cell.setData(Qt.UserRole, item.expected_size)
|
||||
self._table.setItem(row, _COL_SIZE, size_cell)
|
||||
colour = _STATUS_COLOURS.get(item.status, '#808080')
|
||||
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
|
||||
status_cell.setForeground(QColor(colour))
|
||||
@@ -326,6 +351,26 @@ class ManualDownloadDialog(QDialog):
|
||||
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
|
||||
status_cell.setToolTip(item.error_message or "")
|
||||
|
||||
def _rebuild_row_map(self) -> None:
|
||||
self._row_map.clear()
|
||||
for row in range(self._table.rowCount()):
|
||||
cell = self._table.item(row, _COL_MOD)
|
||||
if cell:
|
||||
file_name = cell.data(Qt.UserRole)
|
||||
if file_name:
|
||||
self._row_map[file_name] = row
|
||||
|
||||
def _sync_pending_order_to_visual(self) -> None:
|
||||
visual_order: dict[str, int] = {}
|
||||
for row in range(self._table.rowCount()):
|
||||
cell = self._table.item(row, _COL_MOD)
|
||||
if cell:
|
||||
file_name = cell.data(Qt.UserRole)
|
||||
if file_name:
|
||||
visual_order[file_name] = row
|
||||
with self._manager._lock:
|
||||
self._manager._items.sort(key=lambda i: visual_order.get(i.file_name, 999999))
|
||||
|
||||
def _refresh_header(self) -> None:
|
||||
items = self._manager.items
|
||||
total = len(items)
|
||||
@@ -345,6 +390,10 @@ class ManualDownloadDialog(QDialog):
|
||||
# Slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_header_clicked(self, logical_index: int) -> None:
|
||||
self._rebuild_row_map()
|
||||
self._sync_pending_order_to_visual()
|
||||
|
||||
def _on_item_updated_slot(self, item: DownloadItem) -> None:
|
||||
row = self._row_map.get(item.file_name)
|
||||
if row is not None:
|
||||
@@ -369,7 +418,6 @@ class ManualDownloadDialog(QDialog):
|
||||
self._folder_label.setText(chosen)
|
||||
self._manager._watch_dir = self._watch_dir
|
||||
self._manager._watcher._config.watch_directory = self._watch_dir
|
||||
self._manager._watcher._known = {}
|
||||
try:
|
||||
cfg = ConfigHandler()
|
||||
cfg.set("manual_download_watch_directory", str(self._watch_dir))
|
||||
@@ -430,6 +478,16 @@ class ManualDownloadDialog(QDialog):
|
||||
return
|
||||
self._manager.reopen_item(file_name)
|
||||
|
||||
def _on_scan_now(self) -> None:
|
||||
self._scan_now_btn.setEnabled(False)
|
||||
self._scan_now_btn.setText("Scanning...")
|
||||
self._manager.force_rescan()
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(2000, lambda: (
|
||||
self._scan_now_btn.setEnabled(True),
|
||||
self._scan_now_btn.setText("Scan Now"),
|
||||
))
|
||||
|
||||
def _on_row_double_clicked(self, row: int, _column: int) -> None:
|
||||
file_item = self._table.item(row, _COL_NAME)
|
||||
if file_item is None:
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""NXM download dialog: modlist picker and download runner."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Tuple
|
||||
|
||||
from PySide6.QtCore import QThread, Signal, Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QCheckBox,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QFrame,
|
||||
)
|
||||
|
||||
from jackify.backend.services.nxm_url import NxmUrl
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
import jackify.backend.services.nxm_session as nxm_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BG_DARK = "#16191d"
|
||||
_BG_CARD = "#1e2228"
|
||||
_BG_LIST = "#1a1d23"
|
||||
_BORDER = "#333"
|
||||
_TEXT = "#d0d0d0"
|
||||
_TEXT_DIM = "#8f98a3"
|
||||
_TEXT_BRIGHT = "#e0e0e0"
|
||||
_ERROR = "#cc4444"
|
||||
|
||||
|
||||
class _ModNameFetchThread(QThread):
|
||||
name_ready = Signal(str)
|
||||
|
||||
def __init__(self, nxm: NxmUrl, parent=None):
|
||||
super().__init__(parent)
|
||||
self.nxm = nxm
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
svc = NexusAuthService()
|
||||
token = svc.get_auth_token()
|
||||
method = svc.get_auth_method() or "api_key"
|
||||
except Exception:
|
||||
try:
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
token = APIKeyService().get_saved_api_key()
|
||||
method = "api_key"
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not token:
|
||||
return
|
||||
|
||||
try:
|
||||
import requests
|
||||
if method == "oauth":
|
||||
headers = {"Authorization": f"Bearer {token}", "User-Agent": "jackify"}
|
||||
else:
|
||||
headers = {"apikey": token, "User-Agent": "jackify"}
|
||||
url = (
|
||||
f"https://api.nexusmods.com/v1/games/{self.nxm.game}"
|
||||
f"/mods/{self.nxm.mod_id}.json"
|
||||
)
|
||||
resp = requests.get(url, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
name = resp.json().get("name", "")
|
||||
if name:
|
||||
self.name_ready.emit(name)
|
||||
except Exception as e:
|
||||
logger.debug("Mod name fetch failed: %s", e)
|
||||
|
||||
|
||||
class _DownloadThread(QThread):
|
||||
progress = Signal(int, int)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nxm: NxmUrl,
|
||||
download_dir: Path,
|
||||
auth_token: str,
|
||||
auth_method: str,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.nxm = nxm
|
||||
self.download_dir = download_dir
|
||||
self.auth_token = auth_token
|
||||
self.auth_method = auth_method
|
||||
|
||||
def run(self) -> None:
|
||||
from jackify.backend.services.nxm_downloader import (
|
||||
get_nxm_download_url,
|
||||
download_nxm_file,
|
||||
filename_from_cdn_url,
|
||||
)
|
||||
|
||||
cdn_url = get_nxm_download_url(self.nxm, self.auth_token, self.auth_method)
|
||||
if not cdn_url:
|
||||
self.finished.emit(False, "Could not resolve download URL from Nexus API.")
|
||||
return
|
||||
|
||||
filename = filename_from_cdn_url(
|
||||
cdn_url, f"mod_{self.nxm.mod_id}_file_{self.nxm.file_id}.zip"
|
||||
)
|
||||
ok, msg = download_nxm_file(cdn_url, self.download_dir, filename, self._on_progress)
|
||||
self.finished.emit(ok, msg)
|
||||
|
||||
def _on_progress(self, downloaded: int, total: int) -> None:
|
||||
self.progress.emit(downloaded, total)
|
||||
|
||||
|
||||
class NxmDownloadDialog(QDialog):
|
||||
"""Modlist picker and download runner for incoming nxm:// links.
|
||||
|
||||
When auto_start_modlist is provided the picker is hidden and the download
|
||||
begins immediately - used when session memory has a remembered modlist.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nxm: NxmUrl,
|
||||
modlists: List[Dict],
|
||||
parent=None,
|
||||
auto_start_modlist: Optional[Dict] = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.nxm = nxm
|
||||
self.modlists = modlists
|
||||
self._thread: Optional[_DownloadThread] = None
|
||||
self._name_thread: Optional[_ModNameFetchThread] = None
|
||||
self._auto_start_modlist = auto_start_modlist
|
||||
|
||||
self.setWindowTitle("NXM Download")
|
||||
self.setMinimumWidth(520)
|
||||
self.setModal(False)
|
||||
self.setStyleSheet(f"QDialog {{ background: {_BG_DARK}; color: {_TEXT}; }}")
|
||||
self._build_ui(show_picker=auto_start_modlist is None)
|
||||
if auto_start_modlist is None:
|
||||
self._apply_session_memory()
|
||||
self._fetch_mod_name()
|
||||
|
||||
# --- UI construction ---
|
||||
|
||||
def _build_ui(self, show_picker: bool = True) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
layout.addWidget(self._make_header())
|
||||
|
||||
if show_picker:
|
||||
layout.addWidget(self._make_section_label("Select modlist:"))
|
||||
layout.addWidget(self._make_modlist_list())
|
||||
layout.addWidget(self._make_dest_label())
|
||||
layout.addWidget(self._make_session_row())
|
||||
else:
|
||||
name = self._auto_start_modlist.get("name", "")
|
||||
lbl = QLabel(f"Downloading to: <span style='color:{JACKIFY_COLOR_BLUE}'>{name}</span>")
|
||||
lbl.setTextFormat(Qt.RichText)
|
||||
lbl.setStyleSheet(f"color: {_TEXT};")
|
||||
layout.addWidget(lbl)
|
||||
self._clear_session_btn = QPushButton("Change modlist (clear session)")
|
||||
self._clear_session_btn.setFlat(True)
|
||||
self._clear_session_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
|
||||
self._clear_session_btn.clicked.connect(self._on_clear_and_reopen)
|
||||
layout.addWidget(self._clear_session_btn)
|
||||
|
||||
layout.addWidget(self._make_progress_section())
|
||||
layout.addWidget(self._make_separator())
|
||||
layout.addLayout(self._make_button_row())
|
||||
|
||||
def _make_header(self) -> QFrame:
|
||||
card = QFrame()
|
||||
card.setStyleSheet(
|
||||
f"QFrame {{ background: {_BG_CARD}; border-radius: 6px; border: 1px solid {_BORDER}; }}"
|
||||
)
|
||||
vbox = QVBoxLayout(card)
|
||||
vbox.setContentsMargins(12, 10, 12, 10)
|
||||
vbox.setSpacing(4)
|
||||
|
||||
self._title_label = QLabel(f"<b>NXM Download: Mod {self.nxm.mod_id}</b>")
|
||||
self._title_label.setStyleSheet(f"color: {_TEXT_BRIGHT}; font-size: 13px; border: none;")
|
||||
self._title_label.setTextFormat(Qt.RichText)
|
||||
vbox.addWidget(self._title_label)
|
||||
|
||||
detail = QLabel(
|
||||
f"Game: <span style='color:{JACKIFY_COLOR_BLUE}'>{self.nxm.game}</span>"
|
||||
f" | Mod ID: {self.nxm.mod_id}"
|
||||
f" | File ID: {self.nxm.file_id}"
|
||||
)
|
||||
detail.setTextFormat(Qt.RichText)
|
||||
detail.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px; border: none;")
|
||||
vbox.addWidget(detail)
|
||||
return card
|
||||
|
||||
def _make_section_label(self, text: str) -> QLabel:
|
||||
lbl = QLabel(text)
|
||||
lbl.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
|
||||
return lbl
|
||||
|
||||
def _make_modlist_list(self) -> QListWidget:
|
||||
self._list = QListWidget()
|
||||
self._list.setMaximumHeight(150)
|
||||
self._list.setStyleSheet(
|
||||
f"QListWidget {{ background: {_BG_LIST}; color: {_TEXT}; "
|
||||
f"border: 1px solid {_BORDER}; border-radius: 4px; }}"
|
||||
f"QListWidget::item:selected {{ background: #2a3a4a; color: {_TEXT_BRIGHT}; }}"
|
||||
f"QListWidget::item:hover {{ background: #222830; }}"
|
||||
)
|
||||
for ml in self.modlists:
|
||||
item = QListWidgetItem(ml["name"])
|
||||
item.setData(Qt.UserRole, ml)
|
||||
self._list.addItem(item)
|
||||
self._list.currentItemChanged.connect(self._on_selection_changed)
|
||||
return self._list
|
||||
|
||||
def _make_dest_label(self) -> QLabel:
|
||||
self._dest_label = QLabel("Download directory: (none)")
|
||||
self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
|
||||
self._dest_label.setWordWrap(True)
|
||||
return self._dest_label
|
||||
|
||||
def _make_session_row(self) -> QFrame:
|
||||
container = QFrame()
|
||||
container.setStyleSheet("QFrame { border: none; }")
|
||||
row = QHBoxLayout(container)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._remember_cb = QCheckBox("Remember for this session")
|
||||
self._remember_cb.setStyleSheet(f"color: {_TEXT};")
|
||||
row.addWidget(self._remember_cb)
|
||||
row.addStretch()
|
||||
|
||||
self._clear_btn = QPushButton("Clear remembered modlist")
|
||||
self._clear_btn.setFlat(True)
|
||||
self._clear_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
|
||||
self._clear_btn.clicked.connect(self._on_clear_session)
|
||||
row.addWidget(self._clear_btn)
|
||||
|
||||
self._update_clear_button()
|
||||
return container
|
||||
|
||||
def _make_progress_section(self) -> QFrame:
|
||||
container = QFrame()
|
||||
container.setStyleSheet("QFrame { border: none; }")
|
||||
vbox = QVBoxLayout(container)
|
||||
vbox.setContentsMargins(0, 0, 0, 0)
|
||||
vbox.setSpacing(4)
|
||||
|
||||
self._status_label = QLabel("")
|
||||
self._status_label.setStyleSheet(f"color: {_TEXT_DIM};")
|
||||
self._status_label.setVisible(False)
|
||||
self._status_label.setWordWrap(True)
|
||||
self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
vbox.addWidget(self._status_label)
|
||||
|
||||
self._progress_bar = QProgressBar()
|
||||
self._progress_bar.setStyleSheet(
|
||||
f"QProgressBar {{ border: 1px solid {_BORDER}; border-radius: 4px; "
|
||||
f"background: #2c2c2c; height: 10px; color: transparent; }}"
|
||||
f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}"
|
||||
)
|
||||
self._progress_bar.setVisible(False)
|
||||
vbox.addWidget(self._progress_bar)
|
||||
return container
|
||||
|
||||
def _make_button_row(self) -> QHBoxLayout:
|
||||
row = QHBoxLayout()
|
||||
row.addStretch()
|
||||
|
||||
self._cancel_btn = QPushButton("Cancel")
|
||||
self._cancel_btn.setStyleSheet(
|
||||
f"QPushButton {{ background: #3a2020; color: {_TEXT}; border: 1px solid {_BORDER}; "
|
||||
f"border-radius: 4px; padding: 5px 14px; }}"
|
||||
f"QPushButton:hover {{ background: #5a2828; }}"
|
||||
)
|
||||
self._cancel_btn.clicked.connect(self.reject)
|
||||
row.addWidget(self._cancel_btn)
|
||||
|
||||
self._download_btn = QPushButton("Download")
|
||||
self._download_btn.setEnabled(False)
|
||||
self._download_btn.setDefault(True)
|
||||
self._download_btn.setStyleSheet(
|
||||
f"QPushButton {{ background: {JACKIFY_COLOR_BLUE}; color: #000; font-weight: 600; "
|
||||
f"border: none; border-radius: 4px; padding: 5px 18px; }}"
|
||||
f"QPushButton:hover {{ background: #5ae0f5; }}"
|
||||
f"QPushButton:disabled {{ background: #2a4a52; color: #555; }}"
|
||||
)
|
||||
self._download_btn.clicked.connect(self._on_download)
|
||||
row.addWidget(self._download_btn)
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _make_separator() -> QFrame:
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.HLine)
|
||||
sep.setStyleSheet(f"color: {_BORDER};")
|
||||
return sep
|
||||
|
||||
# --- Session memory ---
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
super().showEvent(event)
|
||||
if self._auto_start_modlist is not None:
|
||||
QTimer.singleShot(0, self._start_auto_download)
|
||||
|
||||
def _start_auto_download(self) -> None:
|
||||
self._start_download_for(self._auto_start_modlist)
|
||||
|
||||
def _on_clear_and_reopen(self) -> None:
|
||||
nxm_session.clear_remembered_modlist()
|
||||
self.reject()
|
||||
dlg = NxmDownloadDialog(self.nxm, self.modlists, parent=self.parent())
|
||||
dlg.show()
|
||||
|
||||
def _fetch_mod_name(self) -> None:
|
||||
self._name_thread = _ModNameFetchThread(self.nxm, self)
|
||||
self._name_thread.name_ready.connect(self._on_mod_name_ready)
|
||||
self._name_thread.start()
|
||||
|
||||
def _on_mod_name_ready(self, name: str) -> None:
|
||||
self._title_label.setText(f"<b>NXM Download: {name}</b>")
|
||||
self._name_thread = None
|
||||
|
||||
def _apply_session_memory(self) -> None:
|
||||
remembered = nxm_session.get_remembered_modlist()
|
||||
if not remembered:
|
||||
return
|
||||
for i in range(self._list.count()):
|
||||
if self._list.item(i).text() == remembered:
|
||||
self._list.setCurrentRow(i)
|
||||
self._remember_cb.setChecked(True)
|
||||
break
|
||||
|
||||
def _on_clear_session(self) -> None:
|
||||
nxm_session.clear_remembered_modlist()
|
||||
self._remember_cb.setChecked(False)
|
||||
self._update_clear_button()
|
||||
|
||||
def _update_clear_button(self) -> None:
|
||||
self._clear_btn.setVisible(bool(nxm_session.get_remembered_modlist()))
|
||||
|
||||
def _on_selection_changed(self) -> None:
|
||||
item = self._list.currentItem()
|
||||
self._download_btn.setEnabled(item is not None)
|
||||
if item:
|
||||
modlist = item.data(Qt.UserRole)
|
||||
self._update_dest_label(modlist)
|
||||
|
||||
def _update_dest_label(self, modlist: Dict) -> None:
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
modlist_dir = Path(modlist.get("modlist_dir", ""))
|
||||
download_dir = resolve_mo2_download_dir(modlist_dir)
|
||||
if download_dir:
|
||||
self._dest_label.setText(f"Download directory: {download_dir}")
|
||||
self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
|
||||
else:
|
||||
self._dest_label.setText("Download directory: not configured - run Configure first")
|
||||
self._dest_label.setStyleSheet(f"color: {_ERROR}; font-size: 11px;")
|
||||
|
||||
# --- Download ---
|
||||
|
||||
def _on_download(self) -> None:
|
||||
item = self._list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
modlist = item.data(Qt.UserRole)
|
||||
if self._remember_cb.isChecked():
|
||||
nxm_session.set_remembered_modlist(modlist["name"])
|
||||
self._update_clear_button()
|
||||
self._start_download_for(modlist)
|
||||
|
||||
def _start_download_for(self, modlist: Dict) -> None:
|
||||
modlist_dir = Path(modlist.get("modlist_dir", ""))
|
||||
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
download_dir = resolve_mo2_download_dir(modlist_dir)
|
||||
|
||||
if not download_dir or not download_dir.exists():
|
||||
self._set_status(
|
||||
f"Download directory not found for {modlist['name']}. "
|
||||
"Run Configure for this modlist first.",
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
token, method = self._get_auth()
|
||||
if not token:
|
||||
self._set_status(
|
||||
"Not logged in to Nexus. Please log in via Settings > Nexus Authentication.",
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
self._set_downloading(True)
|
||||
self._thread = _DownloadThread(self.nxm, download_dir, token, method, self)
|
||||
self._thread.progress.connect(self._on_progress)
|
||||
self._thread.finished.connect(self._on_download_finished)
|
||||
self._thread.start()
|
||||
|
||||
def _get_auth(self) -> Tuple[Optional[str], str]:
|
||||
"""Return (token, auth_method). auth_method is 'oauth' or 'api_key'."""
|
||||
try:
|
||||
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
||||
svc = NexusAuthService()
|
||||
token = svc.get_auth_token()
|
||||
method = svc.get_auth_method() or "api_key"
|
||||
if token:
|
||||
return token, method
|
||||
except Exception as e:
|
||||
logger.warning("NexusAuthService unavailable: %s", e)
|
||||
try:
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
key = APIKeyService().get_saved_api_key()
|
||||
if key:
|
||||
return key, "api_key"
|
||||
except Exception as e:
|
||||
logger.warning("APIKeyService unavailable: %s", e)
|
||||
return None, "api_key"
|
||||
|
||||
def _on_progress(self, downloaded: int, total: int) -> None:
|
||||
if total > 0:
|
||||
self._progress_bar.setValue(int(downloaded * 100 / total))
|
||||
|
||||
def _on_download_finished(self, success: bool, message: str) -> None:
|
||||
self._set_downloading(False)
|
||||
self._thread = None
|
||||
if success:
|
||||
self._set_status("Download complete. MO2 will pick it up automatically.", error=False)
|
||||
QTimer.singleShot(3000, self.accept)
|
||||
else:
|
||||
self._set_status(f"Download failed: {message}", error=True)
|
||||
|
||||
def _set_downloading(self, active: bool) -> None:
|
||||
self._download_btn.setEnabled(not active)
|
||||
self._cancel_btn.setEnabled(not active)
|
||||
if hasattr(self, "_list"):
|
||||
self._list.setEnabled(not active)
|
||||
self._progress_bar.setVisible(active)
|
||||
self._progress_bar.setValue(0)
|
||||
if active:
|
||||
self._set_status("Downloading...", error=False)
|
||||
|
||||
def _set_status(self, text: str, error: bool = False) -> None:
|
||||
self._status_label.setText(text)
|
||||
self._status_label.setVisible(bool(text))
|
||||
colour = _ERROR if error else JACKIFY_COLOR_BLUE
|
||||
self._status_label.setStyleSheet(f"color: {colour};")
|
||||
|
||||
|
||||
def show_no_modlists_error() -> None:
|
||||
"""Show a standalone error when no modlists are found."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
msg = QMessageBox()
|
||||
msg.setWindowTitle("Jackify - NXM Handler")
|
||||
msg.setIcon(QMessageBox.Warning)
|
||||
msg.setText(
|
||||
"No installed modlists found.\n\n"
|
||||
"NXM download handling requires at least one modlist that has been "
|
||||
"installed and configured with Jackify."
|
||||
)
|
||||
msg.exec()
|
||||
@@ -62,7 +62,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
"QFrame#protontricksCard { "
|
||||
" background: #2d2323; "
|
||||
" border-radius: 12px; "
|
||||
" border: 2px solid #e74c3c; "
|
||||
" border: 2px solid #8b2020; "
|
||||
"}"
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
"QLabel { "
|
||||
" font-size: 36px; "
|
||||
" font-weight: bold; "
|
||||
" color: #e74c3c; "
|
||||
" color: #f0c040; "
|
||||
" margin-bottom: 4px; "
|
||||
"}"
|
||||
)
|
||||
@@ -87,7 +87,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
"QLabel { "
|
||||
" font-size: 20px; "
|
||||
" font-weight: 600; "
|
||||
" color: #e74c3c; "
|
||||
" color: #f0c040; "
|
||||
" margin-bottom: 2px; "
|
||||
"}"
|
||||
)
|
||||
@@ -126,7 +126,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
" text-align: center; "
|
||||
"} "
|
||||
"QProgressBar::chunk { "
|
||||
" background-color: #4fc3f7; "
|
||||
" background-color: #3fd0ea; "
|
||||
" border-radius: 3px; "
|
||||
"}"
|
||||
)
|
||||
@@ -139,7 +139,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
self.status_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 14px; "
|
||||
" color: #4fc3f7; "
|
||||
" color: #3fd0ea; "
|
||||
" margin: 8px 0; "
|
||||
"}"
|
||||
)
|
||||
@@ -155,7 +155,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
self.flatpak_btn.clicked.connect(self._install_flatpak)
|
||||
self.flatpak_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #4fc3f7; "
|
||||
" background-color: #1a5fa8; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 6px; "
|
||||
@@ -164,10 +164,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #3498db; "
|
||||
" background-color: #2470b0; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #2980b9; "
|
||||
" background-color: #3fd0ea; "
|
||||
"} "
|
||||
"QPushButton:disabled { "
|
||||
" background-color: #555; "
|
||||
@@ -182,7 +182,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
self.native_btn.clicked.connect(self._show_native_guidance)
|
||||
self.native_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #95a5a6; "
|
||||
" background-color: #4a5568; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 6px; "
|
||||
@@ -191,10 +191,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #7f8c8d; "
|
||||
" background-color: #5a6578; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #6c7b7d; "
|
||||
" background-color: #3fd0ea; "
|
||||
"}"
|
||||
)
|
||||
button_layout.addWidget(self.native_btn)
|
||||
@@ -211,7 +211,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
self.redetect_btn.clicked.connect(self._redetect)
|
||||
self.redetect_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #27ae60; "
|
||||
" background-color: #4a5568; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 4px; "
|
||||
@@ -219,10 +219,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #229954; "
|
||||
" background-color: #5a6578; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #1e8449; "
|
||||
" background-color: #3fd0ea; "
|
||||
"}"
|
||||
)
|
||||
bottom_layout.addWidget(self.redetect_btn)
|
||||
@@ -235,7 +235,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
exit_btn.clicked.connect(self._exit_app)
|
||||
exit_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #e74c3c; "
|
||||
" background-color: #8b2020; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 4px; "
|
||||
@@ -243,10 +243,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #c0392b; "
|
||||
" background-color: #a02828; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #a93226; "
|
||||
" background-color: #7a1a1a; "
|
||||
"}"
|
||||
)
|
||||
bottom_layout.addWidget(exit_btn)
|
||||
@@ -287,12 +287,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
|
||||
if success:
|
||||
self.status_label.setText("✓ Installation successful!")
|
||||
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
|
||||
self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }")
|
||||
# Auto-redetect after successful installation
|
||||
self._redetect()
|
||||
else:
|
||||
self.status_label.setText(f"✗ Installation failed: {message}")
|
||||
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
|
||||
self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }")
|
||||
|
||||
def _show_native_guidance(self):
|
||||
"""Show native installation guidance"""
|
||||
@@ -307,12 +307,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
|
||||
|
||||
if is_installed:
|
||||
self.status_label.setText("✓ Protontricks found!")
|
||||
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
|
||||
self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }")
|
||||
self.status_label.setVisible(True)
|
||||
self.accept() # Close dialog successfully
|
||||
else:
|
||||
self.status_label.setText("✗ Protontricks still not found")
|
||||
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
|
||||
self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }")
|
||||
self.status_label.setVisible(True)
|
||||
|
||||
def _exit_app(self):
|
||||
|
||||
@@ -86,7 +86,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
self.api_show_btn.setText("\U0001F441")
|
||||
if checked:
|
||||
self.api_key_edit.setEchoMode(QLineEdit.Normal)
|
||||
self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }")
|
||||
self.api_show_btn.setStyleSheet("QToolButton { color: #3fd0ea; }")
|
||||
else:
|
||||
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
||||
self.api_show_btn.setStyleSheet("")
|
||||
@@ -122,8 +122,8 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
self.config_handler.clear_api_key()
|
||||
MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low")
|
||||
|
||||
def _on_api_key_changed(self, text):
|
||||
api_key = text.strip()
|
||||
def _on_api_key_changed(self):
|
||||
api_key = self.api_key_edit.text().strip()
|
||||
self.config_handler.save_api_key(api_key)
|
||||
|
||||
|
||||
@@ -314,6 +314,9 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
|
||||
# Save auto tool compat preference
|
||||
self.config_handler.set('auto_tool_compat', self.auto_tool_compat_checkbox.isChecked())
|
||||
self.config_handler.set('force_github_updates', self.force_github_updates_checkbox.isChecked())
|
||||
if getattr(self, 'clf3_default_checkbox', None):
|
||||
from jackify.backend.services.tool_registry import set_active_engine_id
|
||||
set_active_engine_id("clf3" if self.clf3_default_checkbox.isChecked() else "jackify-engine")
|
||||
|
||||
# Save component installation method preference
|
||||
if self.winetricks_radio.isChecked():
|
||||
|
||||
@@ -153,7 +153,7 @@ class SettingsDialogTabsMixin:
|
||||
api_key = self.config_handler.get_api_key()
|
||||
self.api_key_edit.setText(api_key if api_key else "")
|
||||
self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)")
|
||||
self.api_key_edit.textChanged.connect(self._on_api_key_changed)
|
||||
self.api_key_edit.editingFinished.connect(self._on_api_key_changed)
|
||||
self.api_show_btn = QToolButton()
|
||||
self.api_show_btn.setCheckable(True)
|
||||
self.api_show_btn.setIcon(QIcon.fromTheme("view-visible"))
|
||||
@@ -297,6 +297,20 @@ class SettingsDialogTabsMixin:
|
||||
self.force_github_updates_checkbox.setStyleSheet("color: #fff;")
|
||||
component_layout.addWidget(self.force_github_updates_checkbox)
|
||||
|
||||
self.clf3_default_checkbox = QCheckBox("Use CLF3 as default install engine")
|
||||
self.clf3_default_checkbox.setToolTip(
|
||||
"Use CLF3 (SulfurNitride) as the default engine for all installs. "
|
||||
"CLF3 will be downloaded automatically if not already installed. "
|
||||
"You can still override this per-install on the Install screen."
|
||||
)
|
||||
self.clf3_default_checkbox.setStyleSheet("color: #fff;")
|
||||
try:
|
||||
from jackify.backend.services.tool_registry import get_active_engine_id
|
||||
self.clf3_default_checkbox.setChecked(get_active_engine_id() == "clf3")
|
||||
except Exception:
|
||||
pass
|
||||
component_layout.addWidget(self.clf3_default_checkbox)
|
||||
|
||||
advanced_layout.addWidget(component_group)
|
||||
advanced_layout.addStretch()
|
||||
self.tab_widget.addTab(advanced_tab, "Advanced")
|
||||
|
||||
@@ -31,22 +31,32 @@ class SuccessDialog(QDialog):
|
||||
- Return and Exit buttons
|
||||
"""
|
||||
|
||||
def __init__(self, modlist_name: str, workflow_type: str, time_taken: str, game_name: str = None, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
modlist_name: str,
|
||||
workflow_type: str,
|
||||
time_taken: str,
|
||||
game_name: str = None,
|
||||
verification_results=None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.modlist_name = modlist_name
|
||||
self.workflow_type = workflow_type
|
||||
self.time_taken = time_taken
|
||||
self.game_name = game_name
|
||||
self.setWindowTitle("Success!")
|
||||
self.verification_results = verification_results
|
||||
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
|
||||
self.setWindowModality(Qt.NonModal)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self.setFixedSize(500, 500)
|
||||
self.setFixedWidth(500)
|
||||
self.setMinimumHeight(400)
|
||||
self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True)
|
||||
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(30, 20, 30, 20) # Reduced top/bottom margins to prevent truncation
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# --- Card background for content ---
|
||||
card = QFrame(self)
|
||||
@@ -69,22 +79,36 @@ class SuccessDialog(QDialog):
|
||||
)
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
|
||||
|
||||
# Success title (less saturated green)
|
||||
title_label = QLabel("Success!")
|
||||
has_verify_failures = bool(
|
||||
self.verification_results and self.verification_results.failures
|
||||
)
|
||||
|
||||
title_text = "Complete" if has_verify_failures else "Success!"
|
||||
title_color = "#f0c040" if has_verify_failures else "#3fd0ea"
|
||||
title_label = QLabel(title_text)
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
title_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 22px; "
|
||||
" font-weight: 600; "
|
||||
" color: #2ecc71; "
|
||||
" margin-bottom: 2px; "
|
||||
"}"
|
||||
f"QLabel {{ "
|
||||
f" font-size: 22px; "
|
||||
f" font-weight: 600; "
|
||||
f" color: {title_color}; "
|
||||
f" margin-bottom: 2px; "
|
||||
f"}}"
|
||||
)
|
||||
card_layout.addWidget(title_label)
|
||||
|
||||
# Personalized success message (modlist name in Jackify Blue, but less bold)
|
||||
# Personalized message (modlist name in Jackify Blue, but less bold)
|
||||
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
|
||||
if self.workflow_type == "install":
|
||||
if has_verify_failures:
|
||||
suffix_map = {
|
||||
"install": "installed with issues - review verification results.",
|
||||
"update": "updated with issues - review verification results.",
|
||||
"configure_new": "configured with issues - review verification results.",
|
||||
"configure_existing": "configuration updated with issues - review verification results.",
|
||||
"tool_config": "tool compatibility configured with issues - review verification results.",
|
||||
}
|
||||
suffix_text = suffix_map.get(self.workflow_type, "completed with issues - review verification results.")
|
||||
elif self.workflow_type == "install":
|
||||
suffix_text = "installed successfully!"
|
||||
elif self.workflow_type == "update":
|
||||
suffix_text = "updated successfully!"
|
||||
@@ -95,7 +119,6 @@ class SuccessDialog(QDialog):
|
||||
elif self.workflow_type == "tool_config":
|
||||
suffix_text = "tool compatibility configured successfully!"
|
||||
else:
|
||||
# Fallback for other workflow types
|
||||
message_text = self._build_success_message()
|
||||
suffix_text = message_text.replace(self.modlist_name, "").strip()
|
||||
|
||||
@@ -153,12 +176,16 @@ class SuccessDialog(QDialog):
|
||||
)
|
||||
card_layout.addWidget(next_steps_label)
|
||||
|
||||
# Verification results summary
|
||||
if self.verification_results is not None:
|
||||
self._add_verification_section(card_layout)
|
||||
|
||||
# Subtle Ko-Fi support link
|
||||
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#72A5F2; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
|
||||
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#3fd0ea; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
|
||||
kofi_label.setAlignment(Qt.AlignCenter)
|
||||
kofi_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" color: #72A5F2; "
|
||||
" color: #3fd0ea; "
|
||||
" font-size: 11px; "
|
||||
" margin-top: 8px; "
|
||||
" padding: 4px; "
|
||||
@@ -275,6 +302,94 @@ class SuccessDialog(QDialog):
|
||||
# ENB Proton warning shown in separate dialog
|
||||
return base_message
|
||||
|
||||
def _add_verification_section(self, card_layout):
|
||||
"""Add a verification summary section to the card layout."""
|
||||
from PySide6.QtWidgets import QScrollArea
|
||||
|
||||
r = self.verification_results
|
||||
n_pass = len(r.passes)
|
||||
n_warn = len(r.warnings)
|
||||
n_fail = len(r.failures)
|
||||
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.HLine)
|
||||
sep.setStyleSheet("color: #353a40;")
|
||||
card_layout.addWidget(sep)
|
||||
|
||||
if n_fail:
|
||||
summary_text = f"[FAIL] Verification: {n_fail} failure(s), {n_warn} warning(s)"
|
||||
summary_color = "#e05050"
|
||||
elif n_warn:
|
||||
summary_text = f"[WARN] Verification: {n_warn} warning(s) - review before playing"
|
||||
summary_color = "#f0c040"
|
||||
else:
|
||||
summary_text = f"[OK] Verification passed ({n_pass} checks)"
|
||||
summary_color = "#3fd0ea"
|
||||
|
||||
summary_row = QHBoxLayout()
|
||||
summary_row.setContentsMargins(0, 0, 0, 0)
|
||||
summary_row.setSpacing(8)
|
||||
|
||||
summary_lbl = QLabel(summary_text)
|
||||
summary_lbl.setAlignment(Qt.AlignCenter)
|
||||
summary_lbl.setWordWrap(True)
|
||||
summary_lbl.setStyleSheet(
|
||||
f"QLabel {{ font-size: 12px; font-weight: bold; color: {summary_color}; }}"
|
||||
)
|
||||
summary_row.addStretch()
|
||||
summary_row.addWidget(summary_lbl)
|
||||
|
||||
view_btn = QPushButton("View checks")
|
||||
view_btn.setFixedWidth(90)
|
||||
view_btn.setStyleSheet(
|
||||
"QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; "
|
||||
"border: 1px solid #555; border-radius: 4px; padding: 3px 6px; }"
|
||||
"QPushButton:hover { background: #4a4a4a; color: #fff; }"
|
||||
)
|
||||
view_btn.setCursor(Qt.PointingHandCursor)
|
||||
view_btn.clicked.connect(lambda: self._show_verification_detail())
|
||||
summary_row.addWidget(view_btn)
|
||||
summary_row.addStretch()
|
||||
|
||||
row_widget = QWidget()
|
||||
row_widget.setLayout(summary_row)
|
||||
card_layout.addWidget(row_widget)
|
||||
|
||||
if n_fail or n_warn:
|
||||
detail_widget = QWidget()
|
||||
detail_layout = QVBoxLayout(detail_widget)
|
||||
detail_layout.setContentsMargins(0, 0, 0, 0)
|
||||
detail_layout.setSpacing(2)
|
||||
|
||||
for msg in r.failures:
|
||||
lbl = QLabel(f"[FAIL] {msg}")
|
||||
lbl.setWordWrap(True)
|
||||
lbl.setStyleSheet("color: #e05050; font-size: 11px;")
|
||||
detail_layout.addWidget(lbl)
|
||||
for msg in r.warnings:
|
||||
lbl = QLabel(f"[WARN] {msg}")
|
||||
lbl.setWordWrap(True)
|
||||
lbl.setStyleSheet("color: #f0c040; font-size: 11px;")
|
||||
detail_layout.addWidget(lbl)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidget(detail_widget)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setMaximumHeight(120)
|
||||
scroll.setStyleSheet(
|
||||
"QScrollArea { border: 1px solid #353a40; border-radius: 4px; background: #1a1d23; }"
|
||||
)
|
||||
card_layout.addWidget(scroll)
|
||||
|
||||
def _show_verification_detail(self):
|
||||
"""Open the full verification results dialog."""
|
||||
try:
|
||||
from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog
|
||||
dlg = VerificationResultsDialog(self.verification_results, parent=self)
|
||||
dlg.show()
|
||||
except Exception as exc:
|
||||
logger.error("Could not open verification dialog: %s", exc)
|
||||
|
||||
def _update_countdown(self):
|
||||
if self._countdown > 0:
|
||||
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
|
||||
|
||||
@@ -389,11 +389,13 @@ class UlimitGuidanceDialog(QDialog):
|
||||
"""Apply dialog styling"""
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #f5f5f5;
|
||||
background-color: #232323;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
QGroupBox {
|
||||
font-weight: bold;
|
||||
border: 2px solid #cccccc;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #3a3a3a;
|
||||
border-radius: 5px;
|
||||
margin-top: 1ex;
|
||||
padding-top: 10px;
|
||||
@@ -404,13 +406,14 @@ class UlimitGuidanceDialog(QDialog):
|
||||
padding: 0 5px 0 5px;
|
||||
}
|
||||
QTextEdit {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #cccccc;
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #007acc;
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
@@ -418,14 +421,14 @@ class UlimitGuidanceDialog(QDialog):
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #005a9e;
|
||||
background-color: #5a6578;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #004175;
|
||||
background-color: #3fd0ea;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
background-color: #333;
|
||||
color: #666;
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Verification results dialog shown after install/configure workflows."""
|
||||
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QClipboard, QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QLabel, QPushButton,
|
||||
QTextEdit, QVBoxLayout,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_COLOR_FAIL = "#e05050"
|
||||
_COLOR_WARN = "#f0c040"
|
||||
_COLOR_OK = "#3fd0ea"
|
||||
_COLOR_DIM = "#888888"
|
||||
|
||||
|
||||
def _html_row(prefix: str, color: str, msg: str) -> str:
|
||||
safe = msg.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
return (
|
||||
f'<span style="color:{color}; font-family:monospace;">'
|
||||
f'<b>{prefix}</b> {safe}'
|
||||
f'</span><br>'
|
||||
)
|
||||
|
||||
|
||||
class VerificationResultsDialog(QDialog):
|
||||
"""Shows the output of verify_install.py after a workflow completes."""
|
||||
|
||||
def __init__(self, results, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Installation Verification")
|
||||
self.setWindowModality(Qt.NonModal)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
self.setMinimumWidth(600)
|
||||
|
||||
self._results = results
|
||||
n_pass = len(results.passes)
|
||||
n_warn = len(results.warnings)
|
||||
n_fail = len(results.failures)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# --- Headline ---
|
||||
if n_fail:
|
||||
headline = f"[FAIL] {n_fail} failure{'s' if n_fail != 1 else ''}, {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed"
|
||||
h_color = _COLOR_FAIL
|
||||
elif n_warn:
|
||||
headline = f"[WARN] {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed"
|
||||
h_color = _COLOR_WARN
|
||||
else:
|
||||
headline = f"[OK] All {n_pass} checks passed"
|
||||
h_color = _COLOR_OK
|
||||
|
||||
headline_label = QLabel(headline)
|
||||
headline_label.setStyleSheet(
|
||||
f"font-size: 14px; font-weight: bold; color: {h_color};"
|
||||
)
|
||||
layout.addWidget(headline_label)
|
||||
|
||||
# --- Issues view (always visible) ---
|
||||
self._issues_view = QTextEdit()
|
||||
self._issues_view.setReadOnly(True)
|
||||
self._issues_view.setStyleSheet(
|
||||
"QTextEdit { background: #1e1e1e; border: 1px solid #444; "
|
||||
"border-radius: 4px; font-size: 12px; padding: 6px; }"
|
||||
)
|
||||
self._issues_view.setHtml(self._build_issues_html(results))
|
||||
self._issues_view.setMinimumHeight(80)
|
||||
self._issues_view.setMaximumHeight(220)
|
||||
layout.addWidget(self._issues_view)
|
||||
|
||||
# --- Full report view (hidden by default) ---
|
||||
self._full_view = QTextEdit()
|
||||
self._full_view.setReadOnly(True)
|
||||
self._full_view.setStyleSheet(
|
||||
"QTextEdit { background: #1a1a1a; border: 1px solid #333; "
|
||||
"border-radius: 4px; font-size: 11px; padding: 6px; }"
|
||||
)
|
||||
self._full_view.setHtml(self._build_full_html(results))
|
||||
self._full_view.setMinimumHeight(160)
|
||||
self._full_view.setMaximumHeight(300)
|
||||
self._full_view.setVisible(False)
|
||||
layout.addWidget(self._full_view)
|
||||
|
||||
# --- Button row ---
|
||||
btn_row = QHBoxLayout()
|
||||
|
||||
self._toggle_btn = QPushButton("Show all checks")
|
||||
self._toggle_btn.setFixedWidth(130)
|
||||
self._toggle_btn.setStyleSheet(
|
||||
"QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; "
|
||||
"border: 1px solid #555; border-radius: 4px; padding: 4px 8px; }"
|
||||
"QPushButton:hover { background: #4a4a4a; color: #fff; }"
|
||||
)
|
||||
self._toggle_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._toggle_btn.clicked.connect(self._toggle_full_report)
|
||||
btn_row.addWidget(self._toggle_btn)
|
||||
|
||||
btn_row.addStretch()
|
||||
|
||||
copy_btn = QPushButton("Copy Report")
|
||||
copy_btn.setFixedWidth(110)
|
||||
copy_btn.setToolTip("Copy plain-text report to clipboard")
|
||||
copy_btn.clicked.connect(self._copy_report)
|
||||
btn_row.addWidget(copy_btn)
|
||||
|
||||
close_btn = QPushButton("Close")
|
||||
close_btn.setFixedWidth(80)
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.adjustSize()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_issues_html(self, results) -> str:
|
||||
lines = []
|
||||
for msg in results.failures:
|
||||
lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg))
|
||||
for msg in results.warnings:
|
||||
lines.append(_html_row("[WARN]", _COLOR_WARN, msg))
|
||||
if not results.failures and not results.warnings:
|
||||
lines.append(_html_row("[OK]", _COLOR_OK, "No issues found. Your modlist is correctly configured."))
|
||||
return "".join(lines)
|
||||
|
||||
def _build_full_html(self, results) -> str:
|
||||
lines = []
|
||||
if results.failures:
|
||||
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">FAILURES</span><br>')
|
||||
for msg in results.failures:
|
||||
lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg))
|
||||
lines.append("<br>")
|
||||
if results.warnings:
|
||||
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">WARNINGS</span><br>')
|
||||
for msg in results.warnings:
|
||||
lines.append(_html_row("[WARN]", _COLOR_WARN, msg))
|
||||
lines.append("<br>")
|
||||
if results.passes:
|
||||
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">PASSED</span><br>')
|
||||
for msg in results.passes:
|
||||
lines.append(_html_row("[OK] ", _COLOR_OK, msg))
|
||||
components = getattr(results, "installed_components", [])
|
||||
if components:
|
||||
lines.append("<br>")
|
||||
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">INSTALLED COMPONENTS ({len(components)})</span><br>')
|
||||
for c in components:
|
||||
method = c.get("method", "unknown")
|
||||
method_color = _COLOR_OK if method == "native" else _COLOR_DIM
|
||||
safe_name = c["name"].replace("&", "&").replace("<", "<")
|
||||
lines.append(
|
||||
f'<span style="font-family:monospace; font-size:11px;">'
|
||||
f'{safe_name}'
|
||||
f' <span style="color:{method_color}; font-size:10px;">({method})</span>'
|
||||
f'</span><br>'
|
||||
)
|
||||
return "".join(lines)
|
||||
|
||||
def _build_plain_text(self) -> str:
|
||||
r = self._results
|
||||
n_pass = len(r.passes)
|
||||
n_warn = len(r.warnings)
|
||||
n_fail = len(r.failures)
|
||||
|
||||
lines = ["Jackify - Installation Verification", "=" * 40]
|
||||
if n_fail:
|
||||
lines.append(f"RESULT: FAILED ({n_fail} failures, {n_warn} warnings, {n_pass} passed)")
|
||||
elif n_warn:
|
||||
lines.append(f"RESULT: WARNING ({n_warn} warnings, {n_pass} passed)")
|
||||
else:
|
||||
lines.append(f"RESULT: OK (all {n_pass} checks passed)")
|
||||
lines.append("")
|
||||
|
||||
if r.failures:
|
||||
lines.append("FAILURES:")
|
||||
for msg in r.failures:
|
||||
lines.append(f" [FAIL] {msg}")
|
||||
lines.append("")
|
||||
if r.warnings:
|
||||
lines.append("WARNINGS:")
|
||||
for msg in r.warnings:
|
||||
lines.append(f" [WARN] {msg}")
|
||||
lines.append("")
|
||||
if r.passes:
|
||||
lines.append("PASSED:")
|
||||
for msg in r.passes:
|
||||
lines.append(f" [OK] {msg}")
|
||||
components = getattr(r, "installed_components", [])
|
||||
if components:
|
||||
lines.append("")
|
||||
lines.append("INSTALLED COMPONENTS:")
|
||||
for c in components:
|
||||
lines.append(f" {c['name']} ({c.get('method', 'unknown')})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _toggle_full_report(self):
|
||||
visible = self._full_view.isVisible()
|
||||
self._full_view.setVisible(not visible)
|
||||
self._toggle_btn.setText("Hide all checks" if not visible else "Show all checks")
|
||||
self.adjustSize()
|
||||
|
||||
def _copy_report(self):
|
||||
QGuiApplication.clipboard().setText(self._build_plain_text())
|
||||
@@ -49,7 +49,7 @@ class WarningDialog(QDialog):
|
||||
"QFrame#warningCard { "
|
||||
" background: #2d2323; "
|
||||
" border-radius: 12px; "
|
||||
" border: 2px solid #e67e22; "
|
||||
" border: 2px solid #f0c040; "
|
||||
"}"
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ class WarningDialog(QDialog):
|
||||
"QLabel { "
|
||||
" font-size: 36px; "
|
||||
" font-weight: bold; "
|
||||
" color: #e67e22; "
|
||||
" color: #f0c040; "
|
||||
" margin-bottom: 4px; "
|
||||
"}"
|
||||
)
|
||||
@@ -74,7 +74,7 @@ class WarningDialog(QDialog):
|
||||
"QLabel { "
|
||||
" font-size: 20px; "
|
||||
" font-weight: 600; "
|
||||
" color: #e67e22; "
|
||||
" color: #f0c040; "
|
||||
" margin-bottom: 2px; "
|
||||
"}"
|
||||
)
|
||||
@@ -106,7 +106,7 @@ class WarningDialog(QDialog):
|
||||
self.confirm_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 13px; "
|
||||
" color: #e67e22; "
|
||||
" color: #f0c040; "
|
||||
" margin-bottom: 2px; "
|
||||
"}"
|
||||
)
|
||||
@@ -118,11 +118,11 @@ class WarningDialog(QDialog):
|
||||
self._default_lineedit_style = (
|
||||
"QLineEdit { "
|
||||
" font-size: 15px; "
|
||||
" border: 1px solid #e67e22; "
|
||||
" border: 1px solid #f0c040; "
|
||||
" border-radius: 6px; "
|
||||
" padding: 6px; "
|
||||
" background: #23272e; "
|
||||
" color: #e67e22; "
|
||||
" color: #e0e0e0; "
|
||||
"}"
|
||||
)
|
||||
self.confirm_edit.setStyleSheet(self._default_lineedit_style)
|
||||
@@ -140,7 +140,7 @@ class WarningDialog(QDialog):
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
cancel_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #95a5a6; "
|
||||
" background-color: #4a5568; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 4px; "
|
||||
@@ -148,10 +148,10 @@ class WarningDialog(QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #7f8c8d; "
|
||||
" background-color: #5a6578; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #6c7b7d; "
|
||||
" background-color: #3fd0ea; "
|
||||
"}"
|
||||
)
|
||||
button_layout.addWidget(cancel_btn)
|
||||
@@ -161,7 +161,7 @@ class WarningDialog(QDialog):
|
||||
confirm_btn.clicked.connect(self._on_confirm)
|
||||
confirm_btn.setStyleSheet(
|
||||
"QPushButton { "
|
||||
" background-color: #e67e22; "
|
||||
" background-color: #8b2020; "
|
||||
" color: white; "
|
||||
" border: none; "
|
||||
" border-radius: 4px; "
|
||||
@@ -169,10 +169,10 @@ class WarningDialog(QDialog):
|
||||
" padding: 8px 16px; "
|
||||
"} "
|
||||
"QPushButton:hover { "
|
||||
" background-color: #d35400; "
|
||||
" background-color: #a02828; "
|
||||
"} "
|
||||
"QPushButton:pressed { "
|
||||
" background-color: #b34700; "
|
||||
" background-color: #7a1a1a; "
|
||||
"}"
|
||||
)
|
||||
button_layout.addWidget(confirm_btn)
|
||||
@@ -200,7 +200,7 @@ class WarningDialog(QDialog):
|
||||
self.confirm_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 13px; "
|
||||
" color: #e67e22; "
|
||||
" color: #f0c040; "
|
||||
" margin-bottom: 2px; "
|
||||
"}"
|
||||
)
|
||||
@@ -231,7 +231,7 @@ class WarningDialog(QDialog):
|
||||
self.confirm_label.setStyleSheet(
|
||||
"QLabel { "
|
||||
" font-size: 13px; "
|
||||
" color: #c0392b; " # Red for error
|
||||
" color: #e05050; "
|
||||
" margin-bottom: 2px; "
|
||||
" font-weight: bold; "
|
||||
"}"
|
||||
@@ -242,10 +242,10 @@ class WarningDialog(QDialog):
|
||||
self.confirm_edit.setStyleSheet(
|
||||
"QLineEdit { "
|
||||
" font-size: 15px; "
|
||||
" border: 2px solid #c0392b; " # Red border for error
|
||||
" border: 2px solid #8b2020; "
|
||||
" border-radius: 6px; "
|
||||
" padding: 6px; "
|
||||
" background: #3b2323; " # Darker red background
|
||||
" color: #e67e22; "
|
||||
" background: #3b2323; "
|
||||
" color: #e0e0e0; "
|
||||
"}"
|
||||
)
|
||||
@@ -56,7 +56,13 @@ if '--env-diagnostic' in sys.argv:
|
||||
env_data['ld_library_path_suspicious'] = suspicious
|
||||
|
||||
# Try to find jackify-engine from bundled context
|
||||
meipass = getattr(sys, '_MEIPASS', None)
|
||||
appdir = os.environ.get('APPDIR')
|
||||
engine_paths = []
|
||||
if appdir:
|
||||
appdir_engine = Path(appdir) / 'opt' / 'jackify' / 'engine' / 'jackify-engine'
|
||||
if appdir_engine.exists():
|
||||
engine_paths.append(str(appdir_engine))
|
||||
if meipass:
|
||||
meipass_path = Path(meipass)
|
||||
potential_engine = meipass_path / "jackify" / "engine" / "jackify-engine"
|
||||
@@ -86,13 +92,24 @@ from jackify import __version__ as jackify_version
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if '--help' in sys.argv or '-h' in sys.argv:
|
||||
print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n\nIf no options are given, the GUI will launch by default.\n""")
|
||||
print("""Jackify - Native Linux Modlist Manager\n\nUsage:\n jackify [--cli] [--debug] [--version] [--help]\n jackify --list-installed [APPID]\n\nOptions:\n --cli Launch CLI frontend\n --debug Enable debug logging\n --version Show version and exit\n --help, -h Show this help message and exit\n --list-installed List installed Wine components for a modlist prefix.\n Provide APPID to target a specific prefix, or omit to\n select from configured modlists interactively.\n\nIf no options are given, the GUI will launch by default.\n""")
|
||||
sys.exit(0)
|
||||
|
||||
if '-v' in sys.argv or '--version' in sys.argv or '-V' in sys.argv:
|
||||
print(f"Jackify version {jackify_version}")
|
||||
sys.exit(0)
|
||||
|
||||
if '--list-installed' in sys.argv:
|
||||
_li_idx = sys.argv.index('--list-installed')
|
||||
_li_appid = (
|
||||
sys.argv[_li_idx + 1]
|
||||
if _li_idx + 1 < len(sys.argv) and not sys.argv[_li_idx + 1].startswith('-')
|
||||
else None
|
||||
)
|
||||
from jackify.frontends.cli.commands.list_installed import ListInstalledCommand
|
||||
ListInstalledCommand().run(appid=_li_appid)
|
||||
sys.exit(0)
|
||||
|
||||
from jackify import __version__
|
||||
|
||||
# Add src directory to Python path
|
||||
@@ -124,20 +141,6 @@ from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder
|
||||
|
||||
ENABLE_WINDOW_HEIGHT_ANIMATION = False
|
||||
|
||||
# Constants for styling and disclaimer
|
||||
DISCLAIMER_TEXT = (
|
||||
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
|
||||
"without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. "
|
||||
"The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. "
|
||||
"Please back up your data and use caution."
|
||||
)
|
||||
|
||||
MENU_ITEMS = [
|
||||
("Modlist Tasks", "modlist_tasks"),
|
||||
("Hoolamike Tasks", "hoolamike_tasks"),
|
||||
("Additional Tasks", "additional_tasks"),
|
||||
("Exit Jackify", "exit_jackify"),
|
||||
]
|
||||
|
||||
class JackifyMainWindow(
|
||||
MainWindowGeometryMixin,
|
||||
@@ -210,7 +213,7 @@ def resource_path(relative_path):
|
||||
jackify_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||
return os.path.join(jackify_dir, relative_path)
|
||||
|
||||
def main():
|
||||
def main(initial_nxm_url: str = ""):
|
||||
"""Main entry point for the GUI application"""
|
||||
# CRITICAL: Enable faulthandler for segfault debugging
|
||||
# Print Python stack traces on segfault
|
||||
@@ -245,19 +248,41 @@ def main():
|
||||
# Command-line --debug always takes precedence
|
||||
if '--debug' in sys.argv or '-d' in sys.argv:
|
||||
debug_mode = True
|
||||
# Temporarily save CLI debug flag to config so engine can see it
|
||||
config_handler.set('debug_mode', True)
|
||||
os.environ.setdefault('QT_LOGGING_RULES', '*.debug=true')
|
||||
os.environ.setdefault('QT_DEBUG_PLUGINS', '1')
|
||||
import logging
|
||||
|
||||
# Initialize root logger: jackify.log (INFO, always) + jackify-debug.log (DEBUG, debug mode only)
|
||||
from jackify.shared.logging import LoggingHandler
|
||||
root_logger = LoggingHandler().setup_application_logging(debug_mode)
|
||||
|
||||
_unhandled_exception_shown = False
|
||||
|
||||
def _unhandled_exception(exc_type, exc_value, exc_tb):
|
||||
nonlocal _unhandled_exception_shown
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||
return
|
||||
logging.getLogger().critical("Unhandled exception", exc_info=(exc_type, exc_value, exc_tb))
|
||||
if not _unhandled_exception_shown:
|
||||
_unhandled_exception_shown = True
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
log_dir = get_jackify_logs_dir()
|
||||
except Exception:
|
||||
log_dir = "the Jackify log directory"
|
||||
|
||||
def _show_dialog():
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.critical(
|
||||
None,
|
||||
"Unexpected Error",
|
||||
f"Jackify encountered an unexpected error. The current operation may not have completed.\n\n"
|
||||
f"{exc_value}\n\n"
|
||||
f"Details were written to:\n{log_dir}",
|
||||
)
|
||||
QTimer.singleShot(0, _show_dialog)
|
||||
|
||||
sys.excepthook = _unhandled_exception
|
||||
|
||||
@@ -277,7 +302,7 @@ def main():
|
||||
dev_mode = '--dev' in sys.argv
|
||||
|
||||
# Launch GUI application
|
||||
app = QApplication(sys.argv)
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
# CRITICAL: Set application name before desktop file name to ensure proper window title/icon on PopOS/Ubuntu
|
||||
app.setApplicationName("Jackify")
|
||||
app.setApplicationDisplayName("Jackify")
|
||||
@@ -363,6 +388,11 @@ def main():
|
||||
|
||||
# Start background update check after window is shown
|
||||
window._check_for_updates_on_startup()
|
||||
window._check_tool_updates_on_startup()
|
||||
|
||||
if initial_nxm_url:
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(500, lambda: window._on_nxm_url_received(initial_nxm_url))
|
||||
|
||||
# Ensure cleanup on exit
|
||||
import atexit
|
||||
|
||||
@@ -32,6 +32,15 @@ class MainWindowBackendMixin:
|
||||
from jackify.backend.services.update_service import UpdateService
|
||||
from jackify import __version__
|
||||
self.update_service = UpdateService(__version__)
|
||||
|
||||
from jackify.backend.services.nxm_ipc import NxmIpcServer
|
||||
self._nxm_ipc_server = NxmIpcServer(self)
|
||||
self._nxm_ipc_server.url_received.connect(self._on_nxm_url_received)
|
||||
self._nxm_ipc_server.start()
|
||||
|
||||
from jackify.backend.services.nxm_protocol import ensure_nxm_registered
|
||||
ensure_nxm_registered()
|
||||
|
||||
logger.debug(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
|
||||
|
||||
def _is_steamdeck(self):
|
||||
|
||||
@@ -126,8 +126,39 @@ class MainWindowDialogsMixin:
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
def _on_nxm_url_received(self, url: str) -> None:
|
||||
"""Handle an incoming nxm:// URL from the IPC server or initial launch."""
|
||||
try:
|
||||
from jackify.backend.services.nxm_url import parse_nxm_url
|
||||
from jackify.backend.services import nxm_session
|
||||
from jackify.tools.verify_install import discover_installed_modlists
|
||||
from jackify.frontends.gui.dialogs.nxm_download_dialog import NxmDownloadDialog
|
||||
|
||||
nxm = parse_nxm_url(url)
|
||||
modlists = discover_installed_modlists()
|
||||
if not modlists:
|
||||
from jackify.frontends.gui.dialogs.nxm_download_dialog import show_no_modlists_error
|
||||
show_no_modlists_error()
|
||||
return
|
||||
|
||||
# Prefer the actively running MO2 instance; fall back to session memory.
|
||||
auto_start = nxm_session.detect_active_mo2_modlist(modlists)
|
||||
if auto_start is None:
|
||||
remembered = nxm_session.get_remembered_modlist()
|
||||
if remembered:
|
||||
auto_start = next((m for m in modlists if m["name"] == remembered), None)
|
||||
|
||||
dlg = NxmDownloadDialog(nxm, modlists, parent=self, auto_start_modlist=auto_start)
|
||||
dlg.show()
|
||||
dlg.raise_()
|
||||
dlg.activateWindow()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to handle NXM URL %r: %s", url, e)
|
||||
|
||||
def cleanup_processes(self):
|
||||
try:
|
||||
if hasattr(self, '_nxm_ipc_server') and self._nxm_ipc_server is not None:
|
||||
self._nxm_ipc_server.stop()
|
||||
if hasattr(self, '_update_thread') and self._update_thread is not None:
|
||||
self._update_thread = self._stop_qthread(self._update_thread, "_update_thread")
|
||||
if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None:
|
||||
|
||||
@@ -135,10 +135,10 @@ class MainWindowGeometryMixin:
|
||||
self.showMaximized()
|
||||
|
||||
def _on_child_resize_request(self, mode: str):
|
||||
logger.debug(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
|
||||
logger.debug(f"_on_child_resize_request called with mode='{mode}', current_size={self.size()}")
|
||||
try:
|
||||
if self.system_info and self.system_info.is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, ignoring resize request")
|
||||
logger.debug("Steam Deck detected, ignoring resize request")
|
||||
try:
|
||||
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
|
||||
self.install_ttw_screen.show_details_checkbox.setVisible(False)
|
||||
@@ -183,7 +183,7 @@ class MainWindowGeometryMixin:
|
||||
before = self.size()
|
||||
self._programmatic_resize = True
|
||||
self.resize(self.size().width(), target_height)
|
||||
logger.debug(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
|
||||
logger.debug(f"Animated fallback resize from {before} to {self.size()}")
|
||||
QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False))
|
||||
return
|
||||
start_rect = self.geometry()
|
||||
|
||||
@@ -65,6 +65,47 @@ class MainWindowStartupMixin:
|
||||
except Exception as e:
|
||||
print(f"Error checking protontricks: {e}")
|
||||
|
||||
def _check_tool_updates_on_startup(self):
|
||||
class _ToolUpdateCheckThread(QThread):
|
||||
updates_found = Signal(bool)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.tool_registry import ToolRegistry, get_effective_definitions
|
||||
registry = ToolRegistry()
|
||||
for defn in get_effective_definitions():
|
||||
if defn.pinned_version is not None:
|
||||
continue
|
||||
status = registry.get_status(defn.tool_id)
|
||||
if not status or not status.installed:
|
||||
continue
|
||||
logger.debug(
|
||||
"Startup tool update check: %s installed=%s version=%s",
|
||||
defn.tool_id, status.installed, status.installed_version,
|
||||
)
|
||||
tag = registry.check_latest_version(defn.tool_id)
|
||||
logger.debug("Startup tool update check: %s latest=%s", defn.tool_id, tag)
|
||||
if tag and status.installed_version and tag.lstrip("v") != status.installed_version.lstrip("v"):
|
||||
logger.debug("Startup tool update check: update available for %s", defn.tool_id)
|
||||
self.updates_found.emit(True)
|
||||
return
|
||||
self.updates_found.emit(False)
|
||||
except Exception as e:
|
||||
logger.warning("Tool update check failed: %s", e, exc_info=True)
|
||||
self.updates_found.emit(False)
|
||||
|
||||
def on_result(has_updates: bool):
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(0, lambda: (
|
||||
self.main_menu.notify_tool_updates(has_updates)
|
||||
if hasattr(self, 'main_menu') and hasattr(self.main_menu, 'notify_tool_updates')
|
||||
else None
|
||||
))
|
||||
|
||||
self._tool_update_check_thread = _ToolUpdateCheckThread()
|
||||
self._tool_update_check_thread.updates_found.connect(on_result)
|
||||
self._tool_update_check_thread.start()
|
||||
|
||||
def _check_for_updates_on_startup(self):
|
||||
try:
|
||||
logger.debug("Checking for updates on startup...")
|
||||
|
||||
@@ -76,13 +76,13 @@ class MainWindowUIMixin:
|
||||
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
|
||||
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
|
||||
bottom_bar_layout.addStretch(1)
|
||||
kofi_link = QLabel('<a href="#" style="color:#72A5F2; text-decoration:none;">Support on Ko-fi</a>')
|
||||
kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;")
|
||||
kofi_link = QLabel('<a href="#" style="color:#3fd0ea; text-decoration:none;">Support on Ko-fi</a>')
|
||||
kofi_link.setStyleSheet("color: #3fd0ea; font-size: 13px;")
|
||||
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
kofi_link.setOpenExternalLinks(False)
|
||||
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
|
||||
kofi_link.setToolTip("Support Jackify development")
|
||||
bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter)
|
||||
bottom_bar_layout.addWidget(kofi_link)
|
||||
bottom_bar_layout.addStretch(1)
|
||||
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
|
||||
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
|
||||
@@ -229,9 +229,9 @@ class MainWindowUIMixin:
|
||||
return screen
|
||||
|
||||
def _make_third_party_tools_screen(self):
|
||||
from jackify.frontends.gui.screens.third_party_tools import ThirdPartyToolsScreen
|
||||
screen = ThirdPartyToolsScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0,
|
||||
from jackify.frontends.gui.screens.tools_hub import ToolsHubScreen
|
||||
screen = ToolsHubScreen(
|
||||
stacked_widget=self.stacked_widget, main_menu_index=0, ttw_screen_index=5,
|
||||
)
|
||||
self.third_party_tools_screen = screen
|
||||
return screen
|
||||
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,10 +47,12 @@ class ThreadLifecycleMixin:
|
||||
return None
|
||||
|
||||
for name in (signal_names or []):
|
||||
try:
|
||||
getattr(thread, name).disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
try:
|
||||
getattr(thread, name).disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Register in the module-level set so the reference survives screen destruction.
|
||||
# Remove from registry when the thread finishes so it can be GC'd cleanly.
|
||||
@@ -68,6 +71,36 @@ class ThreadLifecycleMixin:
|
||||
pass
|
||||
self._park_all_threads()
|
||||
|
||||
def _kill_prefix_wine_processes(self, appid: str = '') -> None:
|
||||
"""Kill wine/winetricks subprocesses on user-initiated cancel.
|
||||
|
||||
Called before parking threads so the blocked subprocess.run() calls inside
|
||||
the thread actually return rather than running until completion.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
for pattern in ('winetricks', 'protontricks'):
|
||||
subprocess.run(['pkill', '-9', '-f', pattern], capture_output=True)
|
||||
if not appid:
|
||||
return
|
||||
try:
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.handlers.winetricks_handler import WinetricksHandler
|
||||
compat = PathHandler.find_compat_data(str(appid))
|
||||
if not compat:
|
||||
return
|
||||
pfx = str(compat / 'pfx')
|
||||
wine_bin = WinetricksHandler()._get_wine_binary_for_prefix(pfx)
|
||||
if not wine_bin:
|
||||
return
|
||||
wineserver = os.path.join(os.path.dirname(wine_bin), 'wineserver')
|
||||
if os.path.isfile(wineserver):
|
||||
subprocess.run([wineserver, '-k'],
|
||||
env={**os.environ, 'WINEPREFIX': pfx},
|
||||
timeout=5, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _park_all_threads(self):
|
||||
"""Park every running QThread attribute found on this instance.
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
Additional Tasks & Tools Screen
|
||||
|
||||
Simple screen for TTW automation only.
|
||||
Follows the same pattern as ModlistTasksScreen.
|
||||
Additional tools and automation. Follows the same pattern as ModlistTasksScreen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -23,7 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdditionalTasksScreen(QWidget):
|
||||
"""Simple Additional Tasks screen for TTW only"""
|
||||
"""Additional Tasks screen for automation and standalone tools."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None,
|
||||
install_mo2_screen_index: int = 9):
|
||||
@@ -67,7 +66,7 @@ class AdditionalTasksScreen(QWidget):
|
||||
header_layout.addSpacing(10)
|
||||
|
||||
# Description area with fixed height
|
||||
desc = QLabel("TTW automation, Wabbajack installer, and additional tools.")
|
||||
desc = QLabel("Wabbajack installer, MO2 setup, and additional tools.")
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #ccc; font-size: 13px;")
|
||||
desc.setAlignment(Qt.AlignHCenter)
|
||||
@@ -93,10 +92,11 @@ class AdditionalTasksScreen(QWidget):
|
||||
"""Set up the menu buttons section"""
|
||||
# Menu options
|
||||
MENU_ITEMS = [
|
||||
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
|
||||
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
|
||||
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
|
||||
("Run Install Verifier", "run_verifier", "Check an installed modlist for common configuration problems"),
|
||||
("Configure Tool Compatibility", "tool_config", "Apply xEdit, Pandora and DLL fixes to an existing modlist prefix"),
|
||||
("Setup Mod Organizer 2", "setup_mo2", "Download and configure a standalone MO2 instance"),
|
||||
("Install Wabbajack", "wabbajack_install", "Install Wabbajack.exe via Proton (automated setup)"),
|
||||
("Create Diagnostic Bundle", "diagnostic_bundle", "Package logs and system info for support reporting"),
|
||||
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
|
||||
]
|
||||
|
||||
@@ -148,25 +148,19 @@ class AdditionalTasksScreen(QWidget):
|
||||
|
||||
def _handle_button_click(self, action_id):
|
||||
"""Handle button clicks"""
|
||||
if action_id == "ttw_install":
|
||||
self._show_ttw_info()
|
||||
if action_id == "run_verifier":
|
||||
self._run_install_verifier()
|
||||
elif action_id == "wabbajack_install":
|
||||
self._show_wabbajack_installer()
|
||||
elif action_id == "setup_mo2":
|
||||
self._show_mo2_setup()
|
||||
elif action_id == "tool_config":
|
||||
self._show_tool_config()
|
||||
elif action_id == "coming_soon":
|
||||
self._show_coming_soon_info()
|
||||
elif action_id == "diagnostic_bundle":
|
||||
self._run_diagnostic_bundle()
|
||||
elif action_id == "return_main_menu":
|
||||
self._return_to_main_menu()
|
||||
|
||||
def _show_ttw_info(self):
|
||||
"""Navigate to TTW installation screen"""
|
||||
if self.stacked_widget:
|
||||
# Navigate to TTW installation screen (index 5)
|
||||
self.stacked_widget.setCurrentIndex(5)
|
||||
|
||||
def _show_wabbajack_installer(self):
|
||||
"""Navigate to Wabbajack installer screen"""
|
||||
if self.stacked_widget:
|
||||
@@ -178,20 +172,202 @@ class AdditionalTasksScreen(QWidget):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.install_mo2_screen_index)
|
||||
|
||||
def _show_coming_soon_info(self):
|
||||
"""Show coming soon info"""
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.information(
|
||||
self,
|
||||
"Coming Soon",
|
||||
"Additional tools and features will be added in future updates.\n\n"
|
||||
"Check back later for more functionality!"
|
||||
)
|
||||
|
||||
def _show_tool_config(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(11)
|
||||
|
||||
def _run_install_verifier(self):
|
||||
"""Prompt user to pick a modlist, run the verifier, and show results."""
|
||||
from ..services.message_service import MessageService
|
||||
try:
|
||||
from jackify.backend.services.install_verifier_service import _load_verifier
|
||||
verifier_mod = _load_verifier()
|
||||
modlists = verifier_mod.discover_installed_modlists()
|
||||
except Exception as e:
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Verifier Error",
|
||||
f"Could not load install verifier: {e}",
|
||||
)
|
||||
return
|
||||
|
||||
if not modlists:
|
||||
MessageService.information(
|
||||
self,
|
||||
"No Modlists Found",
|
||||
"No installed modlists were found in Steam shortcuts.\n\n"
|
||||
"Ensure ModOrganizer.exe shortcuts exist in Steam for your modlists.",
|
||||
)
|
||||
return
|
||||
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QHBoxLayout
|
||||
picker = QDialog(self)
|
||||
picker.setWindowTitle("Select Modlist to Verify")
|
||||
picker.setMinimumWidth(480)
|
||||
picker.setMinimumHeight(260)
|
||||
picker_layout = QVBoxLayout(picker)
|
||||
picker_layout.addWidget(QLabel("Select a modlist to verify:"))
|
||||
|
||||
lw = QListWidget()
|
||||
for m in modlists:
|
||||
pfx_ok = m["pfx"] and m["pfx"].is_dir()
|
||||
suffix = "" if pfx_ok else " (prefix not found)"
|
||||
item = QListWidgetItem(f"{m['name']}{suffix}")
|
||||
item.setData(1000, m)
|
||||
lw.addItem(item)
|
||||
lw.setCurrentRow(0)
|
||||
picker_layout.addWidget(lw)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("Run Verifier")
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
picker_layout.addLayout(btn_row)
|
||||
|
||||
ok_btn.clicked.connect(picker.accept)
|
||||
cancel_btn.clicked.connect(picker.reject)
|
||||
lw.itemDoubleClicked.connect(lambda _: picker.accept())
|
||||
|
||||
if picker.exec() != QDialog.Accepted:
|
||||
return
|
||||
|
||||
selected_item = lw.currentItem()
|
||||
if not selected_item:
|
||||
return
|
||||
selected = selected_item.data(1000)
|
||||
|
||||
pfx = selected.get("pfx")
|
||||
if not pfx or not pfx.is_dir():
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Prefix Not Found",
|
||||
f"The Proton prefix for '{selected['name']}' was not found.\n\n"
|
||||
"Launch the modlist from Steam at least once to create the prefix.",
|
||||
)
|
||||
return
|
||||
|
||||
from PySide6.QtCore import QThread, Signal as _Signal
|
||||
|
||||
class _VerifierThread(QThread):
|
||||
done = _Signal(object)
|
||||
|
||||
def __init__(self, verifier_module, entry, parent=None):
|
||||
super().__init__(parent)
|
||||
self._verifier = verifier_module
|
||||
self._entry = entry
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
r = self._verifier.run_verification(
|
||||
pfx=self._entry["pfx"],
|
||||
modlist_dir=self._entry["modlist_dir"],
|
||||
game_type=self._entry["game_type"],
|
||||
appid=self._entry["appid"],
|
||||
modlist_name=self._entry.get("name", ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("On-demand verifier error: %s", exc)
|
||||
r = None
|
||||
self.done.emit(r)
|
||||
|
||||
from jackify.frontends.gui.services.message_service import MessageService as _MS
|
||||
progress_dlg = QDialog(self)
|
||||
progress_dlg.setWindowTitle("Verifying...")
|
||||
progress_dlg.setModal(True)
|
||||
prog_layout = QVBoxLayout(progress_dlg)
|
||||
prog_layout.addWidget(QLabel(f"Running verifier for '{selected['name']}'...\nThis may take a moment."))
|
||||
progress_dlg.setFixedSize(340, 100)
|
||||
progress_dlg.show()
|
||||
|
||||
self._verifier_ondemand_thread = _VerifierThread(verifier_mod, selected, parent=self)
|
||||
|
||||
def _on_done(results):
|
||||
progress_dlg.accept()
|
||||
self._verifier_ondemand_thread = None
|
||||
if results is None:
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Verifier Error",
|
||||
"The verifier encountered an error and could not complete.",
|
||||
)
|
||||
return
|
||||
from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog
|
||||
dlg = VerificationResultsDialog(results, parent=self)
|
||||
dlg.exec()
|
||||
|
||||
self._verifier_ondemand_thread.done.connect(_on_done)
|
||||
self._verifier_ondemand_thread.start()
|
||||
|
||||
def _run_diagnostic_bundle(self):
|
||||
"""Open the diagnostic bundle dialog; bundle is only created when the user confirms."""
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit
|
||||
)
|
||||
from PySide6.QtCore import QThread, Signal as _Signal
|
||||
|
||||
class _BundleThread(QThread):
|
||||
done = _Signal(object, str) # (bundle_path or None, error_msg)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.diagnostic_service import build_bundle
|
||||
path = build_bundle()
|
||||
self.done.emit(path, "")
|
||||
except Exception as exc:
|
||||
self.done.emit(None, str(exc))
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Diagnostic Bundle")
|
||||
dlg.setMinimumWidth(600)
|
||||
dlg.setMinimumHeight(220)
|
||||
dlg.setStyleSheet("QDialog { background: #181818; color: #fff; }")
|
||||
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
status_label = QLabel("Package logs and system info into a file for support reporting.")
|
||||
layout.addWidget(status_label)
|
||||
|
||||
path_box = QTextEdit()
|
||||
path_box.setReadOnly(True)
|
||||
path_box.setMinimumHeight(60)
|
||||
path_box.setVisible(False)
|
||||
layout.addWidget(path_box)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
create_btn = QPushButton("Create Bundle")
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
btn_row.addWidget(create_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
|
||||
def _on_create():
|
||||
create_btn.setEnabled(False)
|
||||
cancel_btn.setEnabled(False)
|
||||
status_label.setText("Collecting logs and system info...")
|
||||
self._diag_thread = _BundleThread(parent=self)
|
||||
self._diag_thread.done.connect(_on_done)
|
||||
self._diag_thread.start()
|
||||
|
||||
def _on_done(bundle_path, error):
|
||||
self._diag_thread = None
|
||||
cancel_btn.setEnabled(True)
|
||||
cancel_btn.setText("Close")
|
||||
if not bundle_path:
|
||||
status_label.setText(f"Failed: {error}")
|
||||
return
|
||||
status_label.setText("Bundle created:")
|
||||
path_box.setPlainText(str(bundle_path))
|
||||
path_box.setVisible(True)
|
||||
|
||||
create_btn.clicked.connect(_on_create)
|
||||
dlg.exec()
|
||||
|
||||
def _return_to_main_menu(self):
|
||||
"""Return to main menu"""
|
||||
if self.stacked_widget:
|
||||
|
||||
@@ -22,7 +22,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
from jackify.backend.services.resolution_service import ResolutionService
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from ..dialogs import SuccessDialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,12 +32,14 @@ from .configure_existing_modlist_console import ConfigureExistingModlistConsoleM
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from .install_modlist_ttw import TTWIntegrationMixin
|
||||
from .install_modlist_postinstall import PostInstallFeedbackMixin
|
||||
from .install_verifier_mixin import InstallVerifierMixin
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
class ConfigureExistingModlistScreen(
|
||||
ThreadLifecycleMixin,
|
||||
ScreenBackMixin,
|
||||
TTWIntegrationMixin,
|
||||
InstallVerifierMixin,
|
||||
ConfigureExistingModlistUIMixin,
|
||||
ConfigureExistingModlistWorkflowMixin,
|
||||
ConfigureExistingModlistShortcutsMixin,
|
||||
@@ -73,6 +74,7 @@ class ConfigureExistingModlistScreen(
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
self._kill_prefix_wine_processes(str(getattr(self, '_current_appid', '') or ''))
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
@@ -105,6 +107,10 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
|
||||
"""Handle configuration completion"""
|
||||
if getattr(self, '_awaiting_steam_restart', False):
|
||||
self._deferred_completion_args = (success, message, modlist_name, enb_detected)
|
||||
return
|
||||
|
||||
# Re-enable all controls when workflow completes
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
@@ -137,34 +143,27 @@ class ConfigureExistingModlistScreen(
|
||||
'time_taken': self._calculate_time_taken(),
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
'install_dir': install_dir,
|
||||
'game_type': game_type,
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
return
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show success dialog with celebration
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="configure_existing",
|
||||
time_taken=time_taken,
|
||||
game_name=getattr(self, '_current_game_name', None),
|
||||
parent=self
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=install_dir,
|
||||
game_type=game_type,
|
||||
appid=getattr(self, '_current_appid', '') or '',
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'configure_existing',
|
||||
'time_taken': time_taken,
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to show ENB dialog: %s", e)
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
@@ -184,17 +183,19 @@ class ConfigureExistingModlistScreen(
|
||||
if not hasattr(self, 'config_thread') or self.config_thread is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.config_thread.progress_update.disconnect()
|
||||
self.config_thread.configuration_complete.disconnect()
|
||||
self.config_thread.error_occurred.disconnect()
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
for sig_name in ('progress_update', 'configuration_complete', 'error_occurred', 'steam_restart_needed'):
|
||||
try:
|
||||
getattr(self.config_thread, sig_name).disconnect()
|
||||
except (RuntimeError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
if self.config_thread.isRunning():
|
||||
self.config_thread.quit()
|
||||
self.config_thread.wait(5000)
|
||||
|
||||
# wait() ensures the OS thread has fully exited before deleteLater,
|
||||
# preventing "QThread destroyed while still running" if isRunning()
|
||||
# briefly trails the actual thread exit.
|
||||
self.config_thread.wait(3000)
|
||||
self.config_thread.deleteLater()
|
||||
self.config_thread = None
|
||||
|
||||
@@ -225,7 +226,7 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
logger.debug("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||
logger.debug("cleanup called - cleaning up ConfigurationThread")
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
@@ -233,7 +234,7 @@ class ConfigureExistingModlistScreen(
|
||||
|
||||
# Clean up config thread if running
|
||||
if hasattr(self, 'config_thread') and self.config_thread and self.config_thread.isRunning():
|
||||
logger.debug("DEBUG: Parking ConfigurationThread")
|
||||
logger.debug("Parking ConfigurationThread")
|
||||
self.config_thread = self._park_thread(
|
||||
self.config_thread,
|
||||
["progress_update", "configuration_complete", "error_occurred"],
|
||||
|
||||
@@ -12,6 +12,52 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
|
||||
def _handle_progress_update(self, text):
|
||||
"""Handle progress updates - update console, activity window, and progress indicator"""
|
||||
if text.startswith("[NATIVE_DL] "):
|
||||
parts = text.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
_, component, pct_str, speed_str = parts
|
||||
try:
|
||||
pct, speed = float(pct_str), float(speed_str)
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)",
|
||||
pct,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
if text.startswith("[NATIVE_INSTALL] "):
|
||||
component = text[17:].strip()
|
||||
self._stop_component_install_pulse()
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component}{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}...", 60)
|
||||
self._current_native_component = component
|
||||
self._native_done_components = done + 1
|
||||
return
|
||||
if text.startswith("[NATIVE_WAIT] "):
|
||||
parts = text.split(None, 2)
|
||||
if len(parts) >= 3:
|
||||
component, elapsed_s = parts[1], parts[2].strip()
|
||||
done = getattr(self, '_native_done_components', 1)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {elapsed_s}s{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 60)
|
||||
return
|
||||
|
||||
# Always append to console
|
||||
self._safe_append_text(text)
|
||||
|
||||
@@ -27,13 +73,24 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Applying registry files...", 40)
|
||||
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
|
||||
elif "installing wine components" in message_lower or "wine component" in message_lower:
|
||||
elif (
|
||||
"installing wine components" in message_lower
|
||||
or "wine component" in message_lower
|
||||
or "vcrun" in message_lower
|
||||
or ("dotnet" in message_lower and "fix" not in message_lower)
|
||||
):
|
||||
self.progress_indicator.set_status("Installing wine components...", 60)
|
||||
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
self._start_component_install_pulse()
|
||||
comp_list = self._parse_wine_components_message(text)
|
||||
if comp_list:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
self._native_total_components = len(comp_list)
|
||||
self._native_done_components = 0
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine components: {len(comp_list)} queued",
|
||||
0.0,
|
||||
)
|
||||
elif not getattr(self, '_component_install_timer', None) or not self._component_install_timer.isActive():
|
||||
self._start_component_install_pulse()
|
||||
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Wine components installed", 65)
|
||||
@@ -127,14 +184,22 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
|
||||
return
|
||||
if hasattr(self, '_component_install_list') and self._component_install_list:
|
||||
progresses = [
|
||||
FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
)
|
||||
for comp in self._component_install_list
|
||||
]
|
||||
dl_state = getattr(self, '_native_component_progress', {})
|
||||
progresses = []
|
||||
for comp in self._component_install_list:
|
||||
if comp in dl_state:
|
||||
pct, speed = dl_state[comp]
|
||||
progresses.append(FileProgress(
|
||||
filename=f"Wine component: {comp} | {pct:.0f}% ({speed:.1f} MB/s)",
|
||||
operation=OperationType.DOWNLOAD,
|
||||
percent=pct,
|
||||
))
|
||||
else:
|
||||
progresses.append(FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
))
|
||||
self.file_progress_list.update_files(progresses, current_phase=None)
|
||||
else:
|
||||
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
|
||||
@@ -146,5 +211,7 @@ class ConfigureExistingModlistConsoleMixin:
|
||||
self._component_install_timer = None
|
||||
if hasattr(self, '_component_install_list'):
|
||||
del self._component_install_list
|
||||
if hasattr(self, '_native_component_progress'):
|
||||
del self._native_component_progress
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ConfigureExistingModlistUIMixin:
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
|
||||
super().__init__()
|
||||
logger.debug("DEBUG: ConfigureExistingModlistScreen __init__ called")
|
||||
logger.debug("ConfigureExistingModlistScreen __init__ called")
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
@@ -183,7 +183,7 @@ class ConfigureExistingModlistUIMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
|
||||
@@ -56,20 +56,21 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
self._current_appid = shortcut.get('AppID', shortcut.get('appid', ''))
|
||||
raw_appid = shortcut.get('AppID', shortcut.get('appid', ''))
|
||||
self._current_appid = str(raw_appid) if raw_appid != '' else ''
|
||||
resolution = self.resolution_combo.currentText()
|
||||
# Handle resolution saving
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
logger.debug("Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
logger.debug("Saved resolution cleared")
|
||||
# Start the workflow (no shortcut creation needed)
|
||||
self.start_workflow(modlist_name, install_dir, resolution)
|
||||
|
||||
@@ -104,6 +105,7 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str, bool)
|
||||
error_occurred = Signal(str)
|
||||
steam_restart_needed = Signal(str, str, str) # app_name, exe_path, dl_path
|
||||
|
||||
def __init__(self, modlist_name, install_dir, resolution, system_info, detect_func):
|
||||
super().__init__()
|
||||
@@ -128,10 +130,11 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
|
||||
# Create modlist context for existing modlist configuration
|
||||
mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe")
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
modlist_context = ModlistContext(
|
||||
name=self.modlist_name,
|
||||
install_dir=Path(self.install_dir),
|
||||
download_dir=None,
|
||||
download_dir=resolve_mo2_download_dir(Path(self.install_dir)),
|
||||
game_type=detected_game_type,
|
||||
nexus_api_key='', # Not needed for configuration-only
|
||||
modlist_value='', # Not needed for existing modlist
|
||||
@@ -147,26 +150,31 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
# Define callbacks
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
|
||||
# Store completion args rather than emitting immediately so we can
|
||||
# emit steam_restart_needed first when a restart is required.
|
||||
completion_args = [None]
|
||||
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Existing modlists shouldn't need manual steps, but handle gracefully
|
||||
self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})")
|
||||
|
||||
# Call the working configuration service method
|
||||
completion_args[0] = (success, message, modlist_name, enb_detected)
|
||||
|
||||
self.progress_update.emit("Starting existing modlist configuration...")
|
||||
|
||||
# For existing modlists, call configure_modlist_post_steam directly
|
||||
# since Steam setup and manual steps should already be done
|
||||
success = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
if getattr(modlist_context, 'steam_restart_needed', False):
|
||||
self.steam_restart_needed.emit(
|
||||
getattr(modlist_context, 'mounts_app_name', ''),
|
||||
getattr(modlist_context, 'mounts_exe_path', ''),
|
||||
getattr(modlist_context, 'mounts_dl_path', ''),
|
||||
)
|
||||
|
||||
if completion_args[0] is not None:
|
||||
self.configuration_complete.emit(*completion_args[0])
|
||||
|
||||
if not success:
|
||||
self.error_occurred.emit(
|
||||
"Configuration did not complete successfully. "
|
||||
@@ -183,12 +191,53 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
self.config_thread.progress_update.connect(self._handle_progress_update)
|
||||
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
||||
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
||||
self.config_thread.steam_restart_needed.connect(self._on_steam_restart_needed) # (app_name, exe_path, dl_path)
|
||||
self.config_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
self._safe_append_text(f"[ERROR] Failed to start configuration: {e}")
|
||||
MessageService.show_error(self, configuration_failed(str(e)))
|
||||
|
||||
def _on_steam_restart_needed(self, app_name: str, exe_path: str, dl_path: str):
|
||||
"""Prompt before stopping Steam to write a deferred STEAM_COMPAT_MOUNTS update.
|
||||
|
||||
Receives the shortcut identity and path from the signal so it can call
|
||||
ensure_mounts_in_steam_compat on the GUI's ShortcutHandler (not the backend's).
|
||||
|
||||
Sets _awaiting_steam_restart before showing the dialog so that
|
||||
on_configuration_complete (fired by the nested event loop during exec())
|
||||
defers the success/ENB dialogs until after this handler finishes.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
self._awaiting_steam_restart = True
|
||||
try:
|
||||
reply = MessageService.question(
|
||||
self,
|
||||
"Restart Steam?",
|
||||
"The download directory mount needs to be added to STEAM_COMPAT_MOUNTS for this "
|
||||
"modlist. Steam must be stopped to write this change safely.\n\n"
|
||||
"Any running game will be closed. Do you want Jackify to restart Steam now?",
|
||||
safety_level="medium",
|
||||
)
|
||||
if reply == QMessageBox.No:
|
||||
logger.info("User declined Steam restart; STEAM_COMPAT_MOUNTS update skipped")
|
||||
else:
|
||||
try:
|
||||
from jackify.backend.services.steam_restart_service import shutdown_steam, start_steam
|
||||
shutdown_steam()
|
||||
self.shortcut_handler.ensure_mounts_in_steam_compat(app_name, exe_path, dl_path)
|
||||
start_steam()
|
||||
except Exception as e:
|
||||
logger.warning("Steam restart/mounts update failed: %s", e)
|
||||
finally:
|
||||
self._awaiting_steam_restart = False
|
||||
if hasattr(self, '_deferred_completion_args') and self._deferred_completion_args is not None:
|
||||
args = self._deferred_completion_args
|
||||
self._deferred_completion_args = None
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(0, lambda: self.on_configuration_complete(*args))
|
||||
|
||||
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
|
||||
"""Check if VNV automation should run and start it if applicable.
|
||||
|
||||
@@ -227,26 +276,18 @@ class ConfigureExistingModlistWorkflowMixin:
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type=params['workflow_type'],
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=params.get('install_dir', ''),
|
||||
game_type=params.get('game_type', 'unknown'),
|
||||
appid=params.get('appid', ''),
|
||||
success_params={
|
||||
'modlist_name': params['modlist_name'],
|
||||
'workflow_type': params['workflow_type'],
|
||||
'time_taken': params['time_taken'],
|
||||
'game_name': params.get('game_name'),
|
||||
'enb_detected': params.get('enb_detected', False),
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
def show_manual_steps_dialog(self, extra_warning=""):
|
||||
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
|
||||
|
||||
@@ -24,7 +24,6 @@ from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
from jackify.backend.services.resolution_service import ResolutionService
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from ..dialogs import SuccessDialog
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
@@ -36,11 +35,12 @@ from .configure_new_modlist_dialogs import ConfigureNewModlistDialogsMixin, Modl
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from .install_modlist_ttw import TTWIntegrationMixin
|
||||
from .install_modlist_postinstall import PostInstallFeedbackMixin
|
||||
from .install_verifier_mixin import InstallVerifierMixin
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
|
||||
class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegrationMixin, InstallVerifierMixin, ConfigureNewModlistUISetupMixin, ConfigureNewModlistConsoleMixin, ConfigureNewModlistWorkflowMixin, ConfigureNewModlistDialogsMixin, PostInstallFeedbackMixin, QWidget):
|
||||
resize_request = Signal(str)
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
@@ -48,6 +48,8 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
appid = str(getattr(self, 'context', {}).get('appid', '') or '')
|
||||
self._kill_prefix_wine_processes(appid)
|
||||
self.cleanup_processes()
|
||||
self.collapse_show_details_before_leave()
|
||||
self.go_back()
|
||||
@@ -84,31 +86,27 @@ class ConfigureNewModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWIntegr
|
||||
'time_taken': self._calculate_time_taken(),
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
'install_dir': install_dir,
|
||||
'game_type': game_type,
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
return
|
||||
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="configure_new",
|
||||
time_taken=time_taken,
|
||||
game_name=getattr(self, '_current_game_name', None),
|
||||
parent=self
|
||||
game_type = self._detect_game_type_from_mo2_ini(install_dir) if install_dir else "unknown"
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=install_dir or "",
|
||||
game_type=game_type,
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'configure_new',
|
||||
'time_taken': time_taken,
|
||||
'game_name': getattr(self, '_current_game_name', None),
|
||||
'enb_detected': enb_detected,
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
else:
|
||||
self._safe_append_text(f"Configuration failed: {message}")
|
||||
MessageService.show_error(self, configuration_failed(str(message)))
|
||||
|
||||
@@ -15,6 +15,51 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
|
||||
|
||||
def _handle_progress_update(self, text):
|
||||
"""Handle progress updates - update console, activity window, and progress indicator."""
|
||||
if text.startswith("[NATIVE_DL] "):
|
||||
parts = text.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
_, component, pct_str, speed_str = parts
|
||||
try:
|
||||
pct, speed = float(pct_str), float(speed_str)
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {pct:.0f}% ({speed:.1f} MB/s)",
|
||||
pct,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
if text.startswith("[NATIVE_INSTALL] "):
|
||||
component = text[17:].strip()
|
||||
self._stop_component_install_pulse()
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component}{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}...", 80)
|
||||
self._current_native_component = component
|
||||
self._native_done_components = done + 1
|
||||
return
|
||||
if text.startswith("[NATIVE_WAIT] "):
|
||||
parts = text.split(None, 2)
|
||||
if len(parts) >= 3:
|
||||
component, elapsed_s = parts[1], parts[2].strip()
|
||||
done = getattr(self, '_native_done_components', 1)
|
||||
total = getattr(self, '_native_total_components', 0)
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine component: {component} | {elapsed_s}s{suffix}",
|
||||
0.0,
|
||||
)
|
||||
self.progress_indicator.set_status(f"Installing {component}... ({elapsed_s}s)", 80)
|
||||
return
|
||||
self._safe_append_text(text)
|
||||
|
||||
message_lower = text.lower()
|
||||
@@ -47,11 +92,17 @@ class ConfigureNewModlistConsoleMixin(FocusReclaimMixin):
|
||||
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
|
||||
elif "installing wine components" in message_lower or "wine component" in message_lower:
|
||||
self.progress_indicator.set_status("Installing wine components...", 80)
|
||||
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
self._start_component_install_pulse()
|
||||
comp_list = self._parse_wine_components_message(text)
|
||||
if comp_list:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
self._native_total_components = len(comp_list)
|
||||
self._native_done_components = 0
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__native_component__",
|
||||
f"Wine components: {len(comp_list)} queued",
|
||||
0.0,
|
||||
)
|
||||
elif not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
self._start_component_install_pulse()
|
||||
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
|
||||
self._stop_component_install_pulse()
|
||||
self.progress_indicator.set_status("Wine components installed", 85)
|
||||
|
||||
@@ -234,26 +234,18 @@ class ConfigureNewModlistDialogsMixin:
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type=params['workflow_type'],
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=params.get('install_dir', ''),
|
||||
game_type=params.get('game_type', 'unknown'),
|
||||
appid=params.get('appid', ''),
|
||||
success_params={
|
||||
'modlist_name': params['modlist_name'],
|
||||
'workflow_type': params['workflow_type'],
|
||||
'time_taken': params['time_taken'],
|
||||
'game_name': params.get('game_name'),
|
||||
'enb_detected': params.get('enb_detected', False),
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
dlg = QDialog(self)
|
||||
|
||||
@@ -18,7 +18,7 @@ class ConfigureNewModlistUISetupMixin:
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None):
|
||||
super().__init__()
|
||||
logger.debug("DEBUG: ConfigureNewModlistScreen __init__ called")
|
||||
logger.debug("ConfigureNewModlistScreen __init__ called")
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.dev_mode = dev_mode
|
||||
@@ -178,7 +178,7 @@ class ConfigureNewModlistUISetupMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
|
||||
@@ -95,14 +95,14 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
if resolution and resolution != "Leave unchanged":
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
logger.debug("Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
logger.debug("Saved resolution cleared")
|
||||
|
||||
# Start configuration - automated workflow handles Steam restart internally
|
||||
self.configure_modlist()
|
||||
@@ -134,7 +134,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
from jackify import __version__ as jackify_version
|
||||
logger.info("Jackify v%s", jackify_version)
|
||||
logger.info("Initializing automated Steam setup for '%s'...", modlist_name)
|
||||
logger.info("Initialising automated Steam setup for '%s'...", modlist_name)
|
||||
logger.info("Starting automated Steam shortcut creation and configuration...")
|
||||
|
||||
# Disable the start button to prevent multiple workflows
|
||||
@@ -162,9 +162,13 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def progress_callback(message):
|
||||
self.progress_update.emit(message)
|
||||
|
||||
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
|
||||
download_dir = resolve_mo2_download_dir(Path(self.install_dir))
|
||||
|
||||
result = prefix_service.run_working_workflow(
|
||||
self.modlist_name, self.install_dir, self.mo2_exe_path,
|
||||
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart
|
||||
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart,
|
||||
download_dir=download_dir,
|
||||
)
|
||||
|
||||
self.workflow_complete.emit(result)
|
||||
@@ -304,8 +308,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
'modlist_source': None,
|
||||
'resolution': resolution_value,
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||
'appid': new_appid,
|
||||
'game_name': 'Skyrim Special Edition' # Default for new modlist
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
@@ -364,11 +367,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# This shouldn't happen since automated prefix creation is complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the service method for post-Steam configuration
|
||||
self.progress_update.emit("")
|
||||
self.progress_update.emit("=== Configuration Phase ===")
|
||||
self.progress_update.emit("")
|
||||
@@ -376,7 +374,6 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
@@ -412,8 +409,7 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
'mo2_exe_path': mo2_exe_path,
|
||||
'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None,
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed
|
||||
'appid': new_appid, # Use the NEW AppID from Steam
|
||||
'appid': new_appid,
|
||||
'game_name': 'Skyrim Special Edition' # Default for new modlist
|
||||
}
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
@@ -472,17 +468,11 @@ class ConfigureNewModlistWorkflowMixin:
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- manual steps already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the working configuration service method
|
||||
self.progress_update.emit("Starting configuration with backend service...")
|
||||
|
||||
|
||||
success = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
|
||||
@@ -49,9 +49,10 @@ from .install_modlist_workflow import InstallWorkflowMixin
|
||||
from .install_modlist_nexus import NexusAuthMixin
|
||||
from .install_modlist_selection import ModlistSelectionMixin
|
||||
from .screen_back_mixin import ScreenBackMixin
|
||||
from .install_verifier_mixin import InstallVerifierMixin
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
|
||||
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
|
||||
class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallVerifierMixin, InstallModlistUISetupMixin, ConsoleOutputMixin, ProgressHandlersMixin, PostInstallFeedbackMixin, AutomatedPrefixHandlersMixin, ConfigurationPhaseMixin, QWidget, TTWIntegrationMixin, VNVAutomationMixin, InstallWorkflowMixin, NexusAuthMixin, ModlistSelectionMixin):
|
||||
resize_request = Signal(str) # Signal for expand/collapse like TTW screen
|
||||
def _collect_actionable_controls(self):
|
||||
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
|
||||
@@ -78,6 +79,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
self.nexus_login_btn,
|
||||
# Checkboxes
|
||||
self.auto_restart_checkbox,
|
||||
self.engine_checkbox,
|
||||
]
|
||||
|
||||
def _disable_controls_during_operation(self):
|
||||
@@ -113,12 +115,13 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
|
||||
def _open_url_safe(self, url):
|
||||
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
|
||||
import subprocess
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
try:
|
||||
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not open URL {url}: {e}")
|
||||
logger.warning(f"Could not open URL {url}: {e}")
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle window resize to prioritize form over console"""
|
||||
@@ -216,7 +219,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=420)
|
||||
# DO NOT resize - let window stay at current size
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: showEvent exception: {e}")
|
||||
logger.debug(f"showEvent exception: {e}")
|
||||
|
||||
def _start_gallery_cache_preload(self):
|
||||
"""DEPRECATED: Gallery cache preload now happens at app startup in JackifyMainWindow"""
|
||||
@@ -248,22 +251,22 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
# Check if we got mods
|
||||
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
|
||||
if modlists_with_mods > 0:
|
||||
logger.debug(f"DEBUG: Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
logger.debug(f"Gallery cache ready ({modlists_with_mods} modlists with mods)")
|
||||
else:
|
||||
# Cache didn't have mods, but we fetched fresh - should have mods now
|
||||
logger.debug("DEBUG: Gallery cache updated")
|
||||
logger.debug("Gallery cache updated")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to load gallery cache")
|
||||
logger.debug("Failed to load gallery cache")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Gallery cache preload error: {str(e)}")
|
||||
logger.debug(f"Gallery cache preload error: {str(e)}")
|
||||
|
||||
# Start thread (non-blocking, invisible to user)
|
||||
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
|
||||
# Don't connect finished signal - we don't need to do anything, just let it run
|
||||
self._gallery_cache_preload_thread.start()
|
||||
|
||||
logger.debug("DEBUG: Started background gallery cache preload")
|
||||
logger.debug("Started background gallery cache preload")
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Called when the widget is hidden. Do not clear main window constraints so collapse from go_back() sticks."""
|
||||
@@ -284,17 +287,17 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
if saved_install_parent:
|
||||
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
||||
self.install_dir_edit.setText(suggested_install_dir)
|
||||
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
logger.debug(f"Updated install directory suggestion: {suggested_install_dir}")
|
||||
|
||||
# Update download directory suggestion
|
||||
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
||||
if saved_download_parent:
|
||||
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
||||
self.downloads_dir_edit.setText(suggested_download_dir)
|
||||
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
logger.debug(f"Updated download directory suggestion: {suggested_download_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
logger.debug(f"Error updating directory suggestions: {e}")
|
||||
|
||||
def _save_parent_directories(self, install_dir, downloads_dir):
|
||||
"""Removed automatic saving - user should set defaults in settings"""
|
||||
@@ -422,6 +425,15 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
|
||||
def cleanup_processes(self):
|
||||
"""Clean up any running processes when the window closes or is cancelled"""
|
||||
self._stop_clf3_decompress_pulse()
|
||||
|
||||
fpl = getattr(self, 'file_progress_list', None)
|
||||
if fpl is not None:
|
||||
try:
|
||||
fpl.stop_cpu_tracking()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(self, '_vnv_controller', None) is not None:
|
||||
self._vnv_controller.cleanup()
|
||||
self._vnv_controller = None
|
||||
@@ -446,7 +458,7 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
setattr(self, attr_name, None)
|
||||
return
|
||||
|
||||
logger.debug(f"DEBUG: Stopping {attr_name}")
|
||||
logger.debug(f"Stopping {attr_name}")
|
||||
|
||||
if cancel_method and hasattr(thread, cancel_method):
|
||||
try:
|
||||
@@ -543,13 +555,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
self._pending_manual_download_events = None
|
||||
|
||||
# Cancel the installation thread if it exists
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
|
||||
if self.install_thread.isRunning():
|
||||
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
|
||||
try:
|
||||
if hasattr(self, 'install_thread') and self.install_thread and self.install_thread.isRunning():
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(5000)
|
||||
self.install_thread.wait(12000) # Allow time for child processes (7zz) to die; no terminate() - pthread_cancel corrupts Python
|
||||
if self.install_thread.isRunning():
|
||||
logger.warning("WARNING: InstallationThread still running after 12s cancel wait; retrying")
|
||||
self.install_thread.cancel()
|
||||
self.install_thread.wait(5000)
|
||||
except RuntimeError:
|
||||
self.install_thread = None
|
||||
|
||||
# Park prefix/config threads - disconnect their signals and let them
|
||||
# finish naturally rather than terminating unsafely.
|
||||
@@ -559,6 +574,16 @@ class InstallModlistScreen(ThreadLifecycleMixin, ScreenBackMixin, InstallModlist
|
||||
["progress_update", "workflow_complete", "error_occurred"],
|
||||
)
|
||||
if hasattr(self, 'config_thread') and self.config_thread:
|
||||
_ctx = getattr(self, 'context', None)
|
||||
_appid = str(
|
||||
getattr(self, '_current_appid', '')
|
||||
or ((_ctx.get('appid', '') if isinstance(_ctx, dict) else '') or '')
|
||||
)
|
||||
try:
|
||||
self.config_thread.requestInterruption()
|
||||
except Exception:
|
||||
pass
|
||||
self._kill_prefix_wine_processes(_appid)
|
||||
self.config_thread = self._park_thread(
|
||||
self.config_thread,
|
||||
["progress_update", "configuration_complete", "error_occurred"],
|
||||
|
||||
@@ -160,11 +160,11 @@ class AutomatedPrefixHandlersMixin:
|
||||
self.prefix_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in start_automated_prefix_workflow: {e}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
logger.debug(f"Exception in start_automated_prefix_workflow: {e}")
|
||||
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
|
||||
# Re-enable controls on exception
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
|
||||
"""Handle completion of automated prefix creation"""
|
||||
|
||||
@@ -5,7 +5,6 @@ from .screen_focus_reclaim import FocusReclaimMixin, STEAM_RESTART_SENTINEL
|
||||
from PySide6.QtGui import QFont
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.errors import manual_steps_incomplete, configuration_failed
|
||||
from jackify.frontends.gui.dialogs import SuccessDialog
|
||||
from jackify.backend.handlers.validation_handler import ValidationHandler
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from pathlib import Path
|
||||
@@ -22,7 +21,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
|
||||
def on_configuration_progress(self, progress_msg):
|
||||
"""Handle progress updates from modlist configuration"""
|
||||
self._safe_append_text(progress_msg)
|
||||
if not (progress_msg.startswith("[NATIVE_DL] ") or progress_msg.startswith("[NATIVE_INSTALL] ") or progress_msg.startswith("[NATIVE_WAIT] ")):
|
||||
self._safe_append_text(progress_msg)
|
||||
self._handle_post_install_progress(progress_msg)
|
||||
|
||||
def show_steam_restart_progress(self, message):
|
||||
@@ -90,7 +90,6 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
if self._show_somnium_guidance:
|
||||
self._show_somnium_post_install_guidance()
|
||||
|
||||
# Show celebration SuccessDialog after the entire workflow
|
||||
if not hasattr(self, '_install_workflow_start_time'):
|
||||
self._install_workflow_start_time = time.time()
|
||||
time_taken = int(time.time() - self._install_workflow_start_time)
|
||||
@@ -147,15 +146,16 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
|
||||
if vnv_automation_running:
|
||||
self._cleanup_config_thread()
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': "update" if getattr(self, "_is_update_install", False) else "install",
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected
|
||||
'enb_detected': enb_detected,
|
||||
'install_dir': install_dir,
|
||||
'game_type': getattr(self, '_current_game_type', 'unknown') or 'unknown',
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
# Keep post-install feedback active during VNV automation
|
||||
# Don't show success dialog yet - will be shown in _on_vnv_complete
|
||||
return
|
||||
|
||||
# No VNV automation - end post-install feedback now
|
||||
@@ -167,29 +167,19 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
except Exception as e:
|
||||
logger.warning("Update mode verify: failed post-config INI verification: %s", e)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show normal success dialog
|
||||
workflow_type = "update" if getattr(self, "_is_update_install", False) else "install"
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type=workflow_type,
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
game_type_for_verify = getattr(self, '_current_game_type', 'unknown') or 'unknown'
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=install_dir,
|
||||
game_type=game_type_for_verify,
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': workflow_type,
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': enb_detected,
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
|
||||
if enb_detected:
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
|
||||
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
|
||||
except Exception as e:
|
||||
# Non-blocking: if dialog fails, just log and continue
|
||||
logger.warning(f"Failed to show ENB dialog: {e}")
|
||||
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
|
||||
# Max retries reached - show failure message
|
||||
self._end_post_install_feedback(False)
|
||||
@@ -426,8 +416,7 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||
'appid': new_appid,
|
||||
'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition'
|
||||
}
|
||||
self.context = updated_context # Ensure context is always set
|
||||
@@ -490,15 +479,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
def completion_callback(success, message, modlist_name, enb_detected=False):
|
||||
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- prefix creation already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
@@ -533,8 +516,8 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed
|
||||
'appid': new_appid # Use the NEW AppID from Steam
|
||||
'appid': new_appid
|
||||
|
||||
}
|
||||
|
||||
logger.debug(f"Updated context with new AppID: {new_appid}")
|
||||
@@ -624,15 +607,9 @@ class ConfigurationPhaseMixin(FocusReclaimMixin, InstallModlistShortcutDialogMix
|
||||
def completion_callback(success, message, modlist_name):
|
||||
self.configuration_complete.emit(success, message, modlist_name)
|
||||
|
||||
def manual_steps_callback(modlist_name, retry_count):
|
||||
# Should not reach here -- manual steps already complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the new service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
manual_steps_callback=manual_steps_callback,
|
||||
completion_callback=completion_callback
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Optional
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import logging
|
||||
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code
|
||||
from jackify.backend.utils.engine_error_parser import parse_engine_error_line, error_from_exit_code, nexus_url_from_error_line
|
||||
from jackify.backend.utils.cc_content_detector import is_cc_content_error, extract_cc_filename, is_creation_kit_missing_error
|
||||
from jackify.shared.errors import JackifyError, cc_content_missing, creation_kit_missing
|
||||
|
||||
@@ -35,14 +35,17 @@ class InstallerThread(QThread):
|
||||
|
||||
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name,
|
||||
install_mode='online', progress_state_manager=None, auth_service=None,
|
||||
oauth_info=None):
|
||||
oauth_info=None, game_type=None, clf3_cdn_url=None, engine_id=None):
|
||||
super().__init__()
|
||||
self.modlist = modlist
|
||||
self.install_dir = install_dir
|
||||
self.downloads_dir = downloads_dir
|
||||
self.api_key = api_key
|
||||
self.modlist_name = modlist_name
|
||||
self.game_type = game_type
|
||||
self.install_mode = install_mode
|
||||
self.clf3_cdn_url = clf3_cdn_url
|
||||
self.engine_id = engine_id
|
||||
self.cancelled = False
|
||||
self.process_manager = None
|
||||
self.progress_state_manager = progress_state_manager
|
||||
@@ -126,16 +129,58 @@ class InstallerThread(QThread):
|
||||
|
||||
return False
|
||||
|
||||
# CLF3 tracing line patterns (after ANSI stripping, from tracing_subscriber::fmt default format)
|
||||
_CLF3_TRACING_INFO_RE = re.compile(
|
||||
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(INFO|DEBUG|TRACE)\s+'
|
||||
)
|
||||
_CLF3_TRACING_WARN_RE = re.compile(
|
||||
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s+(WARN|ERROR)\s+\S+:\s+(.*)'
|
||||
)
|
||||
_CLF3_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$')
|
||||
_CLF3_ANSI_RE = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
|
||||
def _read_stderr(self):
|
||||
import time as _time
|
||||
_stderr_log = os.environ.get("JACKIFY_CLF3_VERBOSE")
|
||||
_stderr_fh = open("/tmp/clf3_stderr.log", "w", encoding="utf-8") if _stderr_log else None
|
||||
_last_clf3_phase: str = ''
|
||||
try:
|
||||
for raw in self.process_manager.proc.stderr:
|
||||
line = raw.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
logger.debug(f"Engine stderr: {line}")
|
||||
if _stderr_fh:
|
||||
_stderr_fh.write(line + "\n")
|
||||
_stderr_fh.flush()
|
||||
self._raw_stderr_lines.append(line)
|
||||
if len(self._raw_stderr_lines) > 40:
|
||||
self._raw_stderr_lines.pop(0)
|
||||
|
||||
# CLF3: JSON events are on stdout; human-readable detail and tracing go to stderr.
|
||||
# Forward phase headers and log() messages to Show Details.
|
||||
# Verbose INFO/DEBUG/TRACE tracing is dropped; WARN/ERROR is shown stripped.
|
||||
clf3_parser = getattr(self, '_clf3_parser', None)
|
||||
if clf3_parser is not None:
|
||||
clean = self._CLF3_ANSI_RE.sub('', line)
|
||||
error = parse_engine_error_line(clean)
|
||||
if error and self.last_error is None:
|
||||
self.last_error = error
|
||||
warn_m = self._CLF3_TRACING_WARN_RE.match(clean)
|
||||
if warn_m:
|
||||
self.output_received.emit(f"[WARN] {warn_m.group(2)}\n")
|
||||
elif not self._CLF3_TRACING_INFO_RE.match(clean):
|
||||
if self._CLF3_PHASE_HEADER_RE.match(clean):
|
||||
if clean != _last_clf3_phase:
|
||||
_last_clf3_phase = clean
|
||||
self.output_received.emit(clean + '\n')
|
||||
else:
|
||||
self.output_received.emit(clean + '\n')
|
||||
_act = getattr(self, '_clf3_last_activity', None)
|
||||
if _act is not None:
|
||||
_act[0] = _time.monotonic()
|
||||
continue
|
||||
|
||||
error = parse_engine_error_line(line)
|
||||
if error and self.last_error is None:
|
||||
self.last_error = error
|
||||
@@ -149,9 +194,13 @@ class InstallerThread(QThread):
|
||||
if self.last_error is None and is_cc_content_error(line):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(line) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(line):
|
||||
self.last_error = creation_kit_missing()
|
||||
self.last_error = creation_kit_missing(self.game_type)
|
||||
except Exception as e:
|
||||
logger.debug(f"Stderr reader error: {e}")
|
||||
finally:
|
||||
if _stderr_fh:
|
||||
_stderr_fh.close()
|
||||
logger.info("CLF3 stderr log written to /tmp/clf3_stderr.log")
|
||||
|
||||
def _remember_stdout_line(self, line: str) -> None:
|
||||
"""Keep a bounded tail of meaningful stdout lines for failure diagnostics."""
|
||||
@@ -214,6 +263,9 @@ class InstallerThread(QThread):
|
||||
"""Build a user-facing failure message with the best available root cause."""
|
||||
root_cause = self._extract_root_cause_line()
|
||||
if root_cause:
|
||||
nexus_url = nexus_url_from_error_line(root_cause)
|
||||
if nexus_url:
|
||||
root_cause = f"{root_cause}\nMod page: {nexus_url}"
|
||||
if self._resource_limit_hint and "file descriptor" not in root_cause.lower():
|
||||
return f"{root_cause}\n\nPossible contributing issue: {self._resource_limit_hint}"
|
||||
return root_cause
|
||||
@@ -251,42 +303,195 @@ class InstallerThread(QThread):
|
||||
"Install failed, but the engine did not provide a specific error line."
|
||||
)
|
||||
|
||||
def _run_clf3_heartbeat(self, last_activity: list, stop: threading.Event) -> None:
|
||||
"""Emit periodic 'finalising' updates to Show Details when stdout goes silent.
|
||||
|
||||
Fires once when silence exceeds THRESHOLD, then repeats every REPEAT seconds
|
||||
of continued silence — so extended waits remain visible to the user.
|
||||
Samples /proc/<pid>/io to show write throughput, giving the user concrete
|
||||
evidence that extraction is progressing even when CLF3 emits no output.
|
||||
Resets when real output arrives so a new silence period can trigger it again.
|
||||
"""
|
||||
import copy
|
||||
import time as _time
|
||||
THRESHOLD = 8.0
|
||||
INTERVAL = 5.0
|
||||
REPEAT = 30.0
|
||||
last_heartbeat_at = [0.0]
|
||||
while not stop.wait(INTERVAL):
|
||||
proc = self.process_manager.proc if self.process_manager else None
|
||||
if not proc or proc.poll() is not None:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
elapsed_since_activity = now - last_activity[0]
|
||||
if elapsed_since_activity < THRESHOLD:
|
||||
continue
|
||||
# Allow first fire when silence starts; then only re-fire every REPEAT seconds.
|
||||
already_fired = last_heartbeat_at[0] >= last_activity[0]
|
||||
if already_fired and (now - last_heartbeat_at[0]) < REPEAT:
|
||||
continue
|
||||
clf3_parser = getattr(self, '_clf3_parser', None)
|
||||
phase = (clf3_parser.get_state().phase_name if clf3_parser else None) or 'Working'
|
||||
wait_secs = int(elapsed_since_activity)
|
||||
# Sample write_bytes to show active extraction throughput.
|
||||
# The N/N dispatch counter fires when all archives are handed to worker threads;
|
||||
# the workers themselves continue decompressing in parallel after that point.
|
||||
# /proc/<pid>/io write_bytes confirms real I/O is happening.
|
||||
if not already_fired:
|
||||
total = clf3_parser.get_state().phase_max_steps if clf3_parser else 0
|
||||
archive_str = f"{total} archives" if total else "archives"
|
||||
self.output_received.emit(f"[Decompressing {archive_str}: workers running...]\n")
|
||||
if clf3_parser:
|
||||
from jackify.shared.progress_models import InstallationPhase
|
||||
current = clf3_parser.get_state()
|
||||
heartbeat_state = copy.copy(current)
|
||||
heartbeat_state.phase = InstallationPhase.FINALIZE
|
||||
heartbeat_state.phase_name = "Decompressing"
|
||||
heartbeat_state.phase_step = 0
|
||||
heartbeat_state.phase_max_steps = 0
|
||||
heartbeat_state.overall_percent = 99.0
|
||||
heartbeat_state.message = "Decompressing archives..."
|
||||
self.progress_updated.emit(heartbeat_state)
|
||||
last_heartbeat_at[0] = now
|
||||
|
||||
def _run_clf3_stdout_loop(self, last_activity: list) -> None:
|
||||
"""Read CLF3 stdout line-by-line and forward to Show Details.
|
||||
|
||||
JSON progress events (keyed on "type") and plain human-readable text both arrive
|
||||
on stdout. Manual download events (keyed on "event") also appear on stdout.
|
||||
|
||||
Extraction dispatch counters ("Extracting: N/M") are dropped — named per-archive
|
||||
completion lines already cover this information.
|
||||
Directive counters ("Processing: N/M") are buffered; only the final value is
|
||||
emitted when the next non-counter line arrives, avoiding a 1315-line flood.
|
||||
"""
|
||||
import time as _time
|
||||
_COUNTER_LINE_RE = re.compile(r'^(?:Extracting|Building BSA|DDS Transform): \d+/\d+$')
|
||||
_PROCESSING_RE = re.compile(r'^Processing: \d+/\d+$')
|
||||
_PHASE_HEADER_RE = re.compile(r'^=== .+ ===$')
|
||||
_buffered_processing = None
|
||||
_last_phase_header: str = ''
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
for raw in self.process_manager.proc.stdout:
|
||||
if self.cancelled:
|
||||
self.cancel()
|
||||
break
|
||||
decoded = ansi_escape.sub(b'', raw).decode('utf-8', errors='replace').rstrip('\r\n')
|
||||
stripped = decoded.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
last_activity[0] = _time.monotonic()
|
||||
self._remember_stdout_line(decoded)
|
||||
if self._handle_engine_event(decoded):
|
||||
continue
|
||||
if stripped.startswith('{'):
|
||||
clf3_parser = getattr(self, '_clf3_parser', None)
|
||||
if clf3_parser and clf3_parser.process_line(stripped):
|
||||
state = clf3_parser.get_state()
|
||||
self.progress_updated.emit(state)
|
||||
msg = state.message
|
||||
if msg.startswith('Extracting ') and '(' in msg and state.phase_name == "Extracting":
|
||||
# Relabel as "Queuing" — the N/M counter tracks archive dispatch
|
||||
# to worker threads, not completion of decompression.
|
||||
self.output_received.emit(msg.replace('Extracting ', 'Queuing ', 1) + '\n')
|
||||
continue
|
||||
if _COUNTER_LINE_RE.match(stripped):
|
||||
continue
|
||||
if _PROCESSING_RE.match(stripped):
|
||||
_buffered_processing = stripped
|
||||
continue
|
||||
if _buffered_processing is not None:
|
||||
self.output_received.emit(_buffered_processing + '\n')
|
||||
_buffered_processing = None
|
||||
if _PHASE_HEADER_RE.match(stripped):
|
||||
if stripped == _last_phase_header:
|
||||
continue
|
||||
_last_phase_header = stripped
|
||||
self.output_received.emit(decoded + '\n')
|
||||
if _buffered_processing is not None:
|
||||
self.output_received.emit(_buffered_processing + '\n')
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import get_jackify_engine_path
|
||||
engine_path = get_jackify_engine_path()
|
||||
if not os.path.exists(engine_path):
|
||||
error_msg = f"Engine not found at: {engine_path}"
|
||||
logger.debug(f"DEBUG: {error_msg}")
|
||||
from jackify.backend.services.engine_invoker import (
|
||||
get_active_engine_id, get_engine_path, build_install_command,
|
||||
resolve_game_dir, resolve_game_location,
|
||||
)
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
engine_id = self.engine_id if self.engine_id else get_active_engine_id()
|
||||
engine_path = get_engine_path(engine_id)
|
||||
if not engine_path or not os.path.exists(engine_path):
|
||||
error_msg = f"Engine not found: {engine_id} ({engine_path or 'path unknown'})"
|
||||
logger.error(error_msg)
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
return
|
||||
if not os.access(engine_path, os.X_OK):
|
||||
error_msg = f"Engine is not executable: {engine_path}"
|
||||
logger.debug(f"DEBUG: {error_msg}")
|
||||
logger.error(error_msg)
|
||||
self.installation_finished.emit(False, error_msg)
|
||||
return
|
||||
logger.debug(f"DEBUG: Using engine at: {engine_path}")
|
||||
if self.install_mode == 'file':
|
||||
cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
|
||||
else:
|
||||
cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
|
||||
logger.debug(f"Using engine {engine_id} at: {engine_path}")
|
||||
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
|
||||
logger.debug(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
|
||||
logger.debug(f"DEBUG: modlist value being passed: '{self.modlist}'")
|
||||
game_dir = None
|
||||
clf3_mode = (engine_id == "clf3")
|
||||
if clf3_mode:
|
||||
location = resolve_game_location(self.game_type)
|
||||
if location:
|
||||
game_dir, game_store = location
|
||||
if game_store != 'steam':
|
||||
store_label = {'gog': 'GOG', 'epic': 'Epic Games'}.get(game_store, game_store)
|
||||
self.output_received.emit(
|
||||
f"[WARN] Game detected from {store_label}, not Steam. "
|
||||
"Most Wabbajack modlists require the Steam version. "
|
||||
"If the install fails with hash errors, a store version mismatch is likely the cause.\n"
|
||||
)
|
||||
else:
|
||||
logger.warning("CLF3: could not resolve game directory for game_type=%s", self.game_type)
|
||||
|
||||
if clf3_mode and self.clf3_cdn_url and not os.path.isfile(self.modlist):
|
||||
self.output_received.emit("Downloading modlist file via CLF3...\n")
|
||||
import subprocess as _sp
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
fetch_cmd = [engine_path, "fetch", self.clf3_cdn_url, "--output", self.modlist]
|
||||
logger.debug("CLF3 fetch command: %s", " ".join(fetch_cmd))
|
||||
fetch_env = get_clean_subprocess_env({})
|
||||
fetch_cwd = os.path.dirname(self.modlist) or os.path.expanduser("~")
|
||||
os.makedirs(fetch_cwd, exist_ok=True)
|
||||
fetch_result = _sp.run(fetch_cmd, capture_output=True, text=True, env=fetch_env, cwd=fetch_cwd)
|
||||
if fetch_result.returncode != 0:
|
||||
err = fetch_result.stderr.strip() or fetch_result.stdout.strip() or "unknown error"
|
||||
self.installation_finished.emit(False, f"Failed to download modlist file:\n\n{err}")
|
||||
return
|
||||
self.output_received.emit("Modlist file ready.\n")
|
||||
|
||||
cmd = build_install_command(
|
||||
engine_id=engine_id,
|
||||
engine_path=engine_path,
|
||||
wabbajack=self.modlist,
|
||||
install_dir=self.install_dir,
|
||||
downloads_dir=self.downloads_dir,
|
||||
game_dir=game_dir,
|
||||
install_mode=self.install_mode,
|
||||
debug=debug_mode,
|
||||
)
|
||||
logger.debug(f"FULL Engine command: {' '.join(cmd)}")
|
||||
logger.debug(f"modlist value being passed: '{self.modlist}'")
|
||||
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
|
||||
writeback_path = str(self.auth_service.get_token_writeback_path()) if self.auth_service else None
|
||||
env_vars = {'NEXUS_API_KEY': self.api_key}
|
||||
if self.oauth_info:
|
||||
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
if writeback_path:
|
||||
env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
if clf3_mode:
|
||||
env_vars = {'NEXUS_OAUTH_TOKEN': self.api_key}
|
||||
else:
|
||||
env_vars = {'NEXUS_API_KEY': self.api_key}
|
||||
if self.oauth_info:
|
||||
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
|
||||
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
|
||||
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
|
||||
if writeback_path:
|
||||
env_vars['JACKIFY_TOKEN_WRITEBACK'] = writeback_path
|
||||
env = get_clean_subprocess_env(env_vars)
|
||||
|
||||
# Install-time resource preflight: keep this visible in workflow output so
|
||||
@@ -306,172 +511,188 @@ class InstallerThread(QThread):
|
||||
logger.debug(f"Resource preflight check failed: {e}")
|
||||
|
||||
from jackify.backend.handlers.subprocess_utils import ProcessManager
|
||||
if clf3_mode:
|
||||
import time as _time
|
||||
from jackify.backend.handlers.progress_parser_clf3 import CLF3ProgressStateManager
|
||||
self._clf3_parser = CLF3ProgressStateManager()
|
||||
self._clf3_last_activity = [_time.monotonic()]
|
||||
else:
|
||||
self._clf3_parser = None
|
||||
self.process_manager = ProcessManager(cmd, env=env, text=False, separate_stderr=True, enable_stdin=True)
|
||||
stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
buffer = b''
|
||||
last_was_blank = False
|
||||
while True:
|
||||
if self.cancelled:
|
||||
self.cancel()
|
||||
break
|
||||
char = self.process_manager.read_stdout_char()
|
||||
if not char:
|
||||
break
|
||||
buffer += char
|
||||
while b'\n' in buffer or b'\r' in buffer:
|
||||
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
|
||||
line, buffer = buffer.split(b'\r', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
if len(self.api_key) >= 8:
|
||||
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing()
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.progress_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self.progress_received.emit(decoded + '\r')
|
||||
elif b'\n' in buffer:
|
||||
line, buffer = buffer.split(b'\n', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
if len(self.api_key) >= 8:
|
||||
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
|
||||
self._non_premium_info_sent = True
|
||||
self.non_premium_detected.emit()
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing()
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if self._handle_engine_event(decoded):
|
||||
last_was_blank = False
|
||||
continue
|
||||
|
||||
if clf3_mode:
|
||||
heartbeat_stop = threading.Event()
|
||||
heartbeat_thread = threading.Thread(
|
||||
target=self._run_clf3_heartbeat,
|
||||
args=(self._clf3_last_activity, heartbeat_stop),
|
||||
daemon=True,
|
||||
)
|
||||
heartbeat_thread.start()
|
||||
self._run_clf3_stdout_loop(self._clf3_last_activity)
|
||||
heartbeat_stop.set()
|
||||
heartbeat_thread.join(timeout=2.0)
|
||||
else:
|
||||
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
|
||||
buffer = b''
|
||||
last_was_blank = False
|
||||
while True:
|
||||
if self.cancelled:
|
||||
self.cancel()
|
||||
break
|
||||
char = self.process_manager.read_stdout_char()
|
||||
if not char:
|
||||
break
|
||||
buffer += char
|
||||
while b'\n' in buffer or b'\r' in buffer:
|
||||
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
|
||||
line, buffer = buffer.split(b'\r', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing(self.game_type)
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.progress_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self.progress_received.emit(decoded + '\r')
|
||||
elif b'\n' in buffer:
|
||||
line, buffer = buffer.split(b'\n', 1)
|
||||
line = ansi_escape.sub(b'', line)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
|
||||
is_premium_error, matched_pattern = (False, None) if decoded.strip().startswith('{') else is_non_premium_indicator(decoded)
|
||||
if not self._premium_signal_sent and is_premium_error:
|
||||
self._premium_signal_sent = True
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
|
||||
logger.warning("=" * 80)
|
||||
logger.warning(f"Matched pattern: '{matched_pattern}'")
|
||||
logger.warning(f"Triggering line: '{decoded.strip()}'")
|
||||
logger.warning("AUTHENTICATION DIAGNOSTICS:")
|
||||
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
|
||||
if self.api_key:
|
||||
logger.warning(f" Auth value length: {len(self.api_key)} chars")
|
||||
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
|
||||
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
|
||||
if auth_method == 'oauth' and self.auth_service:
|
||||
token_handler = self.auth_service.token_handler
|
||||
token_info = token_handler.get_token_info()
|
||||
logger.warning(" OAuth Token Status:")
|
||||
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
|
||||
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
|
||||
if 'expires_in_minutes' in token_info:
|
||||
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
|
||||
if 'refresh_token_age_days' in token_info:
|
||||
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
|
||||
if token_info.get('error'):
|
||||
logger.warning(f" Error: {token_info['error']}")
|
||||
logger.warning("Previous engine output (last 10 lines):")
|
||||
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
|
||||
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
|
||||
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
|
||||
logger.warning("=" * 80)
|
||||
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
|
||||
if not self._non_premium_info_sent and 'non-premium' in decoded.lower() and 'routing' in decoded.lower():
|
||||
self._non_premium_info_sent = True
|
||||
self.non_premium_detected.emit()
|
||||
self._engine_output_buffer.append(decoded.strip())
|
||||
if len(self._engine_output_buffer) > self._buffer_size:
|
||||
self._engine_output_buffer.pop(0)
|
||||
if self.last_error is None and is_cc_content_error(decoded):
|
||||
self.last_error = cc_content_missing(extract_cc_filename(decoded) or "")
|
||||
if self.last_error is None and is_creation_kit_missing_error(decoded):
|
||||
self.last_error = creation_kit_missing(self.game_type)
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if self.progress_state_manager:
|
||||
updated = self.progress_state_manager.process_line(decoded)
|
||||
if updated:
|
||||
progress_state = self.progress_state_manager.get_state()
|
||||
if progress_state.active_files and debug_mode:
|
||||
logger.debug(f"Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
|
||||
self.progress_updated.emit(progress_state)
|
||||
if self._handle_engine_event(decoded):
|
||||
last_was_blank = False
|
||||
continue
|
||||
self._remember_stdout_line(decoded)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
last_was_blank = False
|
||||
continue
|
||||
if decoded.strip() == '':
|
||||
if not last_was_blank:
|
||||
self.output_received.emit('\n')
|
||||
last_was_blank = True
|
||||
else:
|
||||
self.output_received.emit(decoded + '\n')
|
||||
last_was_blank = False
|
||||
if buffer:
|
||||
line = ansi_escape.sub(b'', buffer)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
self._install_progress_started = True
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
last_was_blank = False
|
||||
continue
|
||||
if decoded.strip() == '':
|
||||
if not last_was_blank:
|
||||
self.output_received.emit('\n')
|
||||
last_was_blank = True
|
||||
else:
|
||||
self.output_received.emit(decoded + '\n')
|
||||
last_was_blank = False
|
||||
if buffer:
|
||||
line = ansi_escape.sub(b'', buffer)
|
||||
decoded = line.decode('utf-8', errors='replace')
|
||||
if '[FILE_PROGRESS]' in decoded:
|
||||
parts = decoded.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
self.output_received.emit(parts[0].rstrip())
|
||||
else:
|
||||
self._remember_stdout_line(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
self.output_received.emit(decoded)
|
||||
stderr_thread.join(timeout=5)
|
||||
returncode = self.process_manager.wait()
|
||||
if writeback_path and self.auth_service:
|
||||
if writeback_path and self.auth_service and not clf3_mode:
|
||||
self.auth_service.apply_token_writeback(writeback_path)
|
||||
if self.process_manager.proc and self.process_manager.proc.stdout:
|
||||
try:
|
||||
@@ -479,7 +700,7 @@ class InstallerThread(QThread):
|
||||
if remaining:
|
||||
decoded_remaining = remaining.decode('utf-8', errors='replace')
|
||||
if decoded_remaining.strip():
|
||||
logger.debug(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}")
|
||||
logger.debug(f"Remaining output after process exit: {decoded_remaining[:500]}")
|
||||
if '[FILE_PROGRESS]' in decoded_remaining:
|
||||
parts = decoded_remaining.split('[FILE_PROGRESS]', 1)
|
||||
if parts[0].strip():
|
||||
@@ -487,7 +708,7 @@ class InstallerThread(QThread):
|
||||
else:
|
||||
self.output_received.emit(decoded_remaining)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error reading remaining output: {e}")
|
||||
logger.debug(f"Error reading remaining output: {e}")
|
||||
if returncode != 0 and not self.cancelled and self.last_error is None:
|
||||
stderr_tail = self._raw_stderr_lines[-10:] if self._raw_stderr_lines else []
|
||||
stdout_tail = self._raw_stdout_lines[-10:] if self._raw_stdout_lines else []
|
||||
|
||||
@@ -35,24 +35,45 @@ class InstallModlistOutputMixin:
|
||||
if not self._token_error_notified:
|
||||
self._token_error_notified = True
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
"Nexus Mods authentication has failed. This may be due to:\n\n"
|
||||
"• OAuth token expired and refresh failed\n"
|
||||
"• Nexus Premium required for this modlist\n"
|
||||
"• Network connectivity issues\n\n"
|
||||
"Please check the console output (Show Details) for more information.\n"
|
||||
"You may need to re-authorize in Settings."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
guidance = (
|
||||
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
|
||||
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
|
||||
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
|
||||
)
|
||||
engine_id = getattr(self, '_active_session_engine_id', None)
|
||||
if engine_id == 'clf3':
|
||||
MessageService.critical(
|
||||
self,
|
||||
"CLF3 Authentication Error",
|
||||
(
|
||||
"CLF3 could not authenticate with Nexus Mods.\n\n"
|
||||
"The CLF3 binary stores its own Nexus API key, which may have "
|
||||
"expired or been revoked. Your Jackify OAuth is unaffected.\n\n"
|
||||
"To fix: generate an API key at nexus.mods.com (account page), "
|
||||
"then run in a terminal:\n\n"
|
||||
" clf3 set-api-key YOUR_KEY\n\n"
|
||||
"OAuth support for CLF3 will be automatic after the next CLF3 release."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
guidance = (
|
||||
"\n[Jackify] CLF3 auth failed. CLF3 uses its own saved Nexus API key "
|
||||
"(not your Jackify OAuth). Run: clf3 set-api-key YOUR_KEY\n"
|
||||
)
|
||||
else:
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
"Nexus Mods authentication has failed. This may be due to:\n\n"
|
||||
"• OAuth token expired and refresh failed\n"
|
||||
"• Nexus Premium required for this modlist\n"
|
||||
"• Network connectivity issues\n\n"
|
||||
"Please check the console output (Show Details) for more information.\n"
|
||||
"You may need to re-authorize in Settings."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
guidance = (
|
||||
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
|
||||
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
|
||||
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
|
||||
)
|
||||
self._safe_append_text(guidance)
|
||||
if not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
@@ -219,12 +240,12 @@ class InstallModlistOutputMixin:
|
||||
except RuntimeError as e:
|
||||
if "already deleted" in str(e):
|
||||
if getattr(self, 'debug', False):
|
||||
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
logger.debug(f"Ignoring widget deletion error: {e}")
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
if getattr(self, 'debug', False):
|
||||
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
||||
logger.debug(f"Error updating file progress list: {e}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Post-install UI feedback management for InstallModlistScreen (Mixin)."""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
@@ -7,6 +8,8 @@ from PySide6.QtCore import QTimer
|
||||
|
||||
from jackify.shared.progress_models import InstallationProgress, InstallationPhase, FileProgress, OperationType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostInstallFeedbackMixin:
|
||||
"""Mixin providing post-install progress tracking and UI feedback for InstallModlistScreen."""
|
||||
@@ -180,6 +183,21 @@ class PostInstallFeedbackMixin:
|
||||
"bsa decompression:",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'final_config',
|
||||
'label': "Performing final tasks",
|
||||
'keywords': [
|
||||
"digicert",
|
||||
"certificate",
|
||||
"windows version",
|
||||
"windows 11",
|
||||
"mscoree",
|
||||
"tool compat",
|
||||
"nemesis setup",
|
||||
"applying tool",
|
||||
"symlink",
|
||||
],
|
||||
},
|
||||
{
|
||||
'id': 'config_finalize',
|
||||
'label': "Finalising Jackify configuration",
|
||||
@@ -200,15 +218,54 @@ class PostInstallFeedbackMixin:
|
||||
self._post_install_last_label = "Preparing Steam integration"
|
||||
total = max(1, self._post_install_total_steps)
|
||||
self._update_post_install_ui(self._post_install_last_label, 0, total)
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
def _handle_post_install_progress(self, message: str):
|
||||
"""Translate backend progress messages into collapsed-mode feedback."""
|
||||
if not self._post_install_active or not message:
|
||||
if not self._post_install_active and message:
|
||||
logger.debug("[PULSE] _handle_post_install_progress skipped - post_install_active=False, msg=%r", message[:60])
|
||||
return
|
||||
|
||||
text = message.strip()
|
||||
if not text:
|
||||
return
|
||||
|
||||
if any(kw in text.lower() for kw in ['wine', 'vcrun', 'dotnet', 'winetricks', 'component']):
|
||||
logger.debug("[PULSE] progress msg (wine-related): %r, step=%s, timer_active=%s",
|
||||
text[:80], getattr(self, '_post_install_current_step', 'N/A'),
|
||||
bool(getattr(self, '_component_install_timer', None) and
|
||||
getattr(self._component_install_timer, 'isActive', lambda: False)()))
|
||||
|
||||
if text.startswith("[NATIVE_DL] "):
|
||||
parts = text.split(None, 3)
|
||||
if len(parts) == 4:
|
||||
_, component, pct_str, speed_str = parts
|
||||
try:
|
||||
if not hasattr(self, '_native_component_progress'):
|
||||
self._native_component_progress = {}
|
||||
self._native_component_progress[component] = (float(pct_str), float(speed_str))
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
if text.startswith("[NATIVE_INSTALL] "):
|
||||
component = text[17:].strip()
|
||||
if hasattr(self, '_native_component_progress'):
|
||||
self._native_component_progress.pop(component, None)
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
self._current_native_component = component
|
||||
self._native_done_components = done + 1
|
||||
if hasattr(self, '_component_install_list') and self._component_install_list:
|
||||
total = len(self._component_install_list)
|
||||
remaining = max(0, total - (done + 1))
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
self.file_progress_list.update_files(
|
||||
[FileProgress(filename=f"Wine component: {component}{suffix}", operation=OperationType.UNKNOWN, percent=0.0)],
|
||||
current_phase=None,
|
||||
)
|
||||
return
|
||||
|
||||
normalized = text.lower()
|
||||
total = max(1, self._post_install_total_steps)
|
||||
matched = False
|
||||
@@ -239,15 +296,19 @@ class PostInstallFeedbackMixin:
|
||||
# Must remove summary widget so pulser items display immediately
|
||||
# (otherwise the 0.5s hold blocks update_files from adding items).
|
||||
if step['id'] == 'wine_components':
|
||||
logger.debug("[PULSE] wine_components step matched, msg=%r", text[:80])
|
||||
self.file_progress_list.clear_summary()
|
||||
self.progress_indicator.set_status(
|
||||
"Installing Wine components...",
|
||||
int((self._post_install_current_step / total) * 100)
|
||||
)
|
||||
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
|
||||
timer_active = hasattr(self, '_component_install_timer') and self._component_install_timer and self._component_install_timer.isActive()
|
||||
logger.debug("[PULSE] timer_active=%s", timer_active)
|
||||
if not timer_active:
|
||||
self._start_component_install_pulse()
|
||||
# Always check for component list updates (may come in later messages)
|
||||
comp_list = self._parse_wine_components_message(text)
|
||||
logger.debug("[PULSE] comp_list=%s", comp_list)
|
||||
if comp_list:
|
||||
self._start_component_install_pulse_with_components(comp_list)
|
||||
break
|
||||
@@ -364,16 +425,11 @@ class PostInstallFeedbackMixin:
|
||||
|
||||
def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None):
|
||||
"""Update progress indicator + activity summary for post-install steps."""
|
||||
# Use the label as the primary display, but include step info in Activity window
|
||||
display_label = label
|
||||
if detail:
|
||||
# Remove timestamp prefix from detail messages
|
||||
clean_detail = self._strip_timestamp_prefix(detail.strip())
|
||||
if clean_detail:
|
||||
# Filter out winetricks/protontricks internal messages (perl, wine paths, etc.)
|
||||
# These are implementation details, not user-facing status
|
||||
if any(keyword in clean_detail.lower() for keyword in ['perl:', 'wine:', '/usr/bin/', 'winetricks:', 'protontricks:']):
|
||||
# Use original label, ignore internal tool messages
|
||||
pass
|
||||
elif clean_detail.lower().startswith(label.lower()):
|
||||
display_label = clean_detail
|
||||
@@ -383,23 +439,24 @@ class PostInstallFeedbackMixin:
|
||||
step_clamped = max(0, min(step, total))
|
||||
overall_percent = (step_clamped / total) * 100.0
|
||||
|
||||
# CRITICAL: Ensure both displays use the SAME step counter
|
||||
# Progress banner uses phase_step/phase_max_steps from progress_state
|
||||
progress_state = InstallationProgress(
|
||||
phase=InstallationPhase.FINALIZE,
|
||||
phase_name=display_label, # This will show in progress banner
|
||||
phase_step=step_clamped, # This creates [step/total] in display_text
|
||||
phase_name=display_label,
|
||||
phase_step=step_clamped,
|
||||
phase_max_steps=total,
|
||||
overall_percent=overall_percent
|
||||
)
|
||||
self.progress_indicator.update_progress(progress_state)
|
||||
|
||||
# Activity window uses summary_info with the SAME step counter
|
||||
# When the component pulse timer is active, it owns the Activity window.
|
||||
# Writing a summary widget here would block the heartbeat's file items via the 0.5s hold.
|
||||
if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive():
|
||||
return
|
||||
|
||||
summary_info = {
|
||||
'current_step': step_clamped, # Must match phase_step above
|
||||
'max_steps': total, # Must match phase_max_steps above
|
||||
'current_step': step_clamped,
|
||||
'max_steps': total,
|
||||
}
|
||||
# Use the same label for consistency
|
||||
self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info)
|
||||
|
||||
def _end_post_install_feedback(self, success: bool):
|
||||
@@ -414,6 +471,8 @@ class PostInstallFeedbackMixin:
|
||||
self._update_post_install_ui(label, final_step, total)
|
||||
self._post_install_active = False
|
||||
self._post_install_last_label = label
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
def _parse_wine_components_message(self, text: str):
|
||||
"""Extract list of wine component names from backend status message, or None."""
|
||||
@@ -429,40 +488,60 @@ class PostInstallFeedbackMixin:
|
||||
|
||||
def _start_component_install_pulse(self):
|
||||
"""Start pulsing Activity item for Wine component installation."""
|
||||
logger.debug("[PULSE] _start_component_install_pulse called, post_install_active=%s", getattr(self, '_post_install_active', 'N/A'))
|
||||
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
|
||||
if not getattr(self, '_component_install_timer', None):
|
||||
self._component_install_timer = QTimer(self)
|
||||
self._component_install_timer.timeout.connect(self._component_install_heartbeat)
|
||||
self._component_install_timer.start(100)
|
||||
self._component_install_start_time = time.time()
|
||||
logger.debug("[PULSE] component install timer started")
|
||||
|
||||
def _start_component_install_pulse_with_components(self, components: list):
|
||||
"""Replace single item with one Activity entry per component, each with pulsing progress."""
|
||||
"""Show queued count; heartbeat switches to per-component display as each starts."""
|
||||
logger.debug("[PULSE] _start_component_install_pulse_with_components called, components=%s", components)
|
||||
self._component_install_list = components
|
||||
progresses = [
|
||||
FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
)
|
||||
for comp in components
|
||||
]
|
||||
self.file_progress_list.update_files(progresses, current_phase=None)
|
||||
self._native_total_components = len(components)
|
||||
self._native_done_components = 0
|
||||
self._current_native_component = None
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__wine_components__",
|
||||
f"Wine components: {len(components)} queued",
|
||||
0.0,
|
||||
)
|
||||
|
||||
def _component_install_heartbeat(self):
|
||||
"""Heartbeat to keep component install item(s) pulsing."""
|
||||
"""Heartbeat to keep component install item pulsing."""
|
||||
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
|
||||
logger.debug("[PULSE] heartbeat fired but no start_time, skipping")
|
||||
return
|
||||
current = getattr(self, '_current_native_component', None)
|
||||
if hasattr(self, '_component_install_list') and self._component_install_list:
|
||||
progresses = [
|
||||
FileProgress(
|
||||
filename=f"Wine component: {comp}",
|
||||
operation=OperationType.UNKNOWN,
|
||||
percent=0.0,
|
||||
total = len(self._component_install_list)
|
||||
done = getattr(self, '_native_done_components', 0)
|
||||
dl_state = getattr(self, '_native_component_progress', {})
|
||||
if current:
|
||||
if current in dl_state:
|
||||
pct, speed = dl_state[current]
|
||||
label = f"Wine component: {current} | {pct:.0f}% ({speed:.1f} MB/s)"
|
||||
op = OperationType.DOWNLOAD
|
||||
pct_val = pct
|
||||
else:
|
||||
remaining = max(0, total - done)
|
||||
suffix = f" ({remaining} remaining)" if remaining > 0 else ""
|
||||
label = f"Wine component: {current}{suffix}"
|
||||
op = OperationType.UNKNOWN
|
||||
pct_val = 0.0
|
||||
self.file_progress_list.update_files(
|
||||
[FileProgress(filename=label, operation=op, percent=pct_val)],
|
||||
current_phase=None,
|
||||
)
|
||||
else:
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__wine_components__",
|
||||
f"Wine components: {total} queued",
|
||||
0.0,
|
||||
)
|
||||
for comp in self._component_install_list
|
||||
]
|
||||
self.file_progress_list.update_files(progresses, current_phase=None)
|
||||
else:
|
||||
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
|
||||
|
||||
@@ -473,6 +552,8 @@ class PostInstallFeedbackMixin:
|
||||
self._component_install_timer = None
|
||||
if hasattr(self, '_component_install_list'):
|
||||
del self._component_install_list
|
||||
if hasattr(self, '_native_component_progress'):
|
||||
del self._native_component_progress
|
||||
|
||||
def _start_bsa_decompress_pulse(self):
|
||||
"""Keep the Activity window alive during long BSA decompression runs."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Progress and installation event handlers for InstallModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import QProcess
|
||||
from PySide6.QtCore import QProcess, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
@@ -187,7 +187,11 @@ class ProgressHandlersMixin:
|
||||
)
|
||||
is_extraction_phase = (
|
||||
progress_state.phase == InstallationPhase.EXTRACT or
|
||||
(progress_state.phase_name and 'extract' in progress_state.phase_name.lower())
|
||||
(progress_state.phase_name and (
|
||||
'extract' in progress_state.phase_name.lower()
|
||||
or 'queu' in progress_state.phase_name.lower()
|
||||
or 'decompress' in progress_state.phase_name.lower()
|
||||
))
|
||||
)
|
||||
|
||||
# Detect BSA building phase - check multiple indicators
|
||||
@@ -228,23 +232,29 @@ class ProgressHandlersMixin:
|
||||
self._bsa_hold_deadline = now_mono
|
||||
|
||||
if is_installation_phase:
|
||||
# During installation, we may have BSA building AND file installation happening
|
||||
# Show both: install summary + any active BSA files
|
||||
# Render loop handles smooth updates - just set target state
|
||||
|
||||
self._stop_clf3_decompress_pulse()
|
||||
current_step = progress_state.phase_step
|
||||
phase_lower = (progress_state.phase_name or "").lower()
|
||||
|
||||
if "bsa" in phase_lower:
|
||||
step_label = f"Building BSA: {current_step}/{progress_state.phase_max_steps}"
|
||||
elif "dds" in phase_lower or "texture" in phase_lower:
|
||||
step_label = f"Converting Textures: {current_step}/{progress_state.phase_max_steps}"
|
||||
elif "extract" in phase_lower or "queu" in phase_lower:
|
||||
step_label = f"Queuing Archives: {current_step}/{progress_state.phase_max_steps}"
|
||||
else:
|
||||
step_label = f"Installing Files: {current_step}/{progress_state.phase_max_steps}"
|
||||
|
||||
display_items = []
|
||||
|
||||
# Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size)
|
||||
if current_step > 0 or progress_state.phase_max_steps > 0:
|
||||
install_line = FileProgress(
|
||||
filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}",
|
||||
filename=step_label,
|
||||
operation=OperationType.INSTALL,
|
||||
percent=0.0,
|
||||
speed=-1.0
|
||||
)
|
||||
install_line._no_progress_bar = True # Flag to hide progress bar
|
||||
install_line._no_progress_bar = True
|
||||
display_items.append(install_line)
|
||||
|
||||
# Lines 2+: Show converting textures and BSA files
|
||||
@@ -293,25 +303,31 @@ class ProgressHandlersMixin:
|
||||
# Update target state (render loop handles smooth display)
|
||||
# Explicitly pass None for summary_info to clear any stale summary data
|
||||
if display_items:
|
||||
self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None)
|
||||
self.file_progress_list.update_files(display_items, current_phase=phase_label or "Installing", summary_info=None)
|
||||
return
|
||||
elif is_extraction_phase:
|
||||
# Show summary info for Extracting phase (step count)
|
||||
# Render loop handles smooth updates - just set target state
|
||||
# Explicitly pass empty list for file_progresses to clear any stale file list
|
||||
current_step = progress_state.phase_step
|
||||
summary_info = {
|
||||
'current_step': current_step,
|
||||
'max_steps': progress_state.phase_max_steps,
|
||||
}
|
||||
phase_display_name = phase_label or "Extracting"
|
||||
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
|
||||
phase_lower = (progress_state.phase_name or "").lower()
|
||||
if 'decompress' in phase_lower:
|
||||
label = progress_state.message or "Decompressing archives..."
|
||||
self._clf3_decompress_label = label
|
||||
if not getattr(self, '_clf3_decompress_timer', None):
|
||||
self._start_clf3_decompress_pulse(label)
|
||||
else:
|
||||
self._stop_clf3_decompress_pulse()
|
||||
current_step = progress_state.phase_step
|
||||
summary_info = {
|
||||
'current_step': current_step,
|
||||
'max_steps': progress_state.phase_max_steps,
|
||||
}
|
||||
phase_display_name = phase_label or "Queuing Archives"
|
||||
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
|
||||
return
|
||||
elif progress_state.active_files:
|
||||
self._stop_clf3_decompress_pulse()
|
||||
if self.debug:
|
||||
logger.debug(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
|
||||
logger.debug(f"Updating file progress list with {len(progress_state.active_files)} files")
|
||||
for fp in progress_state.active_files:
|
||||
logger.debug(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
||||
logger.debug(f" - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
|
||||
# Pass phase label to update header (e.g., "[Activity - Downloading]")
|
||||
# Explicitly clear summary_info when showing file list
|
||||
try:
|
||||
@@ -320,33 +336,64 @@ class ProgressHandlersMixin:
|
||||
# Widget was deleted - ignore to prevent coredump
|
||||
if "already deleted" in str(e):
|
||||
if self.debug:
|
||||
logger.debug(f"DEBUG: Ignoring widget deletion error: {e}")
|
||||
logger.debug(f"Ignoring widget deletion error: {e}")
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
# Catch any other exceptions to prevent coredump
|
||||
if self.debug:
|
||||
logger.debug(f"DEBUG: Error updating file progress list: {e}")
|
||||
logger.debug(f"Error updating file progress list: {e}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
else:
|
||||
# Show empty state so widget stays visible even when no files are active
|
||||
self._stop_clf3_decompress_pulse()
|
||||
# When there are no active files but phase progress counters are set (CLF3 streaming
|
||||
# pipeline: extraction runs inside the Downloading phase with no per-file events),
|
||||
# show a summary widget so the Activity tab is not blank.
|
||||
summary_info = None
|
||||
if progress_state.phase_step > 0 or progress_state.phase_max_steps > 0:
|
||||
summary_info = {
|
||||
'current_step': progress_state.phase_step,
|
||||
'max_steps': progress_state.phase_max_steps,
|
||||
}
|
||||
try:
|
||||
self.file_progress_list.update_files([], current_phase=phase_label)
|
||||
self.file_progress_list.update_files([], current_phase=phase_label, summary_info=summary_info)
|
||||
except RuntimeError as e:
|
||||
# Widget was deleted - ignore to prevent coredump
|
||||
if "already deleted" in str(e):
|
||||
return
|
||||
raise
|
||||
except Exception as e:
|
||||
# Catch any other exceptions to prevent coredump
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
logger.error(f"Error updating file progress list: {e}", exc_info=True)
|
||||
|
||||
def _start_clf3_decompress_pulse(self, label: str = "Decompressing archives..."):
|
||||
self._clf3_decompress_label = label
|
||||
if not getattr(self, '_clf3_decompress_timer', None):
|
||||
self._clf3_decompress_timer = QTimer(self)
|
||||
self._clf3_decompress_timer.timeout.connect(self._clf3_decompress_heartbeat)
|
||||
self._clf3_decompress_timer.start(250)
|
||||
|
||||
def _clf3_decompress_heartbeat(self):
|
||||
label = getattr(self, '_clf3_decompress_label', "Decompressing archives...")
|
||||
self.file_progress_list.update_or_add_item("__clf3_decompress__", label, 0.0)
|
||||
|
||||
def _stop_clf3_decompress_pulse(self):
|
||||
timer = getattr(self, '_clf3_decompress_timer', None)
|
||||
if timer:
|
||||
timer.stop()
|
||||
self._clf3_decompress_timer = None
|
||||
self._clf3_decompress_label = None
|
||||
|
||||
def on_installation_finished(self, success, message):
|
||||
"""Handle installation completion"""
|
||||
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
# R&D: Clear all progress displays when installation completes
|
||||
self._stop_clf3_decompress_pulse()
|
||||
logger.debug(f"on_installation_finished called with success={success}, message={message}")
|
||||
# installation_finished is emitted from inside run() via a queued connection,
|
||||
# so run() may still be executing its finally block when this slot fires.
|
||||
# Destroying the thread object while run() is still on the stack causes
|
||||
# "QThread: Destroyed while thread is still running" / SIGABRT.
|
||||
thread = getattr(self, 'install_thread', None)
|
||||
if thread and thread.isRunning():
|
||||
thread.wait(3000)
|
||||
self.progress_state_manager.reset()
|
||||
# Clear file list but keep CPU tracking running for configuration phase
|
||||
self.file_progress_list.list_widget.clear()
|
||||
@@ -382,6 +429,13 @@ class ProgressHandlersMixin:
|
||||
except Exception as _meta_err:
|
||||
logger.debug(f"Modlist meta write skipped: {_meta_err}")
|
||||
|
||||
try:
|
||||
from jackify.backend.utils.clf3_postinstall import inject_mo2_download_dir
|
||||
if thread and getattr(thread, 'install_dir', None) and getattr(thread, 'downloads_dir', None):
|
||||
inject_mo2_download_dir(thread.install_dir, thread.downloads_dir)
|
||||
except Exception as _ini_err:
|
||||
logger.debug(f"MO2 INI download_directory injection skipped: {_ini_err}")
|
||||
|
||||
logger.info(f"Installation succeeded: {message}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self._safe_append_text(f"\nSuccess: {message}")
|
||||
@@ -418,12 +472,12 @@ class ProgressHandlersMixin:
|
||||
self.process_finished(1, QProcess.CrashExit) # Simulate error
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
# Reset button states
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
logger.debug("Button states reset in process_finished")
|
||||
|
||||
# Stop manual download manager if it is still running (e.g. install failed mid-phase)
|
||||
if getattr(self, '_manual_dl_manager', None) is not None:
|
||||
|
||||
@@ -153,13 +153,13 @@ class ModlistSelectionMixin:
|
||||
if hasattr(self, 'current_game_type'):
|
||||
game_filter = game_type_to_human_friendly.get(self.current_game_type)
|
||||
|
||||
dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
|
||||
self._gallery_dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
|
||||
if cursor_overridden:
|
||||
QApplication.restoreOverrideCursor()
|
||||
cursor_overridden = False
|
||||
|
||||
if dlg.exec() == QDialog.Accepted and dlg.selected_metadata:
|
||||
metadata = dlg.selected_metadata
|
||||
if self._gallery_dlg.exec() == QDialog.Accepted and self._gallery_dlg.selected_metadata:
|
||||
metadata = self._gallery_dlg.selected_metadata
|
||||
self.modlist_btn.setText(metadata.title)
|
||||
self.selected_modlist_info = {
|
||||
'machine_url': metadata.namespacedName,
|
||||
@@ -170,6 +170,7 @@ class ModlistSelectionMixin:
|
||||
'nsfw': metadata.nsfw,
|
||||
'force_down': metadata.forceDown,
|
||||
'readme_url': metadata.links.readme if metadata.links else None,
|
||||
'download_url': metadata.links.download if metadata.links else None,
|
||||
}
|
||||
self.modlist_name_edit.setText(metadata.title)
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Steam shortcut conflict handling for InstallModlistScreen (Mixin)."""
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from jackify.frontends.gui.dialogs.existing_setup_dialog import prompt_existing_setup_dialog
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstallModlistShortcutDialogMixin:
|
||||
"""Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen."""
|
||||
@@ -62,7 +67,7 @@ class InstallModlistShortcutDialogMixin:
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
self._safe_append_text(f"Reusing existing Steam shortcut '{existing_name}'.")
|
||||
self.continue_configuration_after_automated_prefix(int(existing_appid), modlist_name, install_dir, None)
|
||||
self._reuse_shortcut_with_prefix_check(int(existing_appid), modlist_name, install_dir)
|
||||
return
|
||||
|
||||
if action == "new":
|
||||
@@ -79,6 +84,54 @@ class InstallModlistShortcutDialogMixin:
|
||||
self._safe_append_text("Shortcut creation cancelled by user")
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
|
||||
def _reuse_shortcut_with_prefix_check(self, appid: int, modlist_name: str, install_dir: str) -> None:
|
||||
"""Continue configuration after conflict resolution, creating the prefix if it is missing."""
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
svc = AutomatedPrefixService()
|
||||
if svc.get_prefix_path(appid):
|
||||
self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None)
|
||||
return
|
||||
|
||||
logger.info("Proton prefix missing for AppID %s; creating before configuration", appid)
|
||||
self._safe_append_text("[00:00:00] Proton prefix not found; creating prefix...")
|
||||
|
||||
class _PrefixCreateThread(QThread):
|
||||
finished = Signal(bool)
|
||||
|
||||
def __init__(self, appid):
|
||||
super().__init__()
|
||||
self._appid = appid
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
|
||||
ok = AutomatedPrefixService().create_prefix_with_proton_wrapper(self._appid)
|
||||
self.finished.emit(ok)
|
||||
except Exception as exc:
|
||||
logger.error("Prefix creation thread failed: %s", exc)
|
||||
self.finished.emit(False)
|
||||
|
||||
_thread = _PrefixCreateThread(appid)
|
||||
|
||||
def _on_done(success):
|
||||
_thread.deleteLater()
|
||||
if not success:
|
||||
logger.error("Failed to create Proton prefix for AppID %s", appid)
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Prefix Creation Failed",
|
||||
"Jackify could not create the Proton prefix.\n\n"
|
||||
"Try launching the modlist from Steam once to initialise it, then run Configure.",
|
||||
)
|
||||
self._restore_controls_after_shortcut_dialog_abort()
|
||||
return
|
||||
self._safe_append_text("[00:00:00] Proton prefix created.")
|
||||
self.continue_configuration_after_automated_prefix(appid, modlist_name, install_dir, None)
|
||||
|
||||
_thread.finished.connect(_on_done)
|
||||
self._prefix_create_thread = _thread
|
||||
_thread.start()
|
||||
|
||||
def retry_automated_workflow_with_new_name(self, new_name):
|
||||
"""Retry the automated workflow with a new shortcut name."""
|
||||
self.modlist_name_edit.setText(new_name)
|
||||
|
||||
@@ -33,13 +33,13 @@ class TTWIntegrationMixin:
|
||||
|
||||
# Check 3: TTW must not already be installed
|
||||
if self._detect_existing_ttw(install_dir):
|
||||
logger.debug("DEBUG: TTW already installed, skipping prompt")
|
||||
logger.debug("TTW already installed, skipping prompt")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error checking TTW eligibility: {e}")
|
||||
logger.debug(f"Error checking TTW eligibility: {e}")
|
||||
return False
|
||||
|
||||
def _detect_existing_ttw(self, install_dir: str) -> bool:
|
||||
@@ -73,15 +73,15 @@ class TTWIntegrationMixin:
|
||||
# Verify it has actual TTW content by checking for the main ESM
|
||||
ttw_esm = folder / "TaleOfTwoWastelands.esm"
|
||||
if ttw_esm.exists():
|
||||
logger.debug(f"DEBUG: Found existing TTW installation: {folder.name}")
|
||||
logger.debug(f"Found existing TTW installation: {folder.name}")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
|
||||
logger.debug(f"Found TTW folder but no ESM, skipping: {folder.name}")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error detecting existing TTW: {e}")
|
||||
logger.debug(f"Error detecting existing TTW: {e}")
|
||||
return False # Assume not installed on error
|
||||
|
||||
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
|
||||
@@ -173,40 +173,34 @@ class TTWIntegrationMixin:
|
||||
vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir)
|
||||
|
||||
if vnv_automation_running:
|
||||
# Store success dialog params for later (after VNV automation completes)
|
||||
self._pending_success_dialog_params = {
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'install',
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': False, # TTW installs don't have ENB
|
||||
'ttw_version': ttw_version if 'ttw_version' in locals() else None
|
||||
'enb_detected': False,
|
||||
'install_dir': getattr(self, '_ttw_install_dir', '') or '',
|
||||
'game_type': 'falloutnv',
|
||||
'appid': getattr(self, '_current_appid', '') or '',
|
||||
}
|
||||
# Keep post-install feedback active during VNV automation
|
||||
# Don't show success dialog yet - will be shown in _on_vnv_complete
|
||||
return
|
||||
|
||||
# No VNV automation - end post-install feedback now
|
||||
self._end_post_install_feedback(True)
|
||||
|
||||
# Clear Activity window before showing success dialog
|
||||
self.file_progress_list.clear()
|
||||
|
||||
# Show enhanced success dialog
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="install",
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=getattr(self, '_ttw_install_dir', '') or '',
|
||||
game_type='falloutnv',
|
||||
appid=getattr(self, '_current_appid', '') or '',
|
||||
success_params={
|
||||
'modlist_name': modlist_name,
|
||||
'workflow_type': 'install',
|
||||
'time_taken': time_str,
|
||||
'game_name': game_name,
|
||||
'enb_detected': False,
|
||||
},
|
||||
)
|
||||
|
||||
# Add TTW installation info to dialog if possible
|
||||
if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'):
|
||||
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
|
||||
|
||||
success_dialog.show()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"ERROR: Failed to show final success dialog: {e}")
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
@@ -104,7 +104,7 @@ class InstallModlistUISetupMixin:
|
||||
header_layout = QVBoxLayout()
|
||||
header_layout.setSpacing(1) # Reduce spacing between title and description
|
||||
# Title (no logo)
|
||||
title = QLabel("<b>Install a Modlist (Automated)</b>")
|
||||
title = QLabel("<b>Install a Modlist</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;")
|
||||
title.setAlignment(Qt.AlignHCenter)
|
||||
title.setMaximumHeight(30) # Force compact height
|
||||
@@ -252,6 +252,7 @@ class InstallModlistUISetupMixin:
|
||||
# Update nexus status on init
|
||||
self._update_nexus_status()
|
||||
|
||||
|
||||
# --- Resolution Dropdown ---
|
||||
resolution_label = QLabel("Resolution:")
|
||||
self.resolution_combo = QComboBox()
|
||||
@@ -293,7 +294,7 @@ class InstallModlistUISetupMixin:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
logger.debug(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
logger.debug(f"Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
|
||||
elif is_steam_deck:
|
||||
# Set default to 1280x800 (Steam Deck)
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
@@ -304,23 +305,44 @@ class InstallModlistUISetupMixin:
|
||||
# Otherwise, default is 'Leave unchanged' (index 0)
|
||||
form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
# Horizontal layout for resolution dropdown and auto-restart checkbox
|
||||
# Horizontal layout for resolution dropdown and right-side checkboxes
|
||||
resolution_and_restart_layout = QHBoxLayout()
|
||||
resolution_and_restart_layout.setSpacing(12)
|
||||
|
||||
# Resolution dropdown (made smaller)
|
||||
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
|
||||
|
||||
self.resolution_combo.setMaximumWidth(280)
|
||||
resolution_and_restart_layout.addWidget(self.resolution_combo)
|
||||
|
||||
# Add stretch to push checkbox to the right
|
||||
|
||||
resolution_and_restart_layout.addStretch()
|
||||
|
||||
# Auto-accept Steam restart checkbox (right-aligned)
|
||||
|
||||
right_checks_layout = QVBoxLayout()
|
||||
right_checks_layout.setSpacing(4)
|
||||
right_checks_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
|
||||
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
|
||||
self.auto_restart_checkbox.setChecked(False)
|
||||
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation")
|
||||
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
|
||||
|
||||
right_checks_layout.addWidget(self.auto_restart_checkbox)
|
||||
|
||||
engine_row = QHBoxLayout()
|
||||
engine_row.setSpacing(4)
|
||||
engine_row.setContentsMargins(0, 0, 0, 0)
|
||||
self.engine_checkbox = QCheckBox("Use Experimental Engine")
|
||||
self.engine_checkbox.setToolTip("Use CLF3 (SulfurNitride) as the install engine instead of jackify-engine")
|
||||
self.engine_checkbox.toggled.connect(self._on_engine_checkbox_toggled)
|
||||
engine_row.addWidget(self.engine_checkbox)
|
||||
engine_whats_this = QLabel('<a href="https://github.com/Omni-guides/Jackify/wiki/Install-Engines" style="color: #6fa8dc; font-size: 11px;">(what\'s this?)</a>')
|
||||
engine_whats_this.setOpenExternalLinks(False)
|
||||
engine_whats_this.linkActivated.connect(self._open_url_safe)
|
||||
engine_row.addWidget(engine_whats_this)
|
||||
engine_row.addStretch()
|
||||
engine_row_widget = QWidget()
|
||||
engine_row_widget.setLayout(engine_row)
|
||||
self._engine_row_widget = engine_row_widget
|
||||
right_checks_layout.addWidget(engine_row_widget)
|
||||
self._init_engine_checkbox()
|
||||
|
||||
resolution_and_restart_layout.addLayout(right_checks_layout)
|
||||
|
||||
form_grid.addLayout(resolution_and_restart_layout, 5, 1)
|
||||
form_section_widget = QWidget()
|
||||
form_section_widget.setLayout(form_grid)
|
||||
@@ -514,3 +536,13 @@ class InstallModlistUISetupMixin:
|
||||
|
||||
# Now collect all actionable controls after UI is fully built
|
||||
self._collect_actionable_controls()
|
||||
|
||||
def _init_engine_checkbox(self) -> None:
|
||||
from jackify.backend.services.tool_registry import get_active_engine_id
|
||||
self._engine_row_widget.setVisible(True)
|
||||
self.engine_checkbox.blockSignals(True)
|
||||
self.engine_checkbox.setChecked(get_active_engine_id() == "clf3")
|
||||
self.engine_checkbox.blockSignals(False)
|
||||
|
||||
def _on_engine_checkbox_toggled(self, checked: bool) -> None:
|
||||
pass # state is read at install time; persistent default lives in Settings
|
||||
|
||||
@@ -50,23 +50,15 @@ class VNVAutomationMixin:
|
||||
if hasattr(self, '_pending_success_dialog_params'):
|
||||
params = self._pending_success_dialog_params
|
||||
del self._pending_success_dialog_params
|
||||
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from ..dialogs import SuccessDialog
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=params['modlist_name'],
|
||||
workflow_type="install",
|
||||
time_taken=params['time_taken'],
|
||||
game_name=params['game_name'],
|
||||
parent=self,
|
||||
self._run_verifier_then_show_success(
|
||||
install_dir=params.get('install_dir', ''),
|
||||
game_type=params.get('game_type', 'unknown'),
|
||||
appid=params.get('appid', ''),
|
||||
success_params={
|
||||
'modlist_name': params['modlist_name'],
|
||||
'workflow_type': params.get('workflow_type', 'install'),
|
||||
'time_taken': params['time_taken'],
|
||||
'game_name': params.get('game_name'),
|
||||
'enb_detected': params.get('enb_detected', False),
|
||||
},
|
||||
)
|
||||
success_dialog.show()
|
||||
|
||||
if params.get('enb_detected'):
|
||||
try:
|
||||
from ..dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Execution workflow methods for InstallModlistScreen (Mixin)."""
|
||||
|
||||
from pathlib import Path
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QMessageBox
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -14,15 +15,107 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class InstallWorkflowExecutionMixin:
|
||||
"""Mixin containing install-run and manual-download dialog execution methods."""
|
||||
|
||||
def _session_engine_id(self) -> str:
|
||||
"""Return the engine to use for this install based on the install screen checkbox."""
|
||||
return "clf3" if self.engine_checkbox.isChecked() else "jackify-engine"
|
||||
|
||||
def _ensure_clf3_installed(self) -> bool:
|
||||
"""
|
||||
If CLF3 is already installed, return True immediately.
|
||||
If not, show a download dialog and install it, returning True on success.
|
||||
"""
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
status = ToolRegistry().get_status("clf3")
|
||||
if status and status.installed:
|
||||
return True
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"CLF3 Not Installed",
|
||||
"The experimental engine (CLF3) is not installed.\n\n"
|
||||
"Download and install it now to continue?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return False
|
||||
|
||||
return self._download_clf3_with_dialog()
|
||||
|
||||
def _download_clf3_with_dialog(self) -> bool:
|
||||
"""Download CLF3 in a modal dialog with a pulsing progress bar. Returns True on success."""
|
||||
|
||||
class _Clf3InstallThread(QThread):
|
||||
finished_signal = Signal(bool, str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ok, msg = ToolRegistry().install("clf3")
|
||||
self.finished_signal.emit(ok, msg)
|
||||
except Exception as exc:
|
||||
self.finished_signal.emit(False, str(exc))
|
||||
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Installing CLF3")
|
||||
dlg.setModal(True)
|
||||
dlg.setMinimumWidth(360)
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setSpacing(12)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
|
||||
label = QLabel("Downloading CLF3 (experimental engine)...")
|
||||
label.setStyleSheet("color: #ccc; font-size: 13px;")
|
||||
layout.addWidget(label)
|
||||
|
||||
bar = QProgressBar()
|
||||
bar.setRange(0, 0)
|
||||
bar.setTextVisible(False)
|
||||
bar.setFixedHeight(6)
|
||||
bar.setStyleSheet(f"""
|
||||
QProgressBar {{ border: none; background-color: #333; border-radius: 3px; }}
|
||||
QProgressBar::chunk {{ background-color: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}
|
||||
""")
|
||||
layout.addWidget(bar)
|
||||
|
||||
result = [False, ""]
|
||||
|
||||
thread = _Clf3InstallThread()
|
||||
|
||||
def on_done(ok: bool, msg: str):
|
||||
result[0] = ok
|
||||
result[1] = msg
|
||||
dlg.accept()
|
||||
|
||||
thread.finished_signal.connect(on_done)
|
||||
thread.start()
|
||||
dlg.exec()
|
||||
thread.wait(5000)
|
||||
|
||||
if not result[0]:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"CLF3 Install Failed",
|
||||
f"Could not install CLF3:\n\n{result[1]}",
|
||||
)
|
||||
return False
|
||||
|
||||
label.setText("CLF3 installed.")
|
||||
return True
|
||||
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
# Disable controls before processEvents to prevent double-click re-entry
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
# Immediately show "Initialising" status to provide feedback
|
||||
self.progress_indicator.set_status("Initialising...", 0)
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.processEvents() # Force UI update
|
||||
QApplication.processEvents()
|
||||
|
||||
# Reload config to pick up any settings changes made in Settings dialog
|
||||
self.config_handler.reload_config()
|
||||
@@ -30,12 +123,18 @@ class InstallWorkflowExecutionMixin:
|
||||
# Check protontricks before proceeding
|
||||
if not self._check_protontricks():
|
||||
self.progress_indicator.reset()
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# Disable all controls during installation (except Cancel)
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
try:
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
downloads_dir = self.downloads_dir_edit.text().strip()
|
||||
|
||||
if self._session_engine_id() == "clf3" and not self._ensure_clf3_installed():
|
||||
self.progress_indicator.reset()
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
tab_index = self.source_tabs.currentIndex()
|
||||
install_mode = 'online'
|
||||
if tab_index == 1: # .wabbajack File tab
|
||||
@@ -70,8 +169,22 @@ class InstallWorkflowExecutionMixin:
|
||||
|
||||
# CRITICAL: Use machine_url, NOT button text
|
||||
modlist = machine_url
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
downloads_dir = self.downloads_dir_edit.text().strip()
|
||||
|
||||
if self._session_engine_id() == "clf3":
|
||||
download_url = self.selected_modlist_info.get('download_url')
|
||||
if not download_url:
|
||||
self._abort_with_message(
|
||||
"warning",
|
||||
"Download URL Unavailable",
|
||||
"Could not determine the download URL for this modlist.\n\n"
|
||||
"Use the '.wabbajack File' tab to select a local file instead."
|
||||
)
|
||||
return
|
||||
from jackify.shared.paths import get_jackify_downloads_dir
|
||||
list_id = machine_url.split('/')[-1] if '/' in machine_url else machine_url
|
||||
wabbajack_local = str(get_jackify_downloads_dir() / f"{list_id}.wabbajack")
|
||||
modlist = wabbajack_local
|
||||
self._clf3_cdn_url = download_url
|
||||
|
||||
# Get authentication token (OAuth or API key) with automatic refresh
|
||||
api_key, oauth_info = self.auth_service.get_auth_for_engine()
|
||||
@@ -92,8 +205,6 @@ class InstallWorkflowExecutionMixin:
|
||||
logger.info("Authentication Status at Install Start")
|
||||
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
|
||||
logger.info(f"Token length: {len(api_key)} chars")
|
||||
if len(api_key) >= 8:
|
||||
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
|
||||
|
||||
if auth_method == 'oauth':
|
||||
token_handler = self.auth_service.token_handler
|
||||
@@ -169,14 +280,14 @@ class InstallWorkflowExecutionMixin:
|
||||
self._current_resolution = raw_resolution
|
||||
success = self.resolution_service.save_resolution(resolution)
|
||||
if success:
|
||||
logger.debug(f"DEBUG: Resolution saved successfully: {resolution}")
|
||||
logger.debug(f"Resolution saved successfully: {resolution}")
|
||||
else:
|
||||
logger.debug("DEBUG: Failed to save resolution")
|
||||
logger.debug("Failed to save resolution")
|
||||
else:
|
||||
# Clear saved resolution if "Leave unchanged" is selected
|
||||
if self.resolution_service.has_saved_resolution():
|
||||
self.resolution_service.clear_saved_resolution()
|
||||
logger.debug("DEBUG: Saved resolution cleared")
|
||||
logger.debug("Saved resolution cleared")
|
||||
|
||||
ensure_flatpak_steam_filesystem_access(Path(install_dir))
|
||||
|
||||
@@ -227,7 +338,7 @@ class InstallWorkflowExecutionMixin:
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
readme_url = self.selected_modlist_info.get('readme_url')
|
||||
game_name = self.selected_modlist_info.get('game', '')
|
||||
logger.debug(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
logger.debug(f"Detected game_name from selected_modlist_info: '{game_name}'")
|
||||
|
||||
# Map game name to game type
|
||||
game_mapping = {
|
||||
@@ -247,12 +358,12 @@ class InstallWorkflowExecutionMixin:
|
||||
"baldur's gate 3": 'bg3',
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
logger.debug(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
logger.debug(f"Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
if not game_type:
|
||||
game_type = 'unknown'
|
||||
logger.debug(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
|
||||
logger.debug(f"Game type not found in mapping, setting to 'unknown'")
|
||||
else:
|
||||
logger.debug(f"DEBUG: No selected_modlist_info found")
|
||||
logger.debug(f"No selected_modlist_info found")
|
||||
game_type = 'unknown'
|
||||
|
||||
# Store game type and name for later use
|
||||
@@ -260,13 +371,13 @@ class InstallWorkflowExecutionMixin:
|
||||
self._current_game_name = game_name
|
||||
|
||||
# Check if game is supported
|
||||
logger.debug(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
logger.debug(f"Checking if game_type '{game_type}' is supported")
|
||||
logger.debug(f"game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
logger.debug(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
logger.debug(f"is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
if game_type and not is_supported:
|
||||
logger.debug(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
|
||||
logger.debug(f"Game '{game_type}' is not supported, showing dialog")
|
||||
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
|
||||
dialog = UnsupportedGameDialog(self, game_name)
|
||||
if not dialog.show_dialog(self, game_name):
|
||||
@@ -366,7 +477,8 @@ class InstallWorkflowExecutionMixin:
|
||||
self._record_pre_update_ini_snapshot(install_real)
|
||||
|
||||
# CRITICAL: Final safety check - ensure online modlists use machine_url
|
||||
if install_mode == 'online':
|
||||
# CLF3 is exempt: it uses a pre-resolved local .wabbajack path, not machine_url
|
||||
if install_mode == 'online' and self._session_engine_id() != "clf3":
|
||||
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
|
||||
expected_machine_url = self.selected_modlist_info.get('machine_url')
|
||||
if expected_machine_url:
|
||||
@@ -393,27 +505,31 @@ class InstallWorkflowExecutionMixin:
|
||||
readme_url = readme_url.replace("/main/", "/blob/main/")
|
||||
readme_url = readme_url.replace("/master/", "/blob/master/")
|
||||
logger.info(f"Opening modlist readme: {readme_url}")
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "LD_PRELOAD")}
|
||||
subprocess.Popen(["xdg-open", readme_url], env=clean_env)
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
subprocess.Popen(["xdg-open", readme_url], env=clean_env, start_new_session=True)
|
||||
self._safe_append_text(
|
||||
"Modlist readme opened in your browser. "
|
||||
"Check it for any manual post-install steps before launching the game."
|
||||
)
|
||||
|
||||
logger.debug(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
|
||||
logger.debug(f"Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}")
|
||||
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
# Re-enable all controls after exception
|
||||
logger.error("Unexpected error in validate_and_start_install", exc_info=True)
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug(f"DEBUG: Controls re-enabled in exception handler")
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
from ..services.message_service import MessageService
|
||||
MessageService.critical(
|
||||
self,
|
||||
"Installation Error",
|
||||
f"Could not start the installation.\n\n{e}\n\n"
|
||||
f"Details were written to the Jackify log at:\n{get_jackify_logs_dir()}",
|
||||
)
|
||||
|
||||
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
|
||||
logger.debug('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
|
||||
|
||||
# Rotate log file at start of each workflow run (keep 5 backups)
|
||||
from jackify.backend.handlers.logging_handler import LoggingHandler
|
||||
@@ -434,10 +550,15 @@ class InstallWorkflowExecutionMixin:
|
||||
self._downloads_dir = downloads_dir
|
||||
self.install_thread = InstallerThread(
|
||||
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
|
||||
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
|
||||
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
|
||||
oauth_info=oauth_info, # Pass OAuth state for auto-refresh
|
||||
progress_state_manager=self.progress_state_manager,
|
||||
auth_service=self.auth_service,
|
||||
oauth_info=oauth_info,
|
||||
game_type=getattr(self, '_current_game_type', None),
|
||||
clf3_cdn_url=getattr(self, '_clf3_cdn_url', None),
|
||||
engine_id=self._session_engine_id(),
|
||||
)
|
||||
self._clf3_cdn_url = None
|
||||
self._active_session_engine_id = self._session_engine_id()
|
||||
self.install_thread.output_received.connect(self.on_installation_output)
|
||||
self.install_thread.progress_received.connect(self.on_installation_progress)
|
||||
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
|
||||
|
||||
@@ -116,10 +116,13 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
def _open_url_safe(self, url):
|
||||
"""Safely open URL via subprocess to avoid Qt library clashes inside the AppImage runtime"""
|
||||
import subprocess
|
||||
import os
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
try:
|
||||
subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(['xdg-open', url], env=clean_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not open URL {url}: {e}")
|
||||
logger.warning(f"Could not open URL {url}: {e}")
|
||||
|
||||
def _load_saved_parent_directories(self):
|
||||
"""No-op: do not pre-populate install/download directories from saved values."""
|
||||
@@ -136,16 +139,16 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
if saved_install_parent:
|
||||
suggested_install_dir = os.path.join(saved_install_parent, modlist_name)
|
||||
self.install_dir_edit.setText(suggested_install_dir)
|
||||
logger.debug(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}")
|
||||
logger.debug(f"Updated install directory suggestion: {suggested_install_dir}")
|
||||
|
||||
# Update download directory suggestion
|
||||
saved_download_parent = self.config_handler.get_default_download_parent_dir()
|
||||
if saved_download_parent:
|
||||
suggested_download_dir = os.path.join(saved_download_parent, "Downloads")
|
||||
logger.debug(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}")
|
||||
logger.debug(f"Updated download directory suggestion: {suggested_download_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error updating directory suggestions: {e}")
|
||||
logger.debug(f"Error updating directory suggestions: {e}")
|
||||
|
||||
def _save_parent_directories(self, install_dir, downloads_dir):
|
||||
"""Removed automatic saving - user should set defaults in settings"""
|
||||
@@ -341,14 +344,25 @@ class InstallTTWScreen(ThreadLifecycleMixin, ScreenBackMixin, TTWUISetupMixin, T
|
||||
font-size: 13px;
|
||||
""")
|
||||
|
||||
# Park all threads first (disconnects signals), then send cooperative cancel.
|
||||
self._park_all_threads()
|
||||
# Send process-group kill first so the subprocess tree dies before we
|
||||
# disconnect signals or wait on the thread.
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
try:
|
||||
self.install_thread.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Park threads (disconnects signals so no callbacks fire on the dying widget).
|
||||
self._park_all_threads()
|
||||
|
||||
# Wait up to 5 s for the thread to exit after the kill signal.
|
||||
if hasattr(self, 'install_thread') and self.install_thread:
|
||||
try:
|
||||
self.install_thread.wait(5000)
|
||||
except Exception:
|
||||
pass
|
||||
self.install_thread = None
|
||||
|
||||
# Cleanup any remaining processes
|
||||
self.cleanup_processes()
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ class TTWIntegrationMixin:
|
||||
self.status_banner.setText("TTW integration completed successfully!")
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: #28a745;
|
||||
background-color: #1a3040;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
|
||||
@@ -51,7 +51,7 @@ class TTWLifecycleMixin:
|
||||
def showEvent(self, event):
|
||||
"""Called when the widget becomes visible"""
|
||||
super().showEvent(event)
|
||||
logger.debug(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}")
|
||||
logger.debug(f"TTW showEvent - integration_mode={self._integration_mode}")
|
||||
|
||||
# Check TTW_Linux_Installer status asynchronously (non-blocking) after screen opens
|
||||
from PySide6.QtCore import QTimer
|
||||
@@ -73,7 +73,7 @@ class TTWLifecycleMixin:
|
||||
is_steamdeck = True
|
||||
|
||||
if is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, keeping expanded")
|
||||
logger.debug("Steam Deck detected, keeping expanded")
|
||||
# Force expanded state and hide checkbox
|
||||
if self.show_details_checkbox.isVisible():
|
||||
self.show_details_checkbox.setVisible(False)
|
||||
@@ -84,27 +84,27 @@ class TTWLifecycleMixin:
|
||||
self.console.setMaximumHeight(16777215) # Remove height limit
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Steam Deck check exception: {e}")
|
||||
logger.debug(f"Steam Deck check exception: {e}")
|
||||
pass
|
||||
logger.debug(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}")
|
||||
logger.debug(f"Checkbox checked={self.show_details_checkbox.isChecked()}")
|
||||
if self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.blockSignals(True)
|
||||
self.show_details_checkbox.setChecked(False)
|
||||
self.show_details_checkbox.blockSignals(False)
|
||||
|
||||
logger.debug("DEBUG: Calling _toggle_console_visibility(Unchecked)")
|
||||
logger.debug("Calling _toggle_console_visibility(Unchecked)")
|
||||
self._toggle_console_visibility(_Qt.Unchecked)
|
||||
# Force the window to compact height to eliminate bottom whitespace
|
||||
main_window = self.window()
|
||||
logger.debug(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}")
|
||||
logger.debug(f"main_window={main_window}, size={main_window.size() if main_window else None}")
|
||||
if main_window:
|
||||
# Save original geometry once
|
||||
if self._saved_geometry is None:
|
||||
self._saved_geometry = main_window.geometry()
|
||||
logger.debug(f"DEBUG: Saved geometry: {self._saved_geometry}")
|
||||
logger.debug(f"Saved geometry: {self._saved_geometry}")
|
||||
if self._saved_min_size is None:
|
||||
self._saved_min_size = main_window.minimumSize()
|
||||
logger.debug(f"DEBUG: Saved min size: {self._saved_min_size}")
|
||||
logger.debug(f"Saved min size: {self._saved_min_size}")
|
||||
|
||||
# Fixed compact size - same as menu screens
|
||||
from PySide6.QtCore import QSize
|
||||
@@ -120,14 +120,14 @@ class TTWLifecycleMixin:
|
||||
# Notify parent to ensure compact
|
||||
try:
|
||||
self.resize_request.emit('collapse')
|
||||
logger.debug("DEBUG: Emitted resize_request collapse signal")
|
||||
logger.debug("Emitted resize_request collapse signal")
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception emitting signal: {e}")
|
||||
logger.debug(f"Exception emitting signal: {e}")
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: showEvent exception: {e}")
|
||||
logger.debug(f"showEvent exception: {e}")
|
||||
import traceback
|
||||
logger.debug(f"DEBUG: {traceback.format_exc()}")
|
||||
logger.debug(f"{traceback.format_exc()}")
|
||||
pass
|
||||
|
||||
def hideEvent(self, event):
|
||||
@@ -141,8 +141,8 @@ class TTWLifecycleMixin:
|
||||
# Important when console is expanded
|
||||
main_window.setMaximumSize(QSize(16777215, 16777215))
|
||||
main_window.setMinimumSize(QSize(0, 0))
|
||||
logger.debug("DEBUG: Install TTW hideEvent - cleared window size constraints")
|
||||
logger.debug("Install TTW hideEvent - cleared window size constraints")
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: hideEvent exception: {e}")
|
||||
logger.debug(f"hideEvent exception: {e}")
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,291 +1,119 @@
|
||||
"""TTW installer requirements and validation for InstallTTWScreen (Mixin)."""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from pathlib import Path
|
||||
import os
|
||||
import requests
|
||||
import traceback
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps TTW-required game display names to Jackify game_type strings.
|
||||
_TTW_GAMES = {
|
||||
'Fallout 3': 'fallout3',
|
||||
'Fallout New Vegas': 'falloutnv',
|
||||
}
|
||||
|
||||
|
||||
def _detect_ttw_games() -> Dict[str, Tuple[Path, str]]:
|
||||
"""
|
||||
Return a dict of {display_name: (path, store)} for each TTW-required game found.
|
||||
Tries Steam appmanifests first, then Heroic (GOG/Epic).
|
||||
"""
|
||||
from jackify.backend.handlers.vanilla_game_finder import VanillaGameFinder
|
||||
finder = VanillaGameFinder()
|
||||
results = {}
|
||||
for display_name, game_type in _TTW_GAMES.items():
|
||||
location = finder.find(game_type)
|
||||
if location:
|
||||
results[display_name] = location
|
||||
return results
|
||||
|
||||
|
||||
class TTWRequirementsMixin:
|
||||
"""Mixin providing TTW installer requirement checking and validation for InstallTTWScreen."""
|
||||
|
||||
def check_requirements(self):
|
||||
"""Check and display requirements status"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
path_handler = PathHandler()
|
||||
|
||||
# Check game detection
|
||||
detected_games = path_handler.find_vanilla_game_paths()
|
||||
|
||||
# Fallout 3
|
||||
if 'Fallout 3' in detected_games:
|
||||
self.fallout3_status.setText("Fallout 3: Detected")
|
||||
self.fallout3_status.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam")
|
||||
self.fallout3_status.setStyleSheet("color: #f44336;")
|
||||
|
||||
# Fallout New Vegas
|
||||
if 'Fallout New Vegas' in detected_games:
|
||||
self.fnv_status.setText("Fallout New Vegas: Detected")
|
||||
self.fnv_status.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam")
|
||||
self.fnv_status.setStyleSheet("color: #f44336;")
|
||||
|
||||
# Update Start button state after checking requirements
|
||||
self._update_start_button_state()
|
||||
|
||||
def _check_ttw_installer_status(self):
|
||||
"""Check TTW_Linux_Installer installation status and update UI"""
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
# Create handler instances
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Check if TTW_Linux_Installer is installed
|
||||
ttw_installer_handler._check_installation()
|
||||
_ttw_installer_ready: bool = False
|
||||
|
||||
if ttw_installer_handler.ttw_installer_installed:
|
||||
# Check version against pinned/latest
|
||||
update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available()
|
||||
if update_available:
|
||||
# Determine if this is a downgrade or upgrade
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION
|
||||
if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v:
|
||||
# If we have a pinned version and installed is newer, it's a downgrade
|
||||
try:
|
||||
# Simple version comparison - if installed version string is longer/more complex, likely newer
|
||||
# For now, just check if they're different and show appropriate message
|
||||
if installed_v != target_v:
|
||||
version_text = f"Update to v{target_v} (currently v{installed_v})"
|
||||
else:
|
||||
version_text = f"Update available (v{installed_v} → v{target_v})"
|
||||
except Exception:
|
||||
version_text = f"Update to v{target_v}" if target_v else "Update available"
|
||||
else:
|
||||
# Normal update (newer version available)
|
||||
version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Update now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
else:
|
||||
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
|
||||
self.ttw_installer_btn.setText("Update now")
|
||||
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
def check_requirements(self):
|
||||
detected = _detect_ttw_games()
|
||||
|
||||
for display_name, label_widget in (
|
||||
('Fallout 3', self.fallout3_status),
|
||||
('Fallout New Vegas', self.fnv_status),
|
||||
):
|
||||
if display_name in detected:
|
||||
_, store = detected[display_name]
|
||||
store_label = {'steam': 'Steam', 'gog': 'GOG', 'epic': 'Epic'}.get(store, store)
|
||||
label_widget.setText(f"{display_name}: Detected ({store_label})")
|
||||
label_widget.setStyleSheet("color: #3fd0ea;")
|
||||
else:
|
||||
self.ttw_installer_status.setText("Not Found")
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
|
||||
label_widget.setText(f"{display_name}: Not Found")
|
||||
label_widget.setStyleSheet("color: #f44336;")
|
||||
|
||||
self._update_start_button_state()
|
||||
|
||||
def _check_ttw_installer_status(self):
|
||||
status = None
|
||||
try:
|
||||
from jackify.backend.services.tool_registry import ToolRegistry
|
||||
status = ToolRegistry().get_status("ttw_installer")
|
||||
self._ttw_installer_ready = bool(status and status.installed)
|
||||
except Exception as e:
|
||||
self.ttw_installer_status.setText("Check Failed")
|
||||
logger.debug("TTW installer status check failed: %s", e)
|
||||
self._ttw_installer_ready = False
|
||||
|
||||
if self._ttw_installer_ready:
|
||||
version_text = f"Ready (v{status.installed_version})" if status and status.installed_version else "Ready"
|
||||
self.ttw_installer_status.setText(version_text)
|
||||
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
|
||||
self.ttw_installer_btn.setVisible(False)
|
||||
else:
|
||||
self.ttw_installer_status.setText("Not installed - install via Tools Hub")
|
||||
self.ttw_installer_status.setStyleSheet("color: #f44336;")
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setText("Open Tools Hub")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
self.ttw_installer_btn.setVisible(True)
|
||||
logger.debug(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
|
||||
|
||||
self._update_start_button_state()
|
||||
|
||||
def install_ttw_installer(self):
|
||||
"""Install or update TTW_Linux_Installer"""
|
||||
# If not detected, show info dialog
|
||||
try:
|
||||
current_status = self.ttw_installer_status.text().strip()
|
||||
except Exception:
|
||||
current_status = ""
|
||||
if current_status == "Not Found":
|
||||
MessageService.information(
|
||||
self,
|
||||
"TTW_Linux_Installer Installation",
|
||||
(
|
||||
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
|
||||
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
|
||||
"Please star the repository and thank the developer.<br><br>"
|
||||
"Jackify will now download and install the latest Linux build of TTW_Linux_Installer."
|
||||
),
|
||||
safety_level="low",
|
||||
)
|
||||
"""Navigate to Tools Hub for TTW Linux Installer management."""
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(10)
|
||||
|
||||
# Update button to show installation in progress
|
||||
self.ttw_installer_btn.setText("Installing...")
|
||||
self.ttw_installer_btn.setEnabled(False)
|
||||
def _check_ttw_requirements(self, silent: bool = False) -> bool:
|
||||
detected = _detect_ttw_games()
|
||||
missing = [name for name in _TTW_GAMES if name not in detected]
|
||||
|
||||
self.console.append("Installing/updating TTW_Linux_Installer...")
|
||||
|
||||
# Create background thread for installation
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
class InstallerDownloadThread(QThread):
|
||||
finished = Signal(bool, str) # success, message
|
||||
progress = Signal(str) # progress message
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
|
||||
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.models.configuration import SystemInfo
|
||||
|
||||
# Create handler instances
|
||||
filesystem_handler = FileSystemHandler()
|
||||
config_handler = ConfigHandler()
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
ttw_installer_handler = TTWInstallerHandler(
|
||||
steamdeck=False,
|
||||
verbose=False,
|
||||
filesystem_handler=filesystem_handler,
|
||||
config_handler=config_handler
|
||||
)
|
||||
|
||||
# Install TTW_Linux_Installer (this will download and extract)
|
||||
self.progress.emit("Downloading TTW_Linux_Installer...")
|
||||
success, message = ttw_installer_handler.install_ttw_installer()
|
||||
|
||||
if success:
|
||||
install_path = ttw_installer_handler.ttw_installer_dir
|
||||
self.progress.emit(f"Installation complete: {install_path}")
|
||||
else:
|
||||
self.progress.emit(f"Installation failed: {message}")
|
||||
|
||||
self.finished.emit(success, message)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
|
||||
self.progress.emit(error_msg)
|
||||
logger.debug(f"DEBUG: TTW_Linux_Installer installation error: {e}")
|
||||
self.finished.emit(False, error_msg)
|
||||
|
||||
# Create and start thread
|
||||
self.installer_download_thread = InstallerDownloadThread()
|
||||
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
|
||||
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
|
||||
self.installer_download_thread.start()
|
||||
|
||||
# Update Activity window to show download in progress
|
||||
self.file_progress_list.clear()
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Downloading TTW_Linux_Installer...",
|
||||
progress=0
|
||||
)
|
||||
|
||||
def _on_installer_download_progress(self, message):
|
||||
"""Handle installer download progress updates"""
|
||||
self.console.append(message)
|
||||
# Update Activity window based on progress message
|
||||
if "Downloading" in message:
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Downloading TTW_Linux_Installer...",
|
||||
progress=0 # Indeterminate progress
|
||||
)
|
||||
elif "Extracting" in message or "extracting" in message.lower():
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="Extracting TTW_Linux_Installer...",
|
||||
progress=50
|
||||
)
|
||||
elif "complete" in message.lower() or "successfully" in message.lower():
|
||||
self.file_progress_list.update_or_add_item(
|
||||
item_id="ttw_installer_download",
|
||||
label="TTW_Linux_Installer ready",
|
||||
progress=100
|
||||
)
|
||||
|
||||
def _on_installer_download_finished(self, success, message):
|
||||
"""Handle installer download completion"""
|
||||
if success:
|
||||
self.console.append("TTW_Linux_Installer installed successfully")
|
||||
# Clear Activity window after successful installation
|
||||
self.file_progress_list.clear()
|
||||
# Re-check status after installation (this will update button state correctly)
|
||||
self._check_ttw_installer_status()
|
||||
self._update_start_button_state()
|
||||
else:
|
||||
self.console.append(f"Installation failed: {message}")
|
||||
# Clear Activity window on failure
|
||||
self.file_progress_list.clear()
|
||||
# Re-enable button on failure so user can retry
|
||||
self.ttw_installer_btn.setText("Install now")
|
||||
self.ttw_installer_btn.setEnabled(True)
|
||||
|
||||
def _check_ttw_requirements(self):
|
||||
"""Check TTW requirements before installation"""
|
||||
from jackify.backend.handlers.path_handler import PathHandler
|
||||
|
||||
path_handler = PathHandler()
|
||||
|
||||
# Check game detection
|
||||
detected_games = path_handler.find_vanilla_game_paths()
|
||||
missing_games = []
|
||||
|
||||
if 'Fallout 3' not in detected_games:
|
||||
missing_games.append("Fallout 3")
|
||||
if 'Fallout New Vegas' not in detected_games:
|
||||
missing_games.append("Fallout New Vegas")
|
||||
|
||||
if missing_games:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Missing Required Games",
|
||||
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}"
|
||||
)
|
||||
if missing:
|
||||
if not silent:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"Missing Required Games",
|
||||
f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\n"
|
||||
f"Not found: {', '.join(missing)}\n\n"
|
||||
"Install via Steam, GOG (through Heroic), or another supported store."
|
||||
)
|
||||
return False
|
||||
|
||||
# Check TTW_Linux_Installer using the status we already checked
|
||||
status_text = self.ttw_installer_status.text()
|
||||
if status_text in ("Not Found", "Check Failed"):
|
||||
MessageService.warning(
|
||||
self,
|
||||
"TTW_Linux_Installer Required",
|
||||
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button."
|
||||
)
|
||||
|
||||
if not self._ttw_installer_ready:
|
||||
if not silent:
|
||||
MessageService.warning(
|
||||
self,
|
||||
"TTW Linux Installer Required",
|
||||
"TTW Linux Installer is not installed.\n\nInstall it from the Tools Hub before proceeding."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _update_start_button_state(self):
|
||||
"""Enable/disable Start button based on requirements and file selection"""
|
||||
# Check if all requirements are met
|
||||
requirements_met = self._check_ttw_requirements()
|
||||
|
||||
# Check if .mpi file is selected
|
||||
requirements_met = self._check_ttw_requirements(silent=True)
|
||||
mpi_file_selected = bool(self.file_edit.text().strip())
|
||||
|
||||
# Enable Start button only if both requirements are met and file is selected
|
||||
self.start_btn.setEnabled(requirements_met and mpi_file_selected)
|
||||
|
||||
# Update button text to indicate what's missing
|
||||
if not requirements_met:
|
||||
self.start_btn.setText("Requirements Not Met")
|
||||
elif not mpi_file_selected:
|
||||
self.start_btn.setText("Select TTW .mpi File")
|
||||
else:
|
||||
self.start_btn.setText("Start Installation")
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""TTW installation worker thread."""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
from ..utils import strip_ansi_control_codes
|
||||
@@ -21,11 +23,15 @@ class TTWInstallationThread(QThread):
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
try:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
if self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
pgid = os.getpgid(self.proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def process_and_buffer_line(self, raw_line):
|
||||
"""Clean one output line and queue it for batched emit."""
|
||||
@@ -62,7 +68,7 @@ class TTWInstallationThread(QThread):
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
self.process_and_buffer_line("Initializing TTW installation...")
|
||||
self.process_and_buffer_line("Initialising TTW installation...")
|
||||
self.flush_output_buffer()
|
||||
|
||||
filesystem_handler = FileSystemHandler()
|
||||
|
||||
@@ -84,7 +84,7 @@ class TTWUIMixin:
|
||||
|
||||
# On Steam Deck, skip window resizing - keep default Steam Deck window size
|
||||
if is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
||||
logger.debug("Steam Deck detected, skipping window resize in _toggle_console_visibility")
|
||||
return
|
||||
|
||||
# Restore main window to normal size (clear any compact constraints)
|
||||
@@ -137,7 +137,7 @@ class TTWUIMixin:
|
||||
|
||||
# On Steam Deck, skip window resizing to keep maximized state
|
||||
if is_steamdeck:
|
||||
logger.debug("DEBUG: Steam Deck detected, skipping window resize in collapse branch")
|
||||
logger.debug("Steam Deck detected, skipping window resize in collapse branch")
|
||||
return
|
||||
|
||||
# Use fixed compact height for consistency across all workflow screens
|
||||
|
||||
@@ -23,10 +23,9 @@ class TTWWorkflowMixin:
|
||||
def validate_and_start_install(self):
|
||||
import time
|
||||
self._install_workflow_start_time = time.time()
|
||||
logger.debug('DEBUG: validate_and_start_install called')
|
||||
|
||||
self.config_handler.reload_config()
|
||||
logger.debug('DEBUG: Reloaded config from disk')
|
||||
logger.debug("Reloaded config from disk")
|
||||
|
||||
if not self._check_ttw_requirements():
|
||||
return
|
||||
@@ -87,13 +86,13 @@ class TTWWorkflowMixin:
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
logger.debug(f"DEBUG: Deleted all contents of {install_dir}")
|
||||
logger.debug(f"Deleted all contents of {install_dir}")
|
||||
except Exception as e:
|
||||
MessageService.show_error(self, install_dir_create_failed(str(install_dir), str(e)))
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Error checking directory contents: {e}")
|
||||
logger.debug(f"Error checking directory contents: {e}")
|
||||
|
||||
if not os.path.isdir(install_dir):
|
||||
create = MessageService.question(self, "Create Directory?",
|
||||
@@ -118,18 +117,15 @@ class TTWWorkflowMixin:
|
||||
self.cancel_btn.setVisible(False)
|
||||
self.cancel_install_btn.setVisible(True)
|
||||
|
||||
logger.debug(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}')
|
||||
logger.debug(f"Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}")
|
||||
self.run_ttw_installer(mpi_path, install_dir)
|
||||
except Exception as e:
|
||||
logger.debug(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
logger.debug(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
def run_ttw_installer(self, mpi_path, install_dir):
|
||||
logger.debug('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER')
|
||||
logger.debug("run_ttw_installer called - USING THREADED BACKEND WRAPPER")
|
||||
|
||||
self.config_handler._load_config()
|
||||
|
||||
@@ -141,11 +137,11 @@ class TTWWorkflowMixin:
|
||||
self._safe_append_text("Starting TTW installation...")
|
||||
|
||||
self.file_progress_list.clear()
|
||||
self._update_ttw_phase("Initializing TTW installation", 0, 0, 0)
|
||||
self._update_ttw_phase("Initialising TTW installation", 0, 0, 0)
|
||||
QApplication.processEvents()
|
||||
|
||||
self.status_banner.setVisible(True)
|
||||
self.status_banner.setText("Initializing TTW installation...")
|
||||
self.status_banner.setText("Initialising TTW installation...")
|
||||
self.show_details_checkbox.setVisible(True)
|
||||
|
||||
self.status_banner.setStyleSheet(f"""
|
||||
@@ -178,7 +174,7 @@ class TTWWorkflowMixin:
|
||||
|
||||
def on_installation_finished(self, success, message):
|
||||
"""Handle installation completion."""
|
||||
logger.debug(f"DEBUG: on_installation_finished called with success={success}, message={message}")
|
||||
logger.debug(f"on_installation_finished called with success={success}, message={message}")
|
||||
|
||||
if hasattr(self, 'ttw_elapsed_timer'):
|
||||
self.ttw_elapsed_timer.stop()
|
||||
@@ -189,8 +185,8 @@ class TTWWorkflowMixin:
|
||||
seconds = elapsed % 60
|
||||
self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s")
|
||||
self.status_banner.setStyleSheet("""
|
||||
background-color: #1a4d1a;
|
||||
color: #4CAF50;
|
||||
background-color: #1a3040;
|
||||
color: #3fd0ea;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
@@ -220,11 +216,11 @@ class TTWWorkflowMixin:
|
||||
self.process_finished(1, QProcess.CrashExit)
|
||||
|
||||
def process_finished(self, exit_code, exit_status):
|
||||
logger.debug(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
logger.debug(f"process_finished called with exit_code={exit_code}, exit_status={exit_status}")
|
||||
self.start_btn.setEnabled(True)
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
logger.debug("DEBUG: Button states reset in process_finished")
|
||||
logger.debug("Button states reset in process_finished")
|
||||
|
||||
if exit_code == 0:
|
||||
self._safe_append_text("\nTTW installation completed successfully!")
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Mixin that runs verify_install.py before showing the success dialog."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VerifierThread(QThread):
|
||||
finished = Signal(object)
|
||||
|
||||
def __init__(self, pfx: Path, modlist_dir: Path, game_type: str, appid: str, modlist_name: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self.pfx = pfx
|
||||
self.modlist_dir = modlist_dir
|
||||
self.game_type = game_type
|
||||
self.appid = appid
|
||||
self.modlist_name = modlist_name
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from jackify.backend.services.install_verifier_service import run_install_verification
|
||||
results = run_install_verification(self.pfx, self.modlist_dir, self.game_type, self.appid, self.modlist_name)
|
||||
except Exception as e:
|
||||
logger.warning("Verifier thread error: %s", e)
|
||||
results = None
|
||||
self.finished.emit(results)
|
||||
|
||||
|
||||
def _resolve_pfx_for_appid(appid: str) -> Optional[Path]:
|
||||
from jackify.backend.services.install_verifier_service import resolve_pfx_for_appid
|
||||
return resolve_pfx_for_appid(appid)
|
||||
|
||||
|
||||
class InstallVerifierMixin:
|
||||
"""Mixin: run the verifier before showing the success dialog."""
|
||||
|
||||
def _get_appid_for_install_dir(self, install_dir: str) -> str:
|
||||
stored = getattr(self, "_current_appid", "") or ""
|
||||
if stored:
|
||||
return stored
|
||||
try:
|
||||
import os
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
sh = ShortcutHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
||||
for sc in sh.find_shortcuts_by_exe("ModOrganizer.exe"):
|
||||
if os.path.realpath(sc.get("StartDir", "")) == os.path.realpath(install_dir):
|
||||
raw = sc.get("appid")
|
||||
if raw is not None:
|
||||
return str(int(raw) & 0xFFFFFFFF)
|
||||
except Exception as e:
|
||||
logger.debug("AppID lookup failed: %s", e)
|
||||
return ""
|
||||
|
||||
def _maybe_apply_jcontainers_fix(self, install_dir: str, game_type: str) -> None:
|
||||
"""Apply the JContainers Linux fix if needed, with a countdown confirmation dialog."""
|
||||
try:
|
||||
from jackify.backend.handlers.modlist_fixup_handler import (
|
||||
check_jcontainers_needs_fix,
|
||||
apply_jcontainers_fix,
|
||||
)
|
||||
needs_fix = check_jcontainers_needs_fix(Path(install_dir), game_type)
|
||||
if not needs_fix:
|
||||
return
|
||||
|
||||
from jackify.frontends.gui.services.message_service import SafeMessageBox
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
dlg = SafeMessageBox(parent=self, safety_level="low")
|
||||
dlg.setup_safety_features(
|
||||
title="JContainers Compatibility Fix",
|
||||
message=(
|
||||
"The mod JContainers has been detected. The Nexusmods version of "
|
||||
"JContainers is known to cause crashes on Linux/Proton.\n\n"
|
||||
"A fixed version is available from the mod's GitHub page - would you "
|
||||
"like the fixed version to be applied now?\n\n"
|
||||
"The original DLL will be backed up as part of the process."
|
||||
),
|
||||
danger_action="Yes",
|
||||
safe_action="No",
|
||||
is_question=True,
|
||||
)
|
||||
result = dlg.exec()
|
||||
if result == QMessageBox.Yes:
|
||||
apply_jcontainers_fix(Path(install_dir), game_type)
|
||||
logger.info("JContainers fix applied post-configure")
|
||||
except Exception as e:
|
||||
logger.warning("JContainers fix check failed (non-fatal): %s", e)
|
||||
|
||||
def _run_verifier_then_show_success(
|
||||
self,
|
||||
install_dir: str,
|
||||
game_type: str,
|
||||
success_params: dict,
|
||||
appid: str = "",
|
||||
):
|
||||
"""
|
||||
Show 'Verifying...' state, run the verifier in a background thread,
|
||||
then show SuccessDialog with results embedded.
|
||||
|
||||
success_params keys: modlist_name, workflow_type, time_taken, game_name, enb_detected
|
||||
"""
|
||||
self._maybe_apply_jcontainers_fix(install_dir, game_type)
|
||||
if hasattr(self, "progress_indicator"):
|
||||
self.progress_indicator.set_status("Verifying installation...", 100)
|
||||
if hasattr(self, "file_progress_list"):
|
||||
self.file_progress_list.update_or_add_item(
|
||||
"__verifier__", "Verifying installation...", 0.0
|
||||
)
|
||||
|
||||
resolved_appid = str(appid or self._get_appid_for_install_dir(install_dir) or "")
|
||||
pfx = _resolve_pfx_for_appid(resolved_appid)
|
||||
|
||||
if not install_dir or pfx is None:
|
||||
logger.info(
|
||||
"Verifier skipped: pfx not found (appid=%s dir=%s)",
|
||||
resolved_appid, install_dir,
|
||||
)
|
||||
self._show_success_dialog(success_params, verification_results=None)
|
||||
return
|
||||
|
||||
self._verifier_thread = VerifierThread(
|
||||
pfx=pfx,
|
||||
modlist_dir=Path(install_dir),
|
||||
game_type=game_type,
|
||||
appid=resolved_appid,
|
||||
modlist_name=success_params.get("modlist_name", ""),
|
||||
parent=self,
|
||||
)
|
||||
self._verifier_thread.finished.connect(
|
||||
lambda r: self._on_verifier_complete_show_success(r, success_params)
|
||||
)
|
||||
self._verifier_thread.start()
|
||||
|
||||
def _on_verifier_complete_show_success(self, results, success_params: dict):
|
||||
if self._verifier_thread is not None:
|
||||
self._verifier_thread.wait(2000)
|
||||
self._verifier_thread.deleteLater()
|
||||
self._verifier_thread = None
|
||||
if results is not None:
|
||||
n_pass = len(results.passes)
|
||||
n_warn = len(results.warnings)
|
||||
n_fail = len(results.failures)
|
||||
logger.info(
|
||||
"Install verification: %d passed, %d warnings, %d failures",
|
||||
n_pass, n_warn, n_fail,
|
||||
)
|
||||
for msg in results.failures:
|
||||
logger.warning("Verifier FAIL: %s", msg)
|
||||
for msg in results.warnings:
|
||||
logger.info("Verifier WARN: %s", msg)
|
||||
else:
|
||||
logger.warning("Install verifier returned no results (script error)")
|
||||
|
||||
self._show_success_dialog(success_params, verification_results=results)
|
||||
|
||||
def _show_success_dialog(self, params: dict, verification_results=None):
|
||||
"""Clear the activity window and show SuccessDialog with optional verification results."""
|
||||
if hasattr(self, "file_progress_list"):
|
||||
self.file_progress_list.clear()
|
||||
|
||||
from jackify.frontends.gui.dialogs import SuccessDialog
|
||||
dlg = SuccessDialog(
|
||||
modlist_name=params["modlist_name"],
|
||||
workflow_type=params["workflow_type"],
|
||||
time_taken=params["time_taken"],
|
||||
game_name=params.get("game_name"),
|
||||
verification_results=verification_results,
|
||||
parent=self,
|
||||
)
|
||||
dlg.show()
|
||||
|
||||
if params.get("enb_detected"):
|
||||
try:
|
||||
from jackify.frontends.gui.dialogs.enb_proton_dialog import ENBProtonDialog
|
||||
enb_dialog = ENBProtonDialog(modlist_name=params["modlist_name"], parent=self)
|
||||
enb_dialog.exec()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to show ENB dialog: %s", e)
|
||||
@@ -8,11 +8,18 @@ import os
|
||||
from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT
|
||||
from ..utils import set_responsive_minimum
|
||||
|
||||
_TOOLS_HUB_ACTION = "third_party_tools"
|
||||
_UPDATE_COLOUR = "#f0c040"
|
||||
_NORMAL_DESC_COLOUR = "#999"
|
||||
|
||||
class MainMenu(QWidget):
|
||||
def __init__(self, stacked_widget=None, dev_mode=False):
|
||||
super().__init__()
|
||||
self.stacked_widget = stacked_widget
|
||||
self.dev_mode = dev_mode
|
||||
self._tools_hub_btn: QPushButton = None
|
||||
self._tools_hub_desc: QLabel = None
|
||||
self._tools_hub_desc_original: str = ""
|
||||
layout = QVBoxLayout()
|
||||
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
|
||||
layout.setContentsMargins(30, 30, 30, 30)
|
||||
@@ -63,35 +70,17 @@ class MainMenu(QWidget):
|
||||
button_height = 40
|
||||
MENU_ITEMS = [
|
||||
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
|
||||
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
|
||||
# ("Third Party Tools", "third_party_tools", "Install and manage Sulfur's Linux-native modding tools"), # v0.7
|
||||
("Additional Tasks", "additional_tasks", "Verifier, diagnostics, Nexus OAuth, and more"),
|
||||
("Tools Hub", "third_party_tools", "Install and manage additional engines and modding tools"),
|
||||
("Exit Jackify", "exit_jackify", "Close the application"),
|
||||
]
|
||||
|
||||
for label, action_id, description in MENU_ITEMS:
|
||||
# Main button
|
||||
btn = QPushButton(label)
|
||||
btn.setFixedSize(button_width, button_height) # Use variable height
|
||||
btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: #5a6578;
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {JACKIFY_COLOR_BLUE};
|
||||
}}
|
||||
""")
|
||||
btn.setFixedSize(button_width, button_height)
|
||||
btn.setStyleSheet(self._btn_style())
|
||||
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
|
||||
|
||||
# Button container with proper alignment
|
||||
btn_container = QWidget()
|
||||
btn_layout = QVBoxLayout()
|
||||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -99,10 +88,9 @@ class MainMenu(QWidget):
|
||||
btn_layout.setAlignment(Qt.AlignHCenter)
|
||||
btn_layout.addWidget(btn)
|
||||
|
||||
# Description label with proper alignment
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setAlignment(Qt.AlignHCenter)
|
||||
desc_label.setStyleSheet("color: #999; font-size: 11px;")
|
||||
desc_label.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;")
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setFixedWidth(button_width)
|
||||
btn_layout.addWidget(desc_label)
|
||||
@@ -110,6 +98,11 @@ class MainMenu(QWidget):
|
||||
btn_container.setLayout(btn_layout)
|
||||
layout.addWidget(btn_container)
|
||||
|
||||
if action_id == _TOOLS_HUB_ACTION:
|
||||
self._tools_hub_btn = btn
|
||||
self._tools_hub_desc = desc_label
|
||||
self._tools_hub_desc_original = description
|
||||
|
||||
# Disclaimer
|
||||
layout.addSpacing(12)
|
||||
disclaimer = QLabel(DISCLAIMER_TEXT)
|
||||
@@ -135,6 +128,34 @@ class MainMenu(QWidget):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _btn_style(self, highlight: bool = False) -> str:
|
||||
border = f"1px solid {_UPDATE_COLOUR}" if highlight else "none"
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background-color: #4a5568;
|
||||
color: white;
|
||||
border: {border};
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #5a6578; }}
|
||||
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
|
||||
"""
|
||||
|
||||
def notify_tool_updates(self, has_updates: bool) -> None:
|
||||
if not self._tools_hub_btn or not self._tools_hub_desc:
|
||||
return
|
||||
if has_updates:
|
||||
self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=True))
|
||||
self._tools_hub_desc.setText("Updates available")
|
||||
self._tools_hub_desc.setStyleSheet(f"color: {_UPDATE_COLOUR}; font-size: 11px; font-weight: bold;")
|
||||
else:
|
||||
self._tools_hub_btn.setStyleSheet(self._btn_style(highlight=False))
|
||||
self._tools_hub_desc.setText(self._tools_hub_desc_original)
|
||||
self._tools_hub_desc.setStyleSheet(f"color: {_NORMAL_DESC_COLOUR}; font-size: 11px;")
|
||||
|
||||
def menu_action(self, action_id):
|
||||
if action_id == "exit_jackify":
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
@@ -137,7 +137,7 @@ class ModlistTasksScreen(QWidget):
|
||||
"""Set up the menu buttons section"""
|
||||
# Menu options
|
||||
MENU_ITEMS = [
|
||||
("Install a Modlist (Automated)", "install_modlist", "Download and install modlists automatically"),
|
||||
("Install a Modlist", "install_modlist", "Download and install modlists automatically"),
|
||||
("Configure New Modlist (Post-Download)", "configure_new_modlist", "Configure a newly downloaded modlist"),
|
||||
("Configure Existing Modlist (In Steam)", "configure_existing_modlist", "Reconfigure an existing Steam modlist"),
|
||||
]
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
"""
|
||||
Third Party Tools screen.
|
||||
|
||||
Lists independently-managed tools with install status, version info,
|
||||
and Install / Update / Downgrade / Uninstall actions per tool.
|
||||
Version checks run in a background thread so the screen loads instantly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QLabel, QPushButton,
|
||||
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import TOOL_DEFINITIONS, ToolRegistry, ToolStatus
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
from jackify.frontends.gui.utils import set_responsive_minimum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colours
|
||||
# ---------------------------------------------------------------------------
|
||||
_BTN_INSTALL = "#1a5fa8"
|
||||
_BTN_UPDATE = "#2a6e2a"
|
||||
_BTN_DOWNGRADE = "#7a5a00"
|
||||
_BTN_UNINSTALL = "#6b2020"
|
||||
_BTN_DISABLED = "#333"
|
||||
|
||||
_BADGE_NOT_INSTALLED = ("#555", "#ccc") # bg, fg
|
||||
_BADGE_UP_TO_DATE = ("#1e4d1e", "#8fdc8f")
|
||||
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
||||
_BADGE_CHECKING = ("#333", "#888")
|
||||
|
||||
|
||||
def _btn_style(colour: str, disabled: bool = False) -> str:
|
||||
bg = _BTN_DISABLED if disabled else colour
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background-color: {bg};
|
||||
color: {'#666' if disabled else 'white'};
|
||||
border: none; border-radius: 4px;
|
||||
font-size: 11px; font-weight: bold;
|
||||
padding: 4px 8px;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: {'#444' if disabled else bg}; }}
|
||||
QPushButton:pressed {{ background-color: {bg}; }}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background version-check thread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _VersionCheckThread(QThread):
|
||||
version_ready = Signal(str, str) # tool_id, latest_version_tag
|
||||
|
||||
def run(self):
|
||||
registry = ToolRegistry()
|
||||
for defn in TOOL_DEFINITIONS:
|
||||
try:
|
||||
tag = registry.check_latest_version(defn.tool_id)
|
||||
if tag:
|
||||
self.version_ready.emit(defn.tool_id, tag)
|
||||
except Exception as e:
|
||||
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background install/update/downgrade/uninstall thread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ToolActionThread(QThread):
|
||||
finished_signal = Signal(str, bool, str) # tool_id, success, message
|
||||
|
||||
def __init__(self, tool_id: str, action: str):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._action = action
|
||||
|
||||
def run(self):
|
||||
registry = ToolRegistry()
|
||||
try:
|
||||
if self._action == "install":
|
||||
ok, msg = registry.install(self._tool_id)
|
||||
elif self._action == "update":
|
||||
ok, msg = registry.update(self._tool_id)
|
||||
elif self._action == "downgrade":
|
||||
ok, msg = registry.downgrade(self._tool_id)
|
||||
elif self._action == "uninstall":
|
||||
ok, msg = registry.uninstall(self._tool_id)
|
||||
else:
|
||||
ok, msg = False, f"Unknown action: {self._action}"
|
||||
except Exception as e:
|
||||
ok, msg = False, str(e)
|
||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-tool card widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ToolCard(QFrame):
|
||||
action_requested = Signal(str, str) # tool_id, action
|
||||
|
||||
def __init__(self, status: ToolStatus, parent=None):
|
||||
super().__init__(parent)
|
||||
self._tool_id = status.definition.tool_id
|
||||
self._status = status
|
||||
self._busy = False
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
}
|
||||
""")
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(14, 10, 14, 10)
|
||||
outer.setSpacing(12)
|
||||
|
||||
# --- Left: name + description ---
|
||||
info_col = QVBoxLayout()
|
||||
info_col.setSpacing(2)
|
||||
|
||||
tier_tag = " [required]" if status.definition.tier == 1 else ""
|
||||
name_label = QLabel(f"<b>{status.definition.display_name}</b>{tier_tag}")
|
||||
name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||
info_col.addWidget(name_label)
|
||||
|
||||
desc_label = QLabel(status.definition.description)
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
|
||||
info_col.addWidget(desc_label)
|
||||
|
||||
info_widget = QWidget()
|
||||
info_widget.setLayout(info_col)
|
||||
info_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
info_widget.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(info_widget, stretch=3)
|
||||
|
||||
# --- Centre: status badge + version ---
|
||||
centre_col = QVBoxLayout()
|
||||
centre_col.setSpacing(4)
|
||||
centre_col.setAlignment(Qt.AlignCenter)
|
||||
|
||||
self._badge = QLabel()
|
||||
self._badge.setAlignment(Qt.AlignCenter)
|
||||
self._badge.setFixedWidth(130)
|
||||
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
|
||||
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
|
||||
|
||||
self._version_label = QLabel()
|
||||
self._version_label.setAlignment(Qt.AlignCenter)
|
||||
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
|
||||
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
|
||||
|
||||
centre_widget = QWidget()
|
||||
centre_widget.setLayout(centre_col)
|
||||
centre_widget.setFixedWidth(150)
|
||||
centre_widget.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(centre_widget)
|
||||
|
||||
# --- Right: action buttons ---
|
||||
btn_col = QVBoxLayout()
|
||||
btn_col.setSpacing(4)
|
||||
btn_col.setAlignment(Qt.AlignCenter)
|
||||
|
||||
self._btn_primary = QPushButton()
|
||||
self._btn_primary.setFixedWidth(90)
|
||||
self._btn_primary.clicked.connect(self._on_primary)
|
||||
btn_col.addWidget(self._btn_primary)
|
||||
|
||||
self._btn_downgrade = QPushButton("Downgrade")
|
||||
self._btn_downgrade.setFixedWidth(90)
|
||||
self._btn_downgrade.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "downgrade"))
|
||||
btn_col.addWidget(self._btn_downgrade)
|
||||
|
||||
self._btn_uninstall = QPushButton("Uninstall")
|
||||
self._btn_uninstall.setFixedWidth(90)
|
||||
self._btn_uninstall.clicked.connect(self._on_uninstall)
|
||||
btn_col.addWidget(self._btn_uninstall)
|
||||
|
||||
btn_widget = QWidget()
|
||||
btn_widget.setLayout(btn_col)
|
||||
btn_widget.setFixedWidth(110)
|
||||
btn_widget.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(btn_widget)
|
||||
|
||||
self.setLayout(outer)
|
||||
self._refresh_ui(status)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_ui(self, status: ToolStatus):
|
||||
self._status = status
|
||||
installed = status.installed
|
||||
update_avail = status.update_available
|
||||
can_downgrade = status.can_downgrade
|
||||
can_uninstall = status.definition.can_uninstall
|
||||
|
||||
# Badge
|
||||
if not installed:
|
||||
bg, fg = _BADGE_NOT_INSTALLED
|
||||
badge_text = "Not Installed"
|
||||
elif update_avail:
|
||||
bg, fg = _BADGE_UPDATE_AVAIL
|
||||
badge_text = "Update Available"
|
||||
else:
|
||||
bg, fg = _BADGE_UP_TO_DATE
|
||||
badge_text = "Installed"
|
||||
self._badge.setText(badge_text)
|
||||
self._badge.setStyleSheet(
|
||||
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
|
||||
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
|
||||
)
|
||||
|
||||
# Version line
|
||||
installed_ver = status.installed_version or "-"
|
||||
latest_ver = status.latest_version or "checking..."
|
||||
if installed:
|
||||
self._version_label.setText(f"Installed: {installed_ver}\nLatest: {latest_ver}")
|
||||
else:
|
||||
self._version_label.setText(f"Latest: {latest_ver}")
|
||||
|
||||
# Primary button
|
||||
if not installed:
|
||||
self._btn_primary.setText("Install")
|
||||
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
|
||||
self._btn_primary.setEnabled(True)
|
||||
elif update_avail:
|
||||
self._btn_primary.setText("Update")
|
||||
self._btn_primary.setStyleSheet(_btn_style(_BTN_UPDATE))
|
||||
self._btn_primary.setEnabled(True)
|
||||
else:
|
||||
self._btn_primary.setText("Reinstall")
|
||||
self._btn_primary.setStyleSheet(_btn_style(_BTN_INSTALL))
|
||||
self._btn_primary.setEnabled(True)
|
||||
|
||||
# Downgrade button
|
||||
self._btn_downgrade.setStyleSheet(_btn_style(_BTN_DOWNGRADE, disabled=not can_downgrade))
|
||||
self._btn_downgrade.setEnabled(can_downgrade and not self._busy)
|
||||
|
||||
# Uninstall button
|
||||
self._btn_uninstall.setVisible(can_uninstall)
|
||||
if can_uninstall:
|
||||
self._btn_uninstall.setStyleSheet(_btn_style(_BTN_UNINSTALL, disabled=not installed))
|
||||
self._btn_uninstall.setEnabled(installed and not self._busy)
|
||||
|
||||
if self._busy:
|
||||
self._btn_primary.setEnabled(False)
|
||||
self._btn_primary.setStyleSheet(_btn_style(_BTN_DISABLED, disabled=True))
|
||||
|
||||
def set_latest_version(self, tag: str):
|
||||
self._status.latest_version = tag
|
||||
if self._status.installed and self._status.installed_version:
|
||||
installed = self._status.installed_version.lstrip("v")
|
||||
latest = tag.lstrip("v")
|
||||
self._status.update_available = latest != installed
|
||||
self._refresh_ui(self._status)
|
||||
|
||||
def set_busy(self, busy: bool, label: Optional[str] = None):
|
||||
self._busy = busy
|
||||
if busy and label:
|
||||
self._btn_primary.setText(label)
|
||||
self._refresh_ui(self._status)
|
||||
|
||||
def mark_installed(self, version: str):
|
||||
self._status.installed = True
|
||||
self._status.installed_version = version
|
||||
self._status.update_available = False
|
||||
self._busy = False
|
||||
self._refresh_ui(self._status)
|
||||
|
||||
def mark_uninstalled(self):
|
||||
self._status.installed = False
|
||||
self._status.installed_version = None
|
||||
self._status.update_available = False
|
||||
self._busy = False
|
||||
self._refresh_ui(self._status)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_primary(self):
|
||||
if not self._status.installed:
|
||||
self.action_requested.emit(self._tool_id, "install")
|
||||
elif self._status.update_available:
|
||||
self.action_requested.emit(self._tool_id, "update")
|
||||
else:
|
||||
self.action_requested.emit(self._tool_id, "install")
|
||||
|
||||
def _on_uninstall(self):
|
||||
confirmed = MessageService.question(
|
||||
self,
|
||||
"Uninstall Tool",
|
||||
f"Uninstall {self._status.definition.display_name}?\n\nThis will delete the installed files.",
|
||||
)
|
||||
if confirmed:
|
||||
self.action_requested.emit(self._tool_id, "uninstall")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main screen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ThirdPartyToolsScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Third Party Tools management screen."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index: int = 0, parent=None):
|
||||
super().__init__(parent)
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
|
||||
self._cards: Dict[str, _ToolCard] = {}
|
||||
self._action_thread: Optional[_ToolActionThread] = None
|
||||
self._version_thread: Optional[_VersionCheckThread] = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
root = QVBoxLayout()
|
||||
root.setContentsMargins(30, 24, 30, 24)
|
||||
root.setSpacing(0)
|
||||
self.setLayout(root)
|
||||
|
||||
# Header
|
||||
title = QLabel("<b>Third Party Tools</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
|
||||
title.setAlignment(Qt.AlignHCenter)
|
||||
root.addWidget(title)
|
||||
|
||||
root.addSpacing(6)
|
||||
|
||||
desc = QLabel(
|
||||
"Install and manage independently-updated tools used by Jackify workflows or run via MO2.\n"
|
||||
"Tools marked [required] are needed by existing Jackify workflows."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #aaa; font-size: 12px;")
|
||||
desc.setAlignment(Qt.AlignHCenter)
|
||||
root.addWidget(desc)
|
||||
|
||||
root.addSpacing(10)
|
||||
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(1)
|
||||
sep.setStyleSheet("background: #444;")
|
||||
root.addWidget(sep)
|
||||
|
||||
root.addSpacing(12)
|
||||
|
||||
# Scrollable tool list
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.NoFrame)
|
||||
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
list_widget = QWidget()
|
||||
list_widget.setStyleSheet("background: transparent;")
|
||||
self._list_layout = QVBoxLayout()
|
||||
self._list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._list_layout.setSpacing(8)
|
||||
list_widget.setLayout(self._list_layout)
|
||||
|
||||
registry = ToolRegistry()
|
||||
for status in registry.get_all_statuses():
|
||||
card = _ToolCard(status)
|
||||
card.action_requested.connect(self._on_action)
|
||||
self._cards[status.definition.tool_id] = card
|
||||
self._list_layout.addWidget(card)
|
||||
|
||||
self._list_layout.addStretch()
|
||||
scroll.setWidget(list_widget)
|
||||
root.addWidget(scroll, stretch=1)
|
||||
|
||||
root.addSpacing(12)
|
||||
|
||||
# Back button
|
||||
back_row = QHBoxLayout()
|
||||
back_row.addStretch()
|
||||
back_btn = QPushButton("Back to Main Menu")
|
||||
back_btn.setFixedSize(160, 34)
|
||||
back_btn.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: #4a5568; color: white;
|
||||
border: none; border-radius: 5px;
|
||||
font-size: 12px; font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{ background-color: #5a6578; }}
|
||||
QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}
|
||||
""")
|
||||
back_btn.clicked.connect(self._go_back)
|
||||
back_row.addWidget(back_btn)
|
||||
back_row.addStretch()
|
||||
root.addLayout(back_row)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Version check on show
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
try:
|
||||
main_window = self.window()
|
||||
if main_window:
|
||||
set_responsive_minimum(main_window, min_width=960, min_height=520)
|
||||
except Exception:
|
||||
pass
|
||||
self._start_version_check()
|
||||
|
||||
def _start_version_check(self):
|
||||
if self._version_thread and self._version_thread.isRunning():
|
||||
return
|
||||
self._version_thread = _VersionCheckThread()
|
||||
self._version_thread.version_ready.connect(self._on_version_ready)
|
||||
self._version_thread.start()
|
||||
|
||||
def _on_version_ready(self, tool_id: str, tag: str):
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_latest_version(tag)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_action(self, tool_id: str, action: str):
|
||||
if self._action_thread and self._action_thread.isRunning():
|
||||
MessageService.information(self, "Busy", "Another operation is already running. Please wait.")
|
||||
return
|
||||
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
label_map = {"install": "Installing...", "update": "Updating...",
|
||||
"downgrade": "Downgrading...", "uninstall": "Removing..."}
|
||||
card.set_busy(True, label_map.get(action, "Working..."))
|
||||
|
||||
self._action_thread = _ToolActionThread(tool_id, action)
|
||||
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||
self._action_thread.start()
|
||||
|
||||
def _on_action_finished(self, tool_id: str, success: bool, message: str):
|
||||
self._action_thread = None
|
||||
|
||||
card = self._cards.get(tool_id)
|
||||
if success:
|
||||
registry = ToolRegistry()
|
||||
status = registry.get_status(tool_id)
|
||||
if status and status.installed and card:
|
||||
card.mark_installed(status.installed_version or "")
|
||||
if status.latest_version:
|
||||
card.set_latest_version(status.latest_version)
|
||||
elif card:
|
||||
card.mark_uninstalled()
|
||||
MessageService.information(self, "Done", message)
|
||||
else:
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
MessageService.warning(self, "Failed", message)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _go_back(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Tools Hub screen.
|
||||
|
||||
Manages independently-versioned engines and tools. On each show, the tool list
|
||||
is rebuilt from the effective definitions (remote manifest if fetched, else
|
||||
baked-in). A background thread fetches the manifest; if the tool list changes
|
||||
the cards are rebuilt and version checks restart.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QFrame, QHBoxLayout, QLabel,
|
||||
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import (
|
||||
ToolDefinition, ToolRegistry, ToolStatus,
|
||||
apply_remote_manifest, fetch_remote_manifest, fetch_release_list,
|
||||
get_active_engine_id, get_effective_definitions,
|
||||
)
|
||||
from jackify.frontends.gui.mixins.thread_lifecycle_mixin import ThreadLifecycleMixin
|
||||
from jackify.frontends.gui.screens.tools_hub_card import ToolCard, btn_style, section_header
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
from jackify.frontends.gui.utils import set_responsive_minimum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_C_UPDATE = "#4a5568"
|
||||
_C_BACK = "#4a5568"
|
||||
_C_INSTALL = "#1a5fa8"
|
||||
|
||||
|
||||
# -- background threads ------------------------------------------------------
|
||||
class _VersionCheckThread(QThread):
|
||||
version_ready = Signal(str, str) # tool_id, latest_tag
|
||||
|
||||
def run(self):
|
||||
registry = ToolRegistry()
|
||||
for defn in get_effective_definitions():
|
||||
try:
|
||||
tag = registry.check_latest_version(defn.tool_id)
|
||||
self.version_ready.emit(defn.tool_id, tag or "unknown")
|
||||
except Exception as e:
|
||||
logger.debug("Version check failed for %s: %s", defn.tool_id, e)
|
||||
self.version_ready.emit(defn.tool_id, "unknown")
|
||||
|
||||
|
||||
class _ToolActionThread(QThread):
|
||||
finished_signal = Signal(str, bool, str) # tool_id, success, message
|
||||
|
||||
def __init__(self, tool_id: str, action: str, version: Optional[str] = None):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._action = action
|
||||
self._version = version
|
||||
|
||||
def run(self):
|
||||
registry = ToolRegistry()
|
||||
try:
|
||||
if self._action == "install":
|
||||
ok, msg = registry.install(self._tool_id, version=self._version)
|
||||
elif self._action == "update":
|
||||
ok, msg = registry.update(self._tool_id)
|
||||
elif self._action == "uninstall":
|
||||
ok, msg = registry.uninstall(self._tool_id)
|
||||
else:
|
||||
ok, msg = False, f"Unknown action: {self._action}"
|
||||
except Exception as e:
|
||||
ok, msg = False, str(e)
|
||||
self.finished_signal.emit(self._tool_id, ok, msg)
|
||||
|
||||
|
||||
class _ManifestFetchThread(QThread):
|
||||
manifest_ready = Signal(list) # List[ToolDefinition]
|
||||
|
||||
def run(self):
|
||||
result = fetch_remote_manifest()
|
||||
if result:
|
||||
self.manifest_ready.emit(result)
|
||||
|
||||
|
||||
class _ReleaseFetchThread(QThread):
|
||||
releases_ready = Signal(str, list) # tool_id, List[dict]
|
||||
|
||||
def __init__(self, tool_id: str, github_repo: str):
|
||||
super().__init__()
|
||||
self._tool_id = tool_id
|
||||
self._github_repo = github_repo
|
||||
|
||||
def run(self):
|
||||
releases = fetch_release_list(self._github_repo)
|
||||
self.releases_ready.emit(self._tool_id, releases)
|
||||
|
||||
|
||||
# -- main screen -------------------------------------------------------------
|
||||
class ToolsHubScreen(ThreadLifecycleMixin, QWidget):
|
||||
"""Tools Hub: engine selection and third-party tool management."""
|
||||
|
||||
def __init__(self, stacked_widget=None, main_menu_index: int = 0, ttw_screen_index: int = 5, parent=None):
|
||||
super().__init__(parent)
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.ttw_screen_index = ttw_screen_index
|
||||
|
||||
self._cards: Dict[str, ToolCard] = {}
|
||||
self._action_thread: Optional[_ToolActionThread] = None
|
||||
self._version_thread: Optional[_VersionCheckThread] = None
|
||||
self._manifest_thread: Optional[_ManifestFetchThread] = None
|
||||
self._release_thread: Optional[_ReleaseFetchThread] = None
|
||||
self._active_engine_id = get_active_engine_id()
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
root = QVBoxLayout()
|
||||
root.setContentsMargins(30, 24, 30, 24)
|
||||
root.setSpacing(0)
|
||||
self.setLayout(root)
|
||||
|
||||
header_row = QHBoxLayout()
|
||||
self._btn_update_all = QPushButton("Update All")
|
||||
self._btn_update_all.setFixedSize(100, 30)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
self._btn_update_all.setEnabled(False)
|
||||
self._btn_update_all.clicked.connect(self._on_update_all)
|
||||
# Left spacer matches button width so title stays centred
|
||||
left_spacer = QWidget()
|
||||
left_spacer.setFixedWidth(100)
|
||||
header_row.addWidget(left_spacer)
|
||||
header_row.addStretch()
|
||||
title = QLabel("<b>Tools Hub</b>")
|
||||
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
|
||||
header_row.addWidget(title)
|
||||
header_row.addStretch()
|
||||
header_row.addWidget(self._btn_update_all)
|
||||
root.addLayout(header_row)
|
||||
|
||||
root.addSpacing(6)
|
||||
|
||||
disclaimer = QLabel(
|
||||
"Some of these tools are developed and maintained by their respective authors, "
|
||||
"independently of Jackify. Jackify provides download and update management "
|
||||
"as a convenience only. The Jackify project offers no warranty or support "
|
||||
"for third-party tools."
|
||||
)
|
||||
disclaimer.setWordWrap(True)
|
||||
disclaimer.setStyleSheet("color: #aaa; font-size: 12px;")
|
||||
root.addWidget(disclaimer)
|
||||
|
||||
root.addSpacing(10)
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(2)
|
||||
sep.setStyleSheet("background: #fff;")
|
||||
root.addWidget(sep)
|
||||
root.addSpacing(12)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.NoFrame)
|
||||
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
self._list_widget = QWidget()
|
||||
self._list_widget.setStyleSheet("background: transparent;")
|
||||
self._list_layout = QVBoxLayout()
|
||||
self._list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._list_layout.setSpacing(6)
|
||||
self._list_widget.setLayout(self._list_layout)
|
||||
|
||||
scroll.setWidget(self._list_widget)
|
||||
root.addWidget(scroll, stretch=1)
|
||||
|
||||
root.addSpacing(12)
|
||||
back_row = QHBoxLayout()
|
||||
back_row.addStretch()
|
||||
back_btn = QPushButton("Back to Main Menu")
|
||||
back_btn.setFixedSize(160, 34)
|
||||
back_btn.setStyleSheet(
|
||||
f"QPushButton {{ background-color: {_C_BACK}; color: white; border: none; "
|
||||
f"border-radius: 6px; font-size: 12px; font-weight: bold; }}"
|
||||
f"QPushButton:hover {{ background-color: #5a6578; }}"
|
||||
f"QPushButton:pressed {{ background-color: {JACKIFY_COLOR_BLUE}; }}"
|
||||
)
|
||||
back_btn.clicked.connect(self._go_back)
|
||||
back_row.addWidget(back_btn)
|
||||
back_row.addStretch()
|
||||
root.addLayout(back_row)
|
||||
|
||||
# card list management
|
||||
|
||||
def _rebuild_card_list(self):
|
||||
while self._list_layout.count():
|
||||
item = self._list_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
self._cards.clear()
|
||||
|
||||
statuses = ToolRegistry().get_all_statuses()
|
||||
engines = [s for s in statuses if s.definition.is_engine]
|
||||
tools = [s for s in statuses if not s.definition.is_engine]
|
||||
|
||||
if engines:
|
||||
self._list_layout.addWidget(section_header("Engine"))
|
||||
self._list_layout.addSpacing(4)
|
||||
for s in engines:
|
||||
self._add_card(s)
|
||||
self._list_layout.addSpacing(10)
|
||||
|
||||
if tools:
|
||||
self._list_layout.addWidget(section_header("Tools"))
|
||||
self._list_layout.addSpacing(4)
|
||||
for s in tools:
|
||||
self._add_card(s)
|
||||
|
||||
placeholder = QLabel("More tools coming soon")
|
||||
placeholder.setAlignment(Qt.AlignCenter)
|
||||
placeholder.setStyleSheet(
|
||||
"color: #555; font-size: 12px; font-style: italic; "
|
||||
"background-color: #222; border: 1px dashed #333; "
|
||||
"border-radius: 6px; padding: 10px;"
|
||||
)
|
||||
self._list_layout.addWidget(placeholder)
|
||||
|
||||
self._list_layout.addStretch()
|
||||
|
||||
def _add_card(self, status: ToolStatus):
|
||||
card = ToolCard(status, self._active_engine_id)
|
||||
card.action_requested.connect(self._on_action)
|
||||
card.engine_activated.connect(self._on_engine_activated)
|
||||
self._cards[status.definition.tool_id] = card
|
||||
self._list_layout.addWidget(card)
|
||||
|
||||
# show / manifest / version check
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
try:
|
||||
mw = self.window()
|
||||
if mw:
|
||||
set_responsive_minimum(mw, min_width=960, min_height=520)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_engine_id = get_active_engine_id()
|
||||
self._rebuild_card_list()
|
||||
self._start_manifest_fetch()
|
||||
self._start_version_check()
|
||||
|
||||
def _start_manifest_fetch(self):
|
||||
if self._manifest_thread and self._manifest_thread.isRunning():
|
||||
return
|
||||
self._manifest_thread = _ManifestFetchThread()
|
||||
self._manifest_thread.manifest_ready.connect(self._on_manifest_ready)
|
||||
self._manifest_thread.start()
|
||||
|
||||
def _on_manifest_ready(self, definitions: List[ToolDefinition]):
|
||||
current_ids = set(self._cards.keys())
|
||||
new_ids = {d.tool_id for d in definitions}
|
||||
apply_remote_manifest(definitions)
|
||||
if current_ids != new_ids:
|
||||
if self._version_thread and self._version_thread.isRunning():
|
||||
self._version_thread.quit()
|
||||
self._rebuild_card_list()
|
||||
self._start_version_check()
|
||||
|
||||
def _start_version_check(self):
|
||||
if self._version_thread and self._version_thread.isRunning():
|
||||
return
|
||||
self._version_thread = _VersionCheckThread()
|
||||
self._version_thread.version_ready.connect(self._on_version_ready)
|
||||
self._version_thread.start()
|
||||
|
||||
def _on_version_ready(self, tool_id: str, tag: str):
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
has_update = card.set_latest_version(tag)
|
||||
if has_update:
|
||||
self._btn_update_all.setEnabled(True)
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE))
|
||||
any_updates = any(c._status.update_available for c in self._cards.values())
|
||||
main_menu = self._get_main_menu()
|
||||
if main_menu:
|
||||
main_menu.notify_tool_updates(any_updates)
|
||||
|
||||
def _get_main_menu(self):
|
||||
try:
|
||||
from jackify.frontends.gui.screens.main_menu import MainMenu
|
||||
w = self.window()
|
||||
if w and hasattr(w, 'main_menu'):
|
||||
return w.main_menu
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# engine activation
|
||||
|
||||
def _on_engine_activated(self, tool_id: str):
|
||||
self._active_engine_id = tool_id
|
||||
for card in self._cards.values():
|
||||
card.set_active_engine(tool_id)
|
||||
card = self._cards.get(tool_id)
|
||||
name = card._status.definition.display_name if card else tool_id
|
||||
MessageService.information(self, "Engine Changed", f"{name} is now the active engine.")
|
||||
|
||||
# action dispatch
|
||||
|
||||
def _on_action(self, tool_id: str, action: str):
|
||||
if action == "launch_jackify_ui":
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.ttw_screen_index)
|
||||
ttw_screen = self.stacked_widget.widget(self.ttw_screen_index)
|
||||
if hasattr(ttw_screen, 'main_menu_index'):
|
||||
ttw_screen.main_menu_index = self.stacked_widget.indexOf(self)
|
||||
return
|
||||
if self._action_thread and self._action_thread.isRunning():
|
||||
MessageService.information(self, "Busy", "Another operation is running. Please wait.")
|
||||
return
|
||||
|
||||
if action == "downgrade":
|
||||
self._start_downgrade_flow(tool_id)
|
||||
return
|
||||
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
label_map = {
|
||||
"install": "Installing...", "update": "Updating...", "uninstall": "Removing...",
|
||||
}
|
||||
card.set_busy(True, label_map.get(action, "Working..."))
|
||||
self._action_thread = _ToolActionThread(tool_id, action)
|
||||
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||
self._action_thread.start()
|
||||
|
||||
def _on_action_finished(self, tool_id: str, success: bool, message: str):
|
||||
self._action_thread = None
|
||||
card = self._cards.get(tool_id)
|
||||
if success:
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if status and status.installed and card:
|
||||
card.mark_installed(status.installed_version or "")
|
||||
if status.latest_version:
|
||||
card.set_latest_version(status.latest_version)
|
||||
elif card:
|
||||
card.mark_uninstalled()
|
||||
if message:
|
||||
MessageService.information(self, "Done", message)
|
||||
else:
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
MessageService.warning(self, "Failed", message)
|
||||
|
||||
def _on_update_all(self):
|
||||
updates = [tid for tid, card in self._cards.items()
|
||||
if card._status.installed and card._status.update_available]
|
||||
if not updates:
|
||||
return
|
||||
names = ", ".join(self._cards[tid]._status.definition.display_name for tid in updates)
|
||||
if MessageService.question(self, "Update All", f"Update the following tools?\n\n{names}") != QMessageBox.Yes:
|
||||
return
|
||||
self._pending_updates: List[str] = updates
|
||||
self._run_next_update()
|
||||
|
||||
def _run_next_update(self):
|
||||
if not self._pending_updates:
|
||||
return
|
||||
tool_id = self._pending_updates.pop(0)
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_busy(True, "Updating...")
|
||||
self._action_thread = _ToolActionThread(tool_id, "update")
|
||||
self._action_thread.finished_signal.connect(self._on_update_all_step)
|
||||
self._action_thread.start()
|
||||
|
||||
def _on_update_all_step(self, tool_id: str, success: bool, message: str):
|
||||
self._action_thread = None
|
||||
self._on_action_finished(tool_id, success, message if not success else "")
|
||||
if getattr(self, "_pending_updates", []):
|
||||
self._run_next_update()
|
||||
else:
|
||||
any_remaining = any(c._status.installed and c._status.update_available for c in self._cards.values())
|
||||
self._btn_update_all.setEnabled(any_remaining)
|
||||
if not any_remaining:
|
||||
self._btn_update_all.setStyleSheet(btn_style(_C_UPDATE, disabled=True))
|
||||
|
||||
def _start_downgrade_flow(self, tool_id: str):
|
||||
card = self._cards.get(tool_id)
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
if not status:
|
||||
return
|
||||
if card:
|
||||
card.set_busy(True, "Fetching releases...")
|
||||
self._release_thread = _ReleaseFetchThread(tool_id, status.definition.github_repo)
|
||||
self._release_thread.releases_ready.connect(self._on_releases_ready)
|
||||
self._release_thread.start()
|
||||
|
||||
def _on_releases_ready(self, tool_id: str, releases: list):
|
||||
card = self._cards.get(tool_id)
|
||||
if card:
|
||||
card.set_busy(False)
|
||||
if not releases:
|
||||
MessageService.warning(self, "Change Version", "Could not fetch release list from GitHub.")
|
||||
return
|
||||
status = ToolRegistry().get_status(tool_id)
|
||||
current = status.installed_version if status else None
|
||||
version = self._show_version_picker(tool_id, releases, current)
|
||||
if not version:
|
||||
return
|
||||
if card:
|
||||
card.set_busy(True, "Changing version...")
|
||||
self._action_thread = _ToolActionThread(tool_id, "install", version=version)
|
||||
self._action_thread.finished_signal.connect(self._on_action_finished)
|
||||
self._action_thread.start()
|
||||
|
||||
def _show_version_picker(self, tool_id: str, releases: list, current_version: Optional[str]) -> Optional[str]:
|
||||
dlg = QDialog(self.window())
|
||||
dlg.setWindowTitle("Select Version")
|
||||
dlg.setWindowModality(Qt.ApplicationModal)
|
||||
dlg.setMinimumWidth(360)
|
||||
dlg.setStyleSheet(
|
||||
"QDialog { background-color: #232323; color: #e0e0e0; }"
|
||||
"QLabel { color: #e0e0e0; background: transparent; border: none; }"
|
||||
"QComboBox { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; "
|
||||
" border-radius: 4px; padding: 4px 8px; }"
|
||||
"QComboBox QAbstractItemView { background-color: #2a2a2a; color: #e0e0e0; "
|
||||
" selection-background-color: #3a3a3a; }"
|
||||
)
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
if current_version:
|
||||
lbl = QLabel(f"Currently installed: <b>{current_version}</b>")
|
||||
lbl.setTextFormat(Qt.RichText)
|
||||
layout.addWidget(lbl)
|
||||
combo = QComboBox()
|
||||
for rel in releases:
|
||||
tag = rel.get("tag_name") or rel.get("name", "")
|
||||
date = (rel.get("published_at") or "")[:10]
|
||||
combo.addItem(f"{tag} ({date})", userData=tag)
|
||||
layout.addWidget(combo)
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(1)
|
||||
sep.setStyleSheet("background: #3a3a3a; border: none;")
|
||||
layout.addWidget(sep)
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setFixedSize(90, 30)
|
||||
cancel_btn.setStyleSheet(btn_style(_C_BACK))
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
install_btn = QPushButton("Install")
|
||||
install_btn.setFixedSize(90, 30)
|
||||
install_btn.setStyleSheet(btn_style(_C_INSTALL))
|
||||
install_btn.setDefault(True)
|
||||
install_btn.clicked.connect(dlg.accept)
|
||||
btn_row.addWidget(install_btn)
|
||||
layout.addLayout(btn_row)
|
||||
if dlg.exec() != QDialog.Accepted:
|
||||
return None
|
||||
return combo.currentData()
|
||||
|
||||
def _go_back(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def cleanup_processes(self):
|
||||
self._park_all_threads()
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Tools Hub card widget.
|
||||
|
||||
Per-tool card showing status badge, version, and action buttons.
|
||||
Engines show Set Active / Active badge; tools with can_launch show Launch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFrame, QHBoxLayout, QLabel,
|
||||
QMenu, QPushButton, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from jackify.backend.services.tool_registry import ToolRegistry, ToolStatus, set_active_engine_id
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_C_INSTALL = "#1a5fa8"
|
||||
_C_UPDATE = "#4a5568"
|
||||
_C_LAUNCH = "#1a5fa8"
|
||||
_C_SET_ACTIVE = "#4a5568"
|
||||
_C_BACK = "#4a5568"
|
||||
_C_DISABLED = "#333"
|
||||
|
||||
_BADGE_NOT_INSTALLED = ("#555", "#ccc")
|
||||
_BADGE_UP_TO_DATE = ("#1a3545", "#5fb8c8")
|
||||
_BADGE_UPDATE_AVAIL = ("#5a3d00", "#f0c040")
|
||||
_BADGE_ACTIVE = ("#0e3d5a", JACKIFY_COLOR_BLUE)
|
||||
|
||||
|
||||
def btn_style(colour: str, disabled: bool = False, width: int = 90) -> str:
|
||||
bg = _C_DISABLED if disabled else colour
|
||||
hover = "#444" if disabled else colour
|
||||
return (
|
||||
f"QPushButton {{ background-color: {bg}; color: {'#666' if disabled else 'white'}; "
|
||||
f"border: none; border-radius: 4px; font-size: 11px; font-weight: bold; "
|
||||
f"padding: 4px 8px; min-width: {width}px; }}"
|
||||
f"QPushButton:hover {{ background-color: {hover}; }}"
|
||||
)
|
||||
|
||||
|
||||
def section_header(text: str) -> QLabel:
|
||||
lbl = QLabel(text.upper())
|
||||
lbl.setStyleSheet(
|
||||
"color: #777; font-size: 10px; font-weight: bold; letter-spacing: 1px; "
|
||||
"background: transparent; border: none; padding: 0;"
|
||||
)
|
||||
return lbl
|
||||
|
||||
|
||||
class ToolCard(QFrame):
|
||||
action_requested = Signal(str, str) # tool_id, action
|
||||
engine_activated = Signal(str) # tool_id
|
||||
|
||||
def __init__(self, status: ToolStatus, active_engine_id: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._tool_id = status.definition.tool_id
|
||||
self._status = status
|
||||
self._active_engine_id = active_engine_id
|
||||
self._busy = False
|
||||
self._busy_label: Optional[str] = None
|
||||
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setStyleSheet(
|
||||
"QFrame { background-color: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }"
|
||||
)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(14, 7, 14, 7)
|
||||
outer.setSpacing(12)
|
||||
|
||||
info_col = QVBoxLayout()
|
||||
info_col.setSpacing(2)
|
||||
self._name_label = QLabel(f"<b>{status.definition.display_name}</b>")
|
||||
self._name_label.setStyleSheet("color: #e0e0e0; font-size: 13px; background: transparent; border: none;")
|
||||
info_col.addWidget(self._name_label)
|
||||
desc_label = QLabel(status.definition.description)
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setStyleSheet("color: #888; font-size: 11px; background: transparent; border: none;")
|
||||
info_col.addWidget(desc_label)
|
||||
info_w = QWidget()
|
||||
info_w.setLayout(info_col)
|
||||
info_w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
info_w.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(info_w, stretch=3)
|
||||
|
||||
centre_col = QVBoxLayout()
|
||||
centre_col.setSpacing(4)
|
||||
centre_col.setAlignment(Qt.AlignCenter)
|
||||
self._badge = QLabel()
|
||||
self._badge.setAlignment(Qt.AlignCenter)
|
||||
self._badge.setFixedWidth(140)
|
||||
self._badge.setStyleSheet("border-radius: 3px; padding: 2px 6px; font-size: 11px; font-weight: bold;")
|
||||
centre_col.addWidget(self._badge, alignment=Qt.AlignCenter)
|
||||
self._version_label = QLabel()
|
||||
self._version_label.setAlignment(Qt.AlignCenter)
|
||||
self._version_label.setStyleSheet("color: #777; font-size: 10px; background: transparent; border: none;")
|
||||
centre_col.addWidget(self._version_label, alignment=Qt.AlignCenter)
|
||||
centre_w = QWidget()
|
||||
centre_w.setLayout(centre_col)
|
||||
centre_w.setFixedWidth(160)
|
||||
centre_w.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(centre_w)
|
||||
|
||||
btn_col = QVBoxLayout()
|
||||
btn_col.setSpacing(3)
|
||||
btn_col.setAlignment(Qt.AlignCenter)
|
||||
self._btn_primary = QPushButton()
|
||||
self._btn_primary.setFixedWidth(100)
|
||||
self._btn_primary.clicked.connect(self._on_primary)
|
||||
btn_col.addWidget(self._btn_primary)
|
||||
self._btn_update = QPushButton("Update")
|
||||
self._btn_update.setFixedWidth(100)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_update.clicked.connect(lambda: self.action_requested.emit(self._tool_id, "update"))
|
||||
btn_col.addWidget(self._btn_update)
|
||||
self._btn_more = QPushButton("...")
|
||||
self._btn_more.setFixedWidth(100)
|
||||
self._btn_more.setStyleSheet(btn_style(_C_BACK))
|
||||
self._btn_more.clicked.connect(self._on_more)
|
||||
btn_col.addWidget(self._btn_more)
|
||||
btn_w = QWidget()
|
||||
btn_w.setLayout(btn_col)
|
||||
btn_w.setFixedWidth(120)
|
||||
btn_w.setStyleSheet("background: transparent; border: none;")
|
||||
outer.addWidget(btn_w)
|
||||
|
||||
self.setLayout(outer)
|
||||
self._refresh_ui()
|
||||
|
||||
def _refresh_ui(self):
|
||||
defn = self._status.definition
|
||||
installed = self._status.installed
|
||||
update_avail = self._status.update_available
|
||||
is_active = defn.is_engine and self._active_engine_id == self._tool_id
|
||||
|
||||
if self._busy:
|
||||
self._badge.setText("Working...")
|
||||
self._badge.setStyleSheet(
|
||||
"background-color: #555; color: #ccc; border-radius: 3px; "
|
||||
"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
|
||||
)
|
||||
self._btn_primary.setText(self._busy_label or "Working...")
|
||||
self._btn_primary.setEnabled(False)
|
||||
self._btn_primary.setVisible(True)
|
||||
self._btn_update.setVisible(False)
|
||||
self._btn_more.setEnabled(False)
|
||||
return
|
||||
|
||||
if defn.is_engine and is_active:
|
||||
bg, fg, badge_text = *_BADGE_ACTIVE, "Active Engine"
|
||||
elif not installed:
|
||||
bg, fg, badge_text = *_BADGE_NOT_INSTALLED, "Not Installed"
|
||||
elif update_avail:
|
||||
bg, fg, badge_text = *_BADGE_UPDATE_AVAIL, "Update Available"
|
||||
else:
|
||||
bg, fg, badge_text = *_BADGE_UP_TO_DATE, "Installed"
|
||||
self._badge.setText(badge_text)
|
||||
self._badge.setStyleSheet(
|
||||
f"background-color: {bg}; color: {fg}; border-radius: 3px; "
|
||||
f"padding: 2px 6px; font-size: 11px; font-weight: bold; border: none;"
|
||||
)
|
||||
|
||||
iv = self._status.installed_version or "-"
|
||||
lv = self._status.latest_version or "checking..."
|
||||
self._version_label.setText(
|
||||
f"Installed: {iv}\nLatest: {lv}" if installed else f"Latest: {lv}"
|
||||
)
|
||||
|
||||
if not installed:
|
||||
self._btn_primary.setText("Install")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_INSTALL))
|
||||
self._btn_primary.setEnabled(not self._busy)
|
||||
self._btn_primary.setVisible(True)
|
||||
elif defn.is_engine:
|
||||
if is_active:
|
||||
self._btn_primary.setText("Active")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_DISABLED, disabled=True))
|
||||
self._btn_primary.setEnabled(False)
|
||||
else:
|
||||
self._btn_primary.setText("Set Active")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_SET_ACTIVE))
|
||||
self._btn_primary.setEnabled(not self._busy)
|
||||
self._btn_primary.setVisible(True)
|
||||
elif defn.can_launch:
|
||||
self._btn_primary.setText("Launch")
|
||||
self._btn_primary.setStyleSheet(btn_style(_C_LAUNCH))
|
||||
self._btn_primary.setEnabled(not self._busy)
|
||||
self._btn_primary.setVisible(True)
|
||||
else:
|
||||
self._btn_primary.setVisible(False)
|
||||
|
||||
self._btn_update.setVisible(installed and update_avail and not self._busy)
|
||||
if installed and update_avail:
|
||||
self._btn_update.setStyleSheet(btn_style(_C_UPDATE))
|
||||
self._btn_more.setEnabled(not self._busy)
|
||||
|
||||
def set_latest_version(self, tag: str) -> bool:
|
||||
self._status.latest_version = tag
|
||||
if self._status.installed and self._status.installed_version and tag != "unknown":
|
||||
self._status.update_available = tag.lstrip("v") != self._status.installed_version.lstrip("v")
|
||||
self._refresh_ui()
|
||||
return self._status.update_available
|
||||
|
||||
def set_active_engine(self, active_id: str):
|
||||
self._active_engine_id = active_id
|
||||
self._refresh_ui()
|
||||
|
||||
def set_busy(self, busy: bool, label: Optional[str] = None):
|
||||
self._busy = busy
|
||||
self._busy_label = label if busy else None
|
||||
self._refresh_ui()
|
||||
|
||||
def mark_installed(self, version: str):
|
||||
self._status.installed = True
|
||||
self._status.installed_version = version
|
||||
self._status.update_available = False
|
||||
self._busy = False
|
||||
self._busy_label = None
|
||||
self._refresh_ui()
|
||||
|
||||
def mark_uninstalled(self):
|
||||
self._status.installed = False
|
||||
self._status.installed_version = None
|
||||
self._status.update_available = False
|
||||
self._busy = False
|
||||
self._busy_label = None
|
||||
self._refresh_ui()
|
||||
|
||||
def _prompt_uninstall(self, display_name: str):
|
||||
dlg = QDialog(self.window())
|
||||
dlg.setWindowTitle("Uninstall Tool")
|
||||
dlg.setWindowModality(Qt.ApplicationModal)
|
||||
dlg.setAttribute(Qt.WA_DeleteOnClose)
|
||||
dlg.setMinimumWidth(340)
|
||||
dlg.setStyleSheet(
|
||||
"QDialog { background-color: #232323; color: #e0e0e0; }"
|
||||
"QLabel { color: #e0e0e0; font-size: 13px; background: transparent; border: none; }"
|
||||
)
|
||||
layout = QVBoxLayout(dlg)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(16)
|
||||
msg = QLabel(f"Uninstall <b>{display_name}</b>?<br><br>This will delete the installed files.")
|
||||
msg.setTextFormat(Qt.RichText)
|
||||
msg.setWordWrap(True)
|
||||
layout.addWidget(msg)
|
||||
sep = QLabel()
|
||||
sep.setFixedHeight(1)
|
||||
sep.setStyleSheet("background: #3a3a3a; border: none;")
|
||||
layout.addWidget(sep)
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.setSpacing(8)
|
||||
btn_row.addStretch()
|
||||
cancel_btn = QPushButton("Cancel")
|
||||
cancel_btn.setFixedSize(90, 30)
|
||||
cancel_btn.setStyleSheet(btn_style(_C_BACK))
|
||||
cancel_btn.setDefault(True)
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
uninstall_btn = QPushButton("Uninstall")
|
||||
uninstall_btn.setFixedSize(90, 30)
|
||||
uninstall_btn.setStyleSheet(btn_style("#8b2020"))
|
||||
uninstall_btn.clicked.connect(dlg.accept)
|
||||
btn_row.addWidget(uninstall_btn)
|
||||
layout.addLayout(btn_row)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
self.action_requested.emit(self._tool_id, "uninstall")
|
||||
|
||||
def _on_primary(self):
|
||||
defn = self._status.definition
|
||||
if not self._status.installed:
|
||||
self.action_requested.emit(self._tool_id, "install")
|
||||
elif defn.is_engine:
|
||||
try:
|
||||
set_active_engine_id(self._tool_id)
|
||||
self.engine_activated.emit(self._tool_id)
|
||||
except Exception as e:
|
||||
MessageService.warning(self, "Error", str(e))
|
||||
elif defn.can_launch:
|
||||
if self._tool_id == "ttw_installer":
|
||||
self.action_requested.emit(self._tool_id, "launch_jackify_ui")
|
||||
else:
|
||||
self._launch()
|
||||
|
||||
def _launch(self):
|
||||
binary = ToolRegistry().get_binary_path(self._tool_id)
|
||||
if not binary:
|
||||
MessageService.warning(
|
||||
self, "Not Found",
|
||||
f"No executable found for {self._status.definition.display_name}. Try reinstalling it."
|
||||
)
|
||||
return
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(binary)], start_new_session=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
MessageService.warning(self, "Launch Failed", str(e))
|
||||
|
||||
def _on_more(self):
|
||||
menu = QMenu(self)
|
||||
menu.setStyleSheet(
|
||||
"QMenu { background-color: #2a2a2a; color: #e0e0e0; border: 1px solid #444; }"
|
||||
"QMenu::item:selected { background-color: #3a3a3a; }"
|
||||
"QMenu::item:disabled { color: #555; }"
|
||||
)
|
||||
defn = self._status.definition
|
||||
downgrade_action = menu.addAction("Change Version")
|
||||
downgrade_action.setEnabled(self._status.can_downgrade and not self._busy)
|
||||
uninstall_action = menu.addAction("Uninstall")
|
||||
uninstall_action.setEnabled(defn.can_uninstall and self._status.installed and not self._busy)
|
||||
|
||||
chosen = menu.exec(self._btn_more.mapToGlobal(self._btn_more.rect().bottomLeft()))
|
||||
if chosen == downgrade_action and downgrade_action.isEnabled():
|
||||
self.action_requested.emit(self._tool_id, "downgrade")
|
||||
elif chosen == uninstall_action and uninstall_action.isEnabled():
|
||||
QTimer.singleShot(0, lambda: self._prompt_uninstall(defn.display_name))
|
||||
@@ -24,36 +24,12 @@ class NonFocusMessageBox(QMessageBox):
|
||||
self._setup_no_focus_attributes(critical, safety_level)
|
||||
|
||||
def _setup_no_focus_attributes(self, critical, safety_level):
|
||||
"""Configure the message box to not steal focus"""
|
||||
# Set modality based on criticality and safety level
|
||||
if critical or safety_level == "high":
|
||||
self.setWindowModality(Qt.ApplicationModal)
|
||||
elif safety_level == "medium":
|
||||
self.setWindowModality(Qt.NonModal)
|
||||
else:
|
||||
self.setWindowModality(Qt.NonModal)
|
||||
|
||||
# Prevent focus stealing
|
||||
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
self.setWindowFlags(
|
||||
self.windowFlags() |
|
||||
Qt.WindowStaysOnTopHint |
|
||||
Qt.WindowDoesNotAcceptFocus
|
||||
)
|
||||
|
||||
# Set focus policy to prevent taking focus
|
||||
self.setFocusPolicy(Qt.NoFocus)
|
||||
|
||||
# Make sure child widgets don't steal focus either
|
||||
for child in self.findChildren(QWidget):
|
||||
child.setFocusPolicy(Qt.NoFocus)
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Override to ensure no focus stealing on show"""
|
||||
super().showEvent(event)
|
||||
# Ensure we don't steal focus
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
|
||||
class SafeMessageBox(NonFocusMessageBox):
|
||||
|
||||
@@ -194,7 +194,10 @@ class VNVAutomationController(QObject):
|
||||
logger.warning("VNV non-premium: Nexus API query failed, cannot open download manager")
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'])
|
||||
import os
|
||||
_strip = {"LD_LIBRARY_PATH", "LD_PRELOAD", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "PYTHONPATH", "PYTHONHOME"}
|
||||
clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
|
||||
subprocess.Popen(['xdg-open', 'https://www.nexusmods.com/newvegas/mods/62552?tab=files'], env=clean_env, start_new_session=True)
|
||||
except Exception:
|
||||
pass
|
||||
from .message_service import MessageService
|
||||
|
||||
@@ -8,8 +8,6 @@ DEBUG_BORDERS = False # Enable debug borders to visualize widget boundaries
|
||||
ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets')
|
||||
LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png')
|
||||
DISCLAIMER_TEXT = (
|
||||
"Disclaimer: Jackify is currently in an alpha state. This software is provided as-is, "
|
||||
"without any warranty or guarantee of stability. By using Jackify, you acknowledge that you do so at your own risk. "
|
||||
"The developers are not responsible for any data loss, system issues, or other problems that may arise from its use. "
|
||||
"Please back up your data and use caution."
|
||||
)
|
||||
"Jackify is provided as-is. Back up your modlist and game data before making changes. "
|
||||
"The developers are not responsible for data loss or other issues arising from its use."
|
||||
)
|
||||
@@ -104,6 +104,14 @@ class OverallProgressIndicator(QWidget):
|
||||
# Update status text
|
||||
display_text = progress.display_text
|
||||
from jackify.shared.progress_models import InstallationPhase, FileProgress
|
||||
# CLF3 Status events populate progress.message with detail not captured by display_text.
|
||||
# Use the message directly when display_text is just the bare phase label.
|
||||
if (
|
||||
progress.message
|
||||
and progress.message not in ("Processing...", "")
|
||||
and display_text in (progress.phase_name, progress.phase.value.title() if progress.phase else "", "Processing...", "")
|
||||
):
|
||||
display_text = progress.message
|
||||
if not display_text or display_text == "Processing...":
|
||||
if progress.phase == InstallationPhase.UNKNOWN:
|
||||
# Don't overwrite the banner with "Unknown" for unrecognized section headers;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""
|
||||
Summary progress widget for phase display (e.g. Installing 123/456).
|
||||
Summary progress widget for phase display (e.g. Queuing Archives 123/456).
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
|
||||
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
|
||||
|
||||
|
||||
class SummaryProgressWidget(QWidget):
|
||||
"""Widget showing summary progress for phases like Installing."""
|
||||
"""Single-row summary widget matching FileProgressItem layout."""
|
||||
|
||||
def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None):
|
||||
super().__init__(parent)
|
||||
@@ -16,8 +18,8 @@ class SummaryProgressWidget(QWidget):
|
||||
self.max_steps = max_steps
|
||||
self._target_step = current_step
|
||||
self._target_max = max_steps
|
||||
self._display_step = current_step
|
||||
self._display_max = max_steps
|
||||
self._display_step = float(current_step)
|
||||
self._display_max = float(max_steps)
|
||||
self._interpolation_timer = QTimer(self)
|
||||
self._interpolation_timer.timeout.connect(self._interpolate_counter)
|
||||
self._interpolation_timer.setInterval(16)
|
||||
@@ -26,12 +28,43 @@ class SummaryProgressWidget(QWidget):
|
||||
self._update_display()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
layout.setSpacing(8)
|
||||
|
||||
op_label = QLabel("»")
|
||||
op_label.setFixedWidth(20)
|
||||
op_label.setAlignment(Qt.AlignCenter)
|
||||
op_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;")
|
||||
layout.addWidget(op_label)
|
||||
|
||||
self.text_label = QLabel()
|
||||
self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;")
|
||||
layout.addWidget(self.text_label)
|
||||
self.text_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.text_label.setStyleSheet("color: #ccc; font-size: 11px;")
|
||||
layout.addWidget(self.text_label, 1)
|
||||
|
||||
self.percent_label = QLabel()
|
||||
self.percent_label.setFixedWidth(40)
|
||||
self.percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.percent_label.setStyleSheet("color: #aaa; font-size: 11px;")
|
||||
layout.addWidget(self.percent_label)
|
||||
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setFixedHeight(12)
|
||||
self.progress_bar.setFixedWidth(80)
|
||||
self.progress_bar.setTextVisible(False)
|
||||
self.progress_bar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
border: 1px solid #444;
|
||||
border-radius: 2px;
|
||||
background-color: #1a1a1a;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {JACKIFY_COLOR_BLUE};
|
||||
border-radius: 1px;
|
||||
}}
|
||||
""")
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
def _interpolate_counter(self):
|
||||
step_diff = self._target_step - self._display_step
|
||||
@@ -53,12 +86,16 @@ class SummaryProgressWidget(QWidget):
|
||||
display_max = int(round(self._display_max))
|
||||
|
||||
if display_max > 0:
|
||||
new_text = f"{self.phase_name} ({display_step}/{display_max})"
|
||||
self.text_label.setText(f"{self.phase_name} ({display_step}/{display_max})")
|
||||
pct = int(display_step / display_max * 100)
|
||||
if self.progress_bar.maximum() == 0:
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(pct)
|
||||
self.percent_label.setText(f"{pct}%")
|
||||
else:
|
||||
new_text = f"{self.phase_name}"
|
||||
|
||||
if self.text_label.text() != new_text:
|
||||
self.text_label.setText(new_text)
|
||||
self.text_label.setText(self.phase_name)
|
||||
self.progress_bar.setValue(0)
|
||||
self.percent_label.setText("")
|
||||
|
||||
def update_progress(self, current_step: int, max_steps: int):
|
||||
self._target_step = current_step
|
||||
|
||||
@@ -48,7 +48,7 @@ class UnsupportedGameDialog(QDialog):
|
||||
icon_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
|
||||
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
icon_label.setFixedSize(32, 32)
|
||||
icon_label.setStyleSheet("color: #e67e22;")
|
||||
icon_label.setStyleSheet("color: #f0c040;")
|
||||
title_layout.addWidget(icon_label)
|
||||
title_label = QLabel("<b>VR Platform Notice</b>" if self.vr_warning else "<b>Game Support Notice</b>")
|
||||
title_label.setFont(QFont("Arial", 11, QFont.Weight.Bold))
|
||||
@@ -73,7 +73,7 @@ class UnsupportedGameDialog(QDialog):
|
||||
message_text.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #23272e;
|
||||
color: #f8f9fa;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
@@ -191,10 +191,10 @@ class UnsupportedGameDialog(QDialog):
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #23272e;
|
||||
color: #f8f9fa;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
QLabel {
|
||||
color: #f8f9fa;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user