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

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
+2 -1
View File
@@ -6,5 +6,6 @@ Custom dialogs for the Jackify GUI application.
from .completion_dialog import NextStepsDialog
from .success_dialog import SuccessDialog
from .verification_results_dialog import VerificationResultsDialog
__all__ = ['NextStepsDialog', 'SuccessDialog']
__all__ = ['NextStepsDialog', 'SuccessDialog', 'VerificationResultsDialog']
@@ -112,8 +112,8 @@ class NextStepsDialog(QDialog):
content_text.setReadOnly(True)
content_text.setStyleSheet(
"QTextEdit { "
" background-color: #f8f9fa; "
" border: 1px solid #dee2e6; "
" background-color: #2a2a2a; "
" border: 1px solid #3a3a3a; "
" border-radius: 6px; "
" padding: 12px; "
" font-family: 'Segoe UI', Arial, sans-serif; "
@@ -139,7 +139,7 @@ class NextStepsDialog(QDialog):
return_btn.clicked.connect(self.accept) # This will close dialog and return to menu
return_btn.setStyleSheet(
"QPushButton { "
" background-color: #3498db; "
" background-color: #1a5fa8; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -147,10 +147,10 @@ class NextStepsDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #2980b9; "
" background-color: #2470b0; "
"} "
"QPushButton:pressed { "
" background-color: #21618c; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(return_btn)
@@ -163,7 +163,7 @@ class NextStepsDialog(QDialog):
exit_btn.clicked.connect(self.reject) # This will close dialog and potentially exit app
exit_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -171,10 +171,10 @@ class NextStepsDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(exit_btn)
@@ -194,15 +194,12 @@ class NextStepsDialog(QDialog):
completion_title = "Modlist Configuration complete!" if is_existing else "Modlist Install and Configuration complete!"
completion_log = "Configure_Existing_Modlist_workflow.log" if is_existing else "Configure_New_Modlist_workflow.log"
completion_text = f"""Configuration completed successfully!
completion_text = f"""Configuration completed successfully!
{completion_title}
You should now be able to Launch '{self.modlist_name}' through Steam.
Congratulations and enjoy the game!
NOTE: If you experience ENB issues, consider using GE-Proton 10-14 instead of
Valve's Proton 10 (known ENB compatibility issues in Valve's Proton 10).
- You should now be able to Launch '{self.modlist_name}' through Steam.
- Congratulations and enjoy the game!
Detailed log available at: {get_jackify_logs_dir()}/{completion_log}"""
@@ -55,7 +55,7 @@ class ENBProtonDialog(QDialog):
"QFrame#enbCard { "
" background: #23272e; "
" border-radius: 12px; "
" border: 2px solid #e67e22; " # Orange border for warning
" border: 2px solid #f0c040;" # Orange border for warning
"}"
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
@@ -67,17 +67,26 @@ class ENBProtonDialog(QDialog):
"QLabel { "
" font-size: 24px; "
" font-weight: 700; "
" color: #e67e22; " # Orange warning color
" color: #f0c040;" # Orange warning color
" margin-bottom: 4px; "
"}"
)
card_layout.addWidget(title_label)
# Main warning message
warning_text = (
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
)
from jackify.backend.data.modlist_proton_requirements import get_proton_requirement
_proton_req = get_proton_requirement(self.modlist_name)
if _proton_req:
warning_text = (
f"<span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span> "
f"requires a specific Proton version for ENB compatibility:"
)
else:
warning_text = (
f"If you plan on using ENB as part of <span style='color:#3fb7d6; font-weight:600;'>{self.modlist_name}</span>, "
f"you will need to use one of the following Proton versions, otherwise you will have issues running the modlist:"
)
warning_label = QLabel(warning_text)
warning_label.setAlignment(Qt.AlignCenter)
warning_label.setWordWrap(True)
@@ -93,17 +102,28 @@ class ENBProtonDialog(QDialog):
warning_label.setTextFormat(Qt.RichText)
card_layout.addWidget(warning_label)
# Proton version list (in order of recommendation)
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
"• <b style='color: #2ecc71;'>Proton-CachyOS</b><br/>"
"• <b style='color: #3498db;'>GE-Proton 10-14</b> or <b style='color: #3498db;'>lower</b><br/>"
"• <b style='color: #f39c12;'>Proton 9</b> from Valve"
"</div>"
"</div>"
)
if _proton_req:
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
f"<div style='font-size: 16px; color: #3fd0ea; font-weight: 700; margin-bottom: 8px;'>{_proton_req['required']}</div>"
f"<div style='font-size: 13px; color: #b0b0b0;'>{_proton_req['note']}</div>"
"</div>"
)
else:
versions_text = (
"<div style='text-align: left; padding: 12px; background: #1a1d23; border-radius: 8px; margin: 8px 0;'>"
"<div style='font-size: 13px; color: #b0b0b0; margin-bottom: 8px;'><b style='color: #fff;'>(In order of recommendation)</b></div>"
"<div style='font-size: 14px; color: #fff; line-height: 1.8;'>"
"- <b style='color: #3fd0ea;'>Proton-CachyOS</b><br/>"
"- <b style='color: #aaa;'>GE-Proton</b><br/>"
"- <b style='color: #777;'>Proton 9</b> from Valve"
"</div>"
"<div style='font-size: 12px; color: #777; font-style: italic; margin-top: 10px;'>"
"Valve Proton 10 has known ENB compatibility issues."
"</div>"
"</div>"
)
versions_label = QLabel(versions_text)
versions_label.setAlignment(Qt.AlignLeft)
versions_label.setWordWrap(True)
@@ -118,26 +138,6 @@ class ENBProtonDialog(QDialog):
versions_label.setTextFormat(Qt.RichText)
card_layout.addWidget(versions_label)
# Additional note
note_text = (
"<div style='font-size: 12px; color: #95a5a6; font-style: italic; margin-top: 8px;'>"
"Note: Valve's Proton 10 has known ENB compatibility issues."
"</div>"
)
note_label = QLabel(note_text)
note_label.setAlignment(Qt.AlignCenter)
note_label.setWordWrap(True)
note_label.setStyleSheet(
"QLabel { "
" font-size: 12px; "
" color: #95a5a6; "
" font-style: italic; "
" margin-top: 8px; "
"}"
)
note_label.setTextFormat(Qt.RichText)
card_layout.addWidget(note_label)
layout.addStretch()
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addSpacing(20) # Add spacing between card and button
@@ -36,12 +36,12 @@ _STATUS_LABELS = {
_STATUS_COLOURS = {
'pending': '#808080',
'browser_opened': '#3498db',
'validating': '#f39c12',
'complete': '#27ae60',
'deferred': '#e67e22',
'skipped': '#e67e22',
'error': '#e74c3c',
'browser_opened': '#3fd0ea',
'validating': '#f0c040',
'complete': '#3fd0ea',
'deferred': '#f0c040',
'skipped': '#888',
'error': '#e05050',
}
# Column indices
@@ -61,6 +61,15 @@ def _fmt_size(n: int) -> str:
return f"{n:.1f} TB"
class _SizeTableItem(QTableWidgetItem):
"""QTableWidgetItem that sorts by raw byte count stored in UserRole."""
def __lt__(self, other: 'QTableWidgetItem') -> bool:
try:
return int(self.data(Qt.UserRole) or 0) < int(other.data(Qt.UserRole) or 0)
except (TypeError, ValueError):
return super().__lt__(other)
class _Bridge(QObject):
"""Tiny bridge so worker-thread callbacks can update the Qt table safely."""
item_updated = Signal(object) # DownloadItem
@@ -152,15 +161,17 @@ class ManualDownloadDialog(QDialog):
# Batch-insert new rows with viewport updates suspended to avoid O(n²) repaints
if new_items:
self._table.setSortingEnabled(False)
self._table.setUpdatesEnabled(False)
try:
start_row = self._table.rowCount()
self._table.setRowCount(start_row + len(new_items))
for i, item in enumerate(new_items):
self._fill_row(start_row + i, item)
self._row_map[item.file_name] = start_row + i
finally:
self._table.setUpdatesEnabled(True)
self._table.setSortingEnabled(True)
self._rebuild_row_map()
self._table.viewport().update()
self._refresh_header()
@@ -222,7 +233,9 @@ class ManualDownloadDialog(QDialog):
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.setSortingEnabled(True)
self._table.verticalHeader().setVisible(False)
self._table.horizontalHeader().sectionClicked.connect(self._on_header_clicked)
self._table.cellDoubleClicked.connect(self._on_row_double_clicked)
self._table.setStyleSheet(
"QTableWidget { background: #1a1d23; alternate-background-color: #1e2228; "
@@ -284,6 +297,14 @@ class ManualDownloadDialog(QDialog):
self._open_selected_btn.clicked.connect(self._on_open_selected)
btn_row.addWidget(self._open_selected_btn)
self._scan_now_btn = QPushButton("Scan Now")
self._scan_now_btn.setToolTip(
"Re-scan the watch directory for already-downloaded files.\n"
"Use this if a file downloaded successfully but was not detected automatically."
)
self._scan_now_btn.clicked.connect(self._on_scan_now)
btn_row.addWidget(self._scan_now_btn)
btn_row.addStretch()
self._start_pause_btn = QPushButton("Start")
@@ -308,9 +329,13 @@ class ManualDownloadDialog(QDialog):
def _fill_row(self, row: int, item: DownloadItem) -> None:
"""Populate cells for a pre-allocated row (row must already exist in the table)."""
from PySide6.QtGui import QColor
self._table.setItem(row, _COL_MOD, QTableWidgetItem(item.mod_name))
mod_cell = QTableWidgetItem(item.mod_name)
mod_cell.setData(Qt.UserRole, item.file_name) # stable ID for row lookups after sort
self._table.setItem(row, _COL_MOD, mod_cell)
self._table.setItem(row, _COL_NAME, QTableWidgetItem(item.file_name))
self._table.setItem(row, _COL_SIZE, QTableWidgetItem(_fmt_size(item.expected_size)))
size_cell = _SizeTableItem(_fmt_size(item.expected_size))
size_cell.setData(Qt.UserRole, item.expected_size)
self._table.setItem(row, _COL_SIZE, size_cell)
colour = _STATUS_COLOURS.get(item.status, '#808080')
status_cell = QTableWidgetItem(_STATUS_LABELS.get(item.status, item.status))
status_cell.setForeground(QColor(colour))
@@ -326,6 +351,26 @@ class ManualDownloadDialog(QDialog):
status_cell.setForeground(QColor(_STATUS_COLOURS.get(item.status, '#808080')))
status_cell.setToolTip(item.error_message or "")
def _rebuild_row_map(self) -> None:
self._row_map.clear()
for row in range(self._table.rowCount()):
cell = self._table.item(row, _COL_MOD)
if cell:
file_name = cell.data(Qt.UserRole)
if file_name:
self._row_map[file_name] = row
def _sync_pending_order_to_visual(self) -> None:
visual_order: dict[str, int] = {}
for row in range(self._table.rowCount()):
cell = self._table.item(row, _COL_MOD)
if cell:
file_name = cell.data(Qt.UserRole)
if file_name:
visual_order[file_name] = row
with self._manager._lock:
self._manager._items.sort(key=lambda i: visual_order.get(i.file_name, 999999))
def _refresh_header(self) -> None:
items = self._manager.items
total = len(items)
@@ -345,6 +390,10 @@ class ManualDownloadDialog(QDialog):
# Slots
# ------------------------------------------------------------------
def _on_header_clicked(self, logical_index: int) -> None:
self._rebuild_row_map()
self._sync_pending_order_to_visual()
def _on_item_updated_slot(self, item: DownloadItem) -> None:
row = self._row_map.get(item.file_name)
if row is not None:
@@ -369,7 +418,6 @@ class ManualDownloadDialog(QDialog):
self._folder_label.setText(chosen)
self._manager._watch_dir = self._watch_dir
self._manager._watcher._config.watch_directory = self._watch_dir
self._manager._watcher._known = {}
try:
cfg = ConfigHandler()
cfg.set("manual_download_watch_directory", str(self._watch_dir))
@@ -430,6 +478,16 @@ class ManualDownloadDialog(QDialog):
return
self._manager.reopen_item(file_name)
def _on_scan_now(self) -> None:
self._scan_now_btn.setEnabled(False)
self._scan_now_btn.setText("Scanning...")
self._manager.force_rescan()
from PySide6.QtCore import QTimer
QTimer.singleShot(2000, lambda: (
self._scan_now_btn.setEnabled(True),
self._scan_now_btn.setText("Scan Now"),
))
def _on_row_double_clicked(self, row: int, _column: int) -> None:
file_item = self._table.item(row, _COL_NAME)
if file_item is None:
@@ -0,0 +1,470 @@
"""NXM download dialog: modlist picker and download runner."""
import logging
from pathlib import Path
from typing import Optional, List, Dict, Tuple
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QCheckBox,
QPushButton,
QProgressBar,
QFrame,
)
from jackify.backend.services.nxm_url import NxmUrl
from jackify.frontends.gui.shared_theme import JACKIFY_COLOR_BLUE
import jackify.backend.services.nxm_session as nxm_session
logger = logging.getLogger(__name__)
_BG_DARK = "#16191d"
_BG_CARD = "#1e2228"
_BG_LIST = "#1a1d23"
_BORDER = "#333"
_TEXT = "#d0d0d0"
_TEXT_DIM = "#8f98a3"
_TEXT_BRIGHT = "#e0e0e0"
_ERROR = "#cc4444"
class _ModNameFetchThread(QThread):
name_ready = Signal(str)
def __init__(self, nxm: NxmUrl, parent=None):
super().__init__(parent)
self.nxm = nxm
def run(self) -> None:
try:
from jackify.backend.services.nexus_auth_service import NexusAuthService
svc = NexusAuthService()
token = svc.get_auth_token()
method = svc.get_auth_method() or "api_key"
except Exception:
try:
from jackify.backend.services.api_key_service import APIKeyService
token = APIKeyService().get_saved_api_key()
method = "api_key"
except Exception:
return
if not token:
return
try:
import requests
if method == "oauth":
headers = {"Authorization": f"Bearer {token}", "User-Agent": "jackify"}
else:
headers = {"apikey": token, "User-Agent": "jackify"}
url = (
f"https://api.nexusmods.com/v1/games/{self.nxm.game}"
f"/mods/{self.nxm.mod_id}.json"
)
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
name = resp.json().get("name", "")
if name:
self.name_ready.emit(name)
except Exception as e:
logger.debug("Mod name fetch failed: %s", e)
class _DownloadThread(QThread):
progress = Signal(int, int)
finished = Signal(bool, str)
def __init__(
self,
nxm: NxmUrl,
download_dir: Path,
auth_token: str,
auth_method: str,
parent=None,
):
super().__init__(parent)
self.nxm = nxm
self.download_dir = download_dir
self.auth_token = auth_token
self.auth_method = auth_method
def run(self) -> None:
from jackify.backend.services.nxm_downloader import (
get_nxm_download_url,
download_nxm_file,
filename_from_cdn_url,
)
cdn_url = get_nxm_download_url(self.nxm, self.auth_token, self.auth_method)
if not cdn_url:
self.finished.emit(False, "Could not resolve download URL from Nexus API.")
return
filename = filename_from_cdn_url(
cdn_url, f"mod_{self.nxm.mod_id}_file_{self.nxm.file_id}.zip"
)
ok, msg = download_nxm_file(cdn_url, self.download_dir, filename, self._on_progress)
self.finished.emit(ok, msg)
def _on_progress(self, downloaded: int, total: int) -> None:
self.progress.emit(downloaded, total)
class NxmDownloadDialog(QDialog):
"""Modlist picker and download runner for incoming nxm:// links.
When auto_start_modlist is provided the picker is hidden and the download
begins immediately - used when session memory has a remembered modlist.
"""
def __init__(
self,
nxm: NxmUrl,
modlists: List[Dict],
parent=None,
auto_start_modlist: Optional[Dict] = None,
):
super().__init__(parent)
self.nxm = nxm
self.modlists = modlists
self._thread: Optional[_DownloadThread] = None
self._name_thread: Optional[_ModNameFetchThread] = None
self._auto_start_modlist = auto_start_modlist
self.setWindowTitle("NXM Download")
self.setMinimumWidth(520)
self.setModal(False)
self.setStyleSheet(f"QDialog {{ background: {_BG_DARK}; color: {_TEXT}; }}")
self._build_ui(show_picker=auto_start_modlist is None)
if auto_start_modlist is None:
self._apply_session_memory()
self._fetch_mod_name()
# --- UI construction ---
def _build_ui(self, show_picker: bool = True) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.addWidget(self._make_header())
if show_picker:
layout.addWidget(self._make_section_label("Select modlist:"))
layout.addWidget(self._make_modlist_list())
layout.addWidget(self._make_dest_label())
layout.addWidget(self._make_session_row())
else:
name = self._auto_start_modlist.get("name", "")
lbl = QLabel(f"Downloading to: <span style='color:{JACKIFY_COLOR_BLUE}'>{name}</span>")
lbl.setTextFormat(Qt.RichText)
lbl.setStyleSheet(f"color: {_TEXT};")
layout.addWidget(lbl)
self._clear_session_btn = QPushButton("Change modlist (clear session)")
self._clear_session_btn.setFlat(True)
self._clear_session_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
self._clear_session_btn.clicked.connect(self._on_clear_and_reopen)
layout.addWidget(self._clear_session_btn)
layout.addWidget(self._make_progress_section())
layout.addWidget(self._make_separator())
layout.addLayout(self._make_button_row())
def _make_header(self) -> QFrame:
card = QFrame()
card.setStyleSheet(
f"QFrame {{ background: {_BG_CARD}; border-radius: 6px; border: 1px solid {_BORDER}; }}"
)
vbox = QVBoxLayout(card)
vbox.setContentsMargins(12, 10, 12, 10)
vbox.setSpacing(4)
self._title_label = QLabel(f"<b>NXM Download: Mod {self.nxm.mod_id}</b>")
self._title_label.setStyleSheet(f"color: {_TEXT_BRIGHT}; font-size: 13px; border: none;")
self._title_label.setTextFormat(Qt.RichText)
vbox.addWidget(self._title_label)
detail = QLabel(
f"Game: <span style='color:{JACKIFY_COLOR_BLUE}'>{self.nxm.game}</span>"
f" &nbsp;|&nbsp; Mod ID: {self.nxm.mod_id}"
f" &nbsp;|&nbsp; File ID: {self.nxm.file_id}"
)
detail.setTextFormat(Qt.RichText)
detail.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px; border: none;")
vbox.addWidget(detail)
return card
def _make_section_label(self, text: str) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
return lbl
def _make_modlist_list(self) -> QListWidget:
self._list = QListWidget()
self._list.setMaximumHeight(150)
self._list.setStyleSheet(
f"QListWidget {{ background: {_BG_LIST}; color: {_TEXT}; "
f"border: 1px solid {_BORDER}; border-radius: 4px; }}"
f"QListWidget::item:selected {{ background: #2a3a4a; color: {_TEXT_BRIGHT}; }}"
f"QListWidget::item:hover {{ background: #222830; }}"
)
for ml in self.modlists:
item = QListWidgetItem(ml["name"])
item.setData(Qt.UserRole, ml)
self._list.addItem(item)
self._list.currentItemChanged.connect(self._on_selection_changed)
return self._list
def _make_dest_label(self) -> QLabel:
self._dest_label = QLabel("Download directory: (none)")
self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
self._dest_label.setWordWrap(True)
return self._dest_label
def _make_session_row(self) -> QFrame:
container = QFrame()
container.setStyleSheet("QFrame { border: none; }")
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
self._remember_cb = QCheckBox("Remember for this session")
self._remember_cb.setStyleSheet(f"color: {_TEXT};")
row.addWidget(self._remember_cb)
row.addStretch()
self._clear_btn = QPushButton("Clear remembered modlist")
self._clear_btn.setFlat(True)
self._clear_btn.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
self._clear_btn.clicked.connect(self._on_clear_session)
row.addWidget(self._clear_btn)
self._update_clear_button()
return container
def _make_progress_section(self) -> QFrame:
container = QFrame()
container.setStyleSheet("QFrame { border: none; }")
vbox = QVBoxLayout(container)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.setSpacing(4)
self._status_label = QLabel("")
self._status_label.setStyleSheet(f"color: {_TEXT_DIM};")
self._status_label.setVisible(False)
self._status_label.setWordWrap(True)
self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
vbox.addWidget(self._status_label)
self._progress_bar = QProgressBar()
self._progress_bar.setStyleSheet(
f"QProgressBar {{ border: 1px solid {_BORDER}; border-radius: 4px; "
f"background: #2c2c2c; height: 10px; color: transparent; }}"
f"QProgressBar::chunk {{ background: {JACKIFY_COLOR_BLUE}; border-radius: 3px; }}"
)
self._progress_bar.setVisible(False)
vbox.addWidget(self._progress_bar)
return container
def _make_button_row(self) -> QHBoxLayout:
row = QHBoxLayout()
row.addStretch()
self._cancel_btn = QPushButton("Cancel")
self._cancel_btn.setStyleSheet(
f"QPushButton {{ background: #3a2020; color: {_TEXT}; border: 1px solid {_BORDER}; "
f"border-radius: 4px; padding: 5px 14px; }}"
f"QPushButton:hover {{ background: #5a2828; }}"
)
self._cancel_btn.clicked.connect(self.reject)
row.addWidget(self._cancel_btn)
self._download_btn = QPushButton("Download")
self._download_btn.setEnabled(False)
self._download_btn.setDefault(True)
self._download_btn.setStyleSheet(
f"QPushButton {{ background: {JACKIFY_COLOR_BLUE}; color: #000; font-weight: 600; "
f"border: none; border-radius: 4px; padding: 5px 18px; }}"
f"QPushButton:hover {{ background: #5ae0f5; }}"
f"QPushButton:disabled {{ background: #2a4a52; color: #555; }}"
)
self._download_btn.clicked.connect(self._on_download)
row.addWidget(self._download_btn)
return row
@staticmethod
def _make_separator() -> QFrame:
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet(f"color: {_BORDER};")
return sep
# --- Session memory ---
def showEvent(self, event) -> None:
super().showEvent(event)
if self._auto_start_modlist is not None:
QTimer.singleShot(0, self._start_auto_download)
def _start_auto_download(self) -> None:
self._start_download_for(self._auto_start_modlist)
def _on_clear_and_reopen(self) -> None:
nxm_session.clear_remembered_modlist()
self.reject()
dlg = NxmDownloadDialog(self.nxm, self.modlists, parent=self.parent())
dlg.show()
def _fetch_mod_name(self) -> None:
self._name_thread = _ModNameFetchThread(self.nxm, self)
self._name_thread.name_ready.connect(self._on_mod_name_ready)
self._name_thread.start()
def _on_mod_name_ready(self, name: str) -> None:
self._title_label.setText(f"<b>NXM Download: {name}</b>")
self._name_thread = None
def _apply_session_memory(self) -> None:
remembered = nxm_session.get_remembered_modlist()
if not remembered:
return
for i in range(self._list.count()):
if self._list.item(i).text() == remembered:
self._list.setCurrentRow(i)
self._remember_cb.setChecked(True)
break
def _on_clear_session(self) -> None:
nxm_session.clear_remembered_modlist()
self._remember_cb.setChecked(False)
self._update_clear_button()
def _update_clear_button(self) -> None:
self._clear_btn.setVisible(bool(nxm_session.get_remembered_modlist()))
def _on_selection_changed(self) -> None:
item = self._list.currentItem()
self._download_btn.setEnabled(item is not None)
if item:
modlist = item.data(Qt.UserRole)
self._update_dest_label(modlist)
def _update_dest_label(self, modlist: Dict) -> None:
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
modlist_dir = Path(modlist.get("modlist_dir", ""))
download_dir = resolve_mo2_download_dir(modlist_dir)
if download_dir:
self._dest_label.setText(f"Download directory: {download_dir}")
self._dest_label.setStyleSheet(f"color: {_TEXT_DIM}; font-size: 11px;")
else:
self._dest_label.setText("Download directory: not configured - run Configure first")
self._dest_label.setStyleSheet(f"color: {_ERROR}; font-size: 11px;")
# --- Download ---
def _on_download(self) -> None:
item = self._list.currentItem()
if not item:
return
modlist = item.data(Qt.UserRole)
if self._remember_cb.isChecked():
nxm_session.set_remembered_modlist(modlist["name"])
self._update_clear_button()
self._start_download_for(modlist)
def _start_download_for(self, modlist: Dict) -> None:
modlist_dir = Path(modlist.get("modlist_dir", ""))
from jackify.backend.services.nxm_downloader import resolve_mo2_download_dir
download_dir = resolve_mo2_download_dir(modlist_dir)
if not download_dir or not download_dir.exists():
self._set_status(
f"Download directory not found for {modlist['name']}. "
"Run Configure for this modlist first.",
error=True,
)
return
token, method = self._get_auth()
if not token:
self._set_status(
"Not logged in to Nexus. Please log in via Settings > Nexus Authentication.",
error=True,
)
return
self._set_downloading(True)
self._thread = _DownloadThread(self.nxm, download_dir, token, method, self)
self._thread.progress.connect(self._on_progress)
self._thread.finished.connect(self._on_download_finished)
self._thread.start()
def _get_auth(self) -> Tuple[Optional[str], str]:
"""Return (token, auth_method). auth_method is 'oauth' or 'api_key'."""
try:
from jackify.backend.services.nexus_auth_service import NexusAuthService
svc = NexusAuthService()
token = svc.get_auth_token()
method = svc.get_auth_method() or "api_key"
if token:
return token, method
except Exception as e:
logger.warning("NexusAuthService unavailable: %s", e)
try:
from jackify.backend.services.api_key_service import APIKeyService
key = APIKeyService().get_saved_api_key()
if key:
return key, "api_key"
except Exception as e:
logger.warning("APIKeyService unavailable: %s", e)
return None, "api_key"
def _on_progress(self, downloaded: int, total: int) -> None:
if total > 0:
self._progress_bar.setValue(int(downloaded * 100 / total))
def _on_download_finished(self, success: bool, message: str) -> None:
self._set_downloading(False)
self._thread = None
if success:
self._set_status("Download complete. MO2 will pick it up automatically.", error=False)
QTimer.singleShot(3000, self.accept)
else:
self._set_status(f"Download failed: {message}", error=True)
def _set_downloading(self, active: bool) -> None:
self._download_btn.setEnabled(not active)
self._cancel_btn.setEnabled(not active)
if hasattr(self, "_list"):
self._list.setEnabled(not active)
self._progress_bar.setVisible(active)
self._progress_bar.setValue(0)
if active:
self._set_status("Downloading...", error=False)
def _set_status(self, text: str, error: bool = False) -> None:
self._status_label.setText(text)
self._status_label.setVisible(bool(text))
colour = _ERROR if error else JACKIFY_COLOR_BLUE
self._status_label.setStyleSheet(f"color: {colour};")
def show_no_modlists_error() -> None:
"""Show a standalone error when no modlists are found."""
from PySide6.QtWidgets import QMessageBox
msg = QMessageBox()
msg.setWindowTitle("Jackify - NXM Handler")
msg.setIcon(QMessageBox.Warning)
msg.setText(
"No installed modlists found.\n\n"
"NXM download handling requires at least one modlist that has been "
"installed and configured with Jackify."
)
msg.exec()
@@ -62,7 +62,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"QFrame#protontricksCard { "
" background: #2d2323; "
" border-radius: 12px; "
" border: 2px solid #e74c3c; "
" border: 2px solid #8b2020; "
"}"
)
@@ -74,7 +74,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"QLabel { "
" font-size: 36px; "
" font-weight: bold; "
" color: #e74c3c; "
" color: #f0c040; "
" margin-bottom: 4px; "
"}"
)
@@ -87,7 +87,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
"QLabel { "
" font-size: 20px; "
" font-weight: 600; "
" color: #e74c3c; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -126,7 +126,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" text-align: center; "
"} "
"QProgressBar::chunk { "
" background-color: #4fc3f7; "
" background-color: #3fd0ea; "
" border-radius: 3px; "
"}"
)
@@ -139,7 +139,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.status_label.setStyleSheet(
"QLabel { "
" font-size: 14px; "
" color: #4fc3f7; "
" color: #3fd0ea; "
" margin: 8px 0; "
"}"
)
@@ -155,7 +155,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.flatpak_btn.clicked.connect(self._install_flatpak)
self.flatpak_btn.setStyleSheet(
"QPushButton { "
" background-color: #4fc3f7; "
" background-color: #1a5fa8; "
" color: white; "
" border: none; "
" border-radius: 6px; "
@@ -164,10 +164,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #3498db; "
" background-color: #2470b0; "
"} "
"QPushButton:pressed { "
" background-color: #2980b9; "
" background-color: #3fd0ea; "
"} "
"QPushButton:disabled { "
" background-color: #555; "
@@ -182,7 +182,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.native_btn.clicked.connect(self._show_native_guidance)
self.native_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 6px; "
@@ -191,10 +191,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(self.native_btn)
@@ -211,7 +211,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
self.redetect_btn.clicked.connect(self._redetect)
self.redetect_btn.setStyleSheet(
"QPushButton { "
" background-color: #27ae60; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -219,10 +219,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #229954; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #1e8449; "
" background-color: #3fd0ea; "
"}"
)
bottom_layout.addWidget(self.redetect_btn)
@@ -235,7 +235,7 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
exit_btn.clicked.connect(self._exit_app)
exit_btn.setStyleSheet(
"QPushButton { "
" background-color: #e74c3c; "
" background-color: #8b2020; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -243,10 +243,10 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #c0392b; "
" background-color: #a02828; "
"} "
"QPushButton:pressed { "
" background-color: #a93226; "
" background-color: #7a1a1a; "
"}"
)
bottom_layout.addWidget(exit_btn)
@@ -287,12 +287,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
if success:
self.status_label.setText("✓ Installation successful!")
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }")
# Auto-redetect after successful installation
self._redetect()
else:
self.status_label.setText(f"✗ Installation failed: {message}")
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }")
def _show_native_guidance(self):
"""Show native installation guidance"""
@@ -307,12 +307,12 @@ class ProtontricksErrorDialog(ThreadLifecycleMixin, QDialog):
if is_installed:
self.status_label.setText("✓ Protontricks found!")
self.status_label.setStyleSheet("QLabel { color: #27ae60; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #3fd0ea; font-size: 14px; margin: 8px 0; }")
self.status_label.setVisible(True)
self.accept() # Close dialog successfully
else:
self.status_label.setText("✗ Protontricks still not found")
self.status_label.setStyleSheet("QLabel { color: #e74c3c; font-size: 14px; margin: 8px 0; }")
self.status_label.setStyleSheet("QLabel { color: #e05050; font-size: 14px; margin: 8px 0; }")
self.status_label.setVisible(True)
def _exit_app(self):
@@ -86,7 +86,7 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.api_show_btn.setText("\U0001F441")
if checked:
self.api_key_edit.setEchoMode(QLineEdit.Normal)
self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }")
self.api_show_btn.setStyleSheet("QToolButton { color: #3fd0ea; }")
else:
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_show_btn.setStyleSheet("")
@@ -122,8 +122,8 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
self.config_handler.clear_api_key()
MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low")
def _on_api_key_changed(self, text):
api_key = text.strip()
def _on_api_key_changed(self):
api_key = self.api_key_edit.text().strip()
self.config_handler.save_api_key(api_key)
@@ -314,6 +314,9 @@ class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog
# Save auto tool compat preference
self.config_handler.set('auto_tool_compat', self.auto_tool_compat_checkbox.isChecked())
self.config_handler.set('force_github_updates', self.force_github_updates_checkbox.isChecked())
if getattr(self, 'clf3_default_checkbox', None):
from jackify.backend.services.tool_registry import set_active_engine_id
set_active_engine_id("clf3" if self.clf3_default_checkbox.isChecked() else "jackify-engine")
# Save component installation method preference
if self.winetricks_radio.isChecked():
@@ -153,7 +153,7 @@ class SettingsDialogTabsMixin:
api_key = self.config_handler.get_api_key()
self.api_key_edit.setText(api_key if api_key else "")
self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)")
self.api_key_edit.textChanged.connect(self._on_api_key_changed)
self.api_key_edit.editingFinished.connect(self._on_api_key_changed)
self.api_show_btn = QToolButton()
self.api_show_btn.setCheckable(True)
self.api_show_btn.setIcon(QIcon.fromTheme("view-visible"))
@@ -297,6 +297,20 @@ class SettingsDialogTabsMixin:
self.force_github_updates_checkbox.setStyleSheet("color: #fff;")
component_layout.addWidget(self.force_github_updates_checkbox)
self.clf3_default_checkbox = QCheckBox("Use CLF3 as default install engine")
self.clf3_default_checkbox.setToolTip(
"Use CLF3 (SulfurNitride) as the default engine for all installs. "
"CLF3 will be downloaded automatically if not already installed. "
"You can still override this per-install on the Install screen."
)
self.clf3_default_checkbox.setStyleSheet("color: #fff;")
try:
from jackify.backend.services.tool_registry import get_active_engine_id
self.clf3_default_checkbox.setChecked(get_active_engine_id() == "clf3")
except Exception:
pass
component_layout.addWidget(self.clf3_default_checkbox)
advanced_layout.addWidget(component_group)
advanced_layout.addStretch()
self.tab_widget.addTab(advanced_tab, "Advanced")
+132 -17
View File
@@ -31,22 +31,32 @@ class SuccessDialog(QDialog):
- Return and Exit buttons
"""
def __init__(self, modlist_name: str, workflow_type: str, time_taken: str, game_name: str = None, parent=None):
def __init__(
self,
modlist_name: str,
workflow_type: str,
time_taken: str,
game_name: str = None,
verification_results=None,
parent=None,
):
super().__init__(parent)
self.modlist_name = modlist_name
self.workflow_type = workflow_type
self.time_taken = time_taken
self.game_name = game_name
self.setWindowTitle("Success!")
self.verification_results = verification_results
self.setWindowTitle("Complete" if (verification_results and verification_results.failures) else "Success!")
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setFixedSize(500, 500)
self.setFixedWidth(500)
self.setMinimumHeight(400)
self.setWindowFlag(Qt.WindowDoesNotAcceptFocus, True)
self.setStyleSheet("QDialog { background: #181818; color: #fff; border-radius: 12px; }" )
layout = QVBoxLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(30, 20, 30, 20) # Reduced top/bottom margins to prevent truncation
layout.setContentsMargins(20, 20, 20, 20)
# --- Card background for content ---
card = QFrame(self)
@@ -69,22 +79,36 @@ class SuccessDialog(QDialog):
)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# Success title (less saturated green)
title_label = QLabel("Success!")
has_verify_failures = bool(
self.verification_results and self.verification_results.failures
)
title_text = "Complete" if has_verify_failures else "Success!"
title_color = "#f0c040" if has_verify_failures else "#3fd0ea"
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(
"QLabel { "
" font-size: 22px; "
" font-weight: 600; "
" color: #2ecc71; "
" margin-bottom: 2px; "
"}"
f"QLabel {{ "
f" font-size: 22px; "
f" font-weight: 600; "
f" color: {title_color}; "
f" margin-bottom: 2px; "
f"}}"
)
card_layout.addWidget(title_label)
# Personalized success message (modlist name in Jackify Blue, but less bold)
# Personalized message (modlist name in Jackify Blue, but less bold)
modlist_name_html = f'<span style="color:#3fb7d6; font-size:17px; font-weight:500;">{self.modlist_name}</span>'
if self.workflow_type == "install":
if has_verify_failures:
suffix_map = {
"install": "installed with issues - review verification results.",
"update": "updated with issues - review verification results.",
"configure_new": "configured with issues - review verification results.",
"configure_existing": "configuration updated with issues - review verification results.",
"tool_config": "tool compatibility configured with issues - review verification results.",
}
suffix_text = suffix_map.get(self.workflow_type, "completed with issues - review verification results.")
elif self.workflow_type == "install":
suffix_text = "installed successfully!"
elif self.workflow_type == "update":
suffix_text = "updated successfully!"
@@ -95,7 +119,6 @@ class SuccessDialog(QDialog):
elif self.workflow_type == "tool_config":
suffix_text = "tool compatibility configured successfully!"
else:
# Fallback for other workflow types
message_text = self._build_success_message()
suffix_text = message_text.replace(self.modlist_name, "").strip()
@@ -153,12 +176,16 @@ class SuccessDialog(QDialog):
)
card_layout.addWidget(next_steps_label)
# Verification results summary
if self.verification_results is not None:
self._add_verification_section(card_layout)
# Subtle Ko-Fi support link
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#72A5F2; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
kofi_label = QLabel('<a href="https://ko-fi.com/omni1" style="color:#3fd0ea; text-decoration:none;">Enjoying Jackify? Support development ♥</a>')
kofi_label.setAlignment(Qt.AlignCenter)
kofi_label.setStyleSheet(
"QLabel { "
" color: #72A5F2; "
" color: #3fd0ea; "
" font-size: 11px; "
" margin-top: 8px; "
" padding: 4px; "
@@ -275,6 +302,94 @@ class SuccessDialog(QDialog):
# ENB Proton warning shown in separate dialog
return base_message
def _add_verification_section(self, card_layout):
"""Add a verification summary section to the card layout."""
from PySide6.QtWidgets import QScrollArea
r = self.verification_results
n_pass = len(r.passes)
n_warn = len(r.warnings)
n_fail = len(r.failures)
sep = QFrame()
sep.setFrameShape(QFrame.HLine)
sep.setStyleSheet("color: #353a40;")
card_layout.addWidget(sep)
if n_fail:
summary_text = f"[FAIL] Verification: {n_fail} failure(s), {n_warn} warning(s)"
summary_color = "#e05050"
elif n_warn:
summary_text = f"[WARN] Verification: {n_warn} warning(s) - review before playing"
summary_color = "#f0c040"
else:
summary_text = f"[OK] Verification passed ({n_pass} checks)"
summary_color = "#3fd0ea"
summary_row = QHBoxLayout()
summary_row.setContentsMargins(0, 0, 0, 0)
summary_row.setSpacing(8)
summary_lbl = QLabel(summary_text)
summary_lbl.setAlignment(Qt.AlignCenter)
summary_lbl.setWordWrap(True)
summary_lbl.setStyleSheet(
f"QLabel {{ font-size: 12px; font-weight: bold; color: {summary_color}; }}"
)
summary_row.addStretch()
summary_row.addWidget(summary_lbl)
view_btn = QPushButton("View checks")
view_btn.setFixedWidth(90)
view_btn.setStyleSheet(
"QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; "
"border: 1px solid #555; border-radius: 4px; padding: 3px 6px; }"
"QPushButton:hover { background: #4a4a4a; color: #fff; }"
)
view_btn.setCursor(Qt.PointingHandCursor)
view_btn.clicked.connect(lambda: self._show_verification_detail())
summary_row.addWidget(view_btn)
summary_row.addStretch()
row_widget = QWidget()
row_widget.setLayout(summary_row)
card_layout.addWidget(row_widget)
if n_fail or n_warn:
detail_widget = QWidget()
detail_layout = QVBoxLayout(detail_widget)
detail_layout.setContentsMargins(0, 0, 0, 0)
detail_layout.setSpacing(2)
for msg in r.failures:
lbl = QLabel(f"[FAIL] {msg}")
lbl.setWordWrap(True)
lbl.setStyleSheet("color: #e05050; font-size: 11px;")
detail_layout.addWidget(lbl)
for msg in r.warnings:
lbl = QLabel(f"[WARN] {msg}")
lbl.setWordWrap(True)
lbl.setStyleSheet("color: #f0c040; font-size: 11px;")
detail_layout.addWidget(lbl)
scroll = QScrollArea()
scroll.setWidget(detail_widget)
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(120)
scroll.setStyleSheet(
"QScrollArea { border: 1px solid #353a40; border-radius: 4px; background: #1a1d23; }"
)
card_layout.addWidget(scroll)
def _show_verification_detail(self):
"""Open the full verification results dialog."""
try:
from jackify.frontends.gui.dialogs.verification_results_dialog import VerificationResultsDialog
dlg = VerificationResultsDialog(self.verification_results, parent=self)
dlg.show()
except Exception as exc:
logger.error("Could not open verification dialog: %s", exc)
def _update_countdown(self):
if self._countdown > 0:
self.return_btn.setText(f"{self._orig_return_text} ({self._countdown}s)")
@@ -389,11 +389,13 @@ class UlimitGuidanceDialog(QDialog):
"""Apply dialog styling"""
self.setStyleSheet("""
QDialog {
background-color: #f5f5f5;
background-color: #232323;
color: #e0e0e0;
}
QGroupBox {
font-weight: bold;
border: 2px solid #cccccc;
color: #e0e0e0;
border: 2px solid #3a3a3a;
border-radius: 5px;
margin-top: 1ex;
padding-top: 10px;
@@ -404,13 +406,14 @@ class UlimitGuidanceDialog(QDialog):
padding: 0 5px 0 5px;
}
QTextEdit {
background-color: #ffffff;
border: 1px solid #cccccc;
background-color: #2a2a2a;
color: #e0e0e0;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 5px;
}
QPushButton {
background-color: #007acc;
background-color: #4a5568;
color: white;
border: none;
padding: 8px 16px;
@@ -418,14 +421,14 @@ class UlimitGuidanceDialog(QDialog):
font-weight: bold;
}
QPushButton:hover {
background-color: #005a9e;
background-color: #5a6578;
}
QPushButton:pressed {
background-color: #004175;
background-color: #3fd0ea;
}
QPushButton:disabled {
background-color: #cccccc;
color: #666666;
background-color: #333;
color: #666;
}
""")
@@ -0,0 +1,210 @@
"""Verification results dialog shown after install/configure workflows."""
import logging
from PySide6.QtCore import Qt
from PySide6.QtGui import QClipboard, QGuiApplication
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QVBoxLayout,
)
logger = logging.getLogger(__name__)
_COLOR_FAIL = "#e05050"
_COLOR_WARN = "#f0c040"
_COLOR_OK = "#3fd0ea"
_COLOR_DIM = "#888888"
def _html_row(prefix: str, color: str, msg: str) -> str:
safe = msg.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return (
f'<span style="color:{color}; font-family:monospace;">'
f'<b>{prefix}</b>&nbsp;{safe}'
f'</span><br>'
)
class VerificationResultsDialog(QDialog):
"""Shows the output of verify_install.py after a workflow completes."""
def __init__(self, results, parent=None):
super().__init__(parent)
self.setWindowTitle("Installation Verification")
self.setWindowModality(Qt.NonModal)
self.setAttribute(Qt.WA_DeleteOnClose, True)
self.setMinimumWidth(600)
self._results = results
n_pass = len(results.passes)
n_warn = len(results.warnings)
n_fail = len(results.failures)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
# --- Headline ---
if n_fail:
headline = f"[FAIL] {n_fail} failure{'s' if n_fail != 1 else ''}, {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed"
h_color = _COLOR_FAIL
elif n_warn:
headline = f"[WARN] {n_warn} warning{'s' if n_warn != 1 else ''}, {n_pass} passed"
h_color = _COLOR_WARN
else:
headline = f"[OK] All {n_pass} checks passed"
h_color = _COLOR_OK
headline_label = QLabel(headline)
headline_label.setStyleSheet(
f"font-size: 14px; font-weight: bold; color: {h_color};"
)
layout.addWidget(headline_label)
# --- Issues view (always visible) ---
self._issues_view = QTextEdit()
self._issues_view.setReadOnly(True)
self._issues_view.setStyleSheet(
"QTextEdit { background: #1e1e1e; border: 1px solid #444; "
"border-radius: 4px; font-size: 12px; padding: 6px; }"
)
self._issues_view.setHtml(self._build_issues_html(results))
self._issues_view.setMinimumHeight(80)
self._issues_view.setMaximumHeight(220)
layout.addWidget(self._issues_view)
# --- Full report view (hidden by default) ---
self._full_view = QTextEdit()
self._full_view.setReadOnly(True)
self._full_view.setStyleSheet(
"QTextEdit { background: #1a1a1a; border: 1px solid #333; "
"border-radius: 4px; font-size: 11px; padding: 6px; }"
)
self._full_view.setHtml(self._build_full_html(results))
self._full_view.setMinimumHeight(160)
self._full_view.setMaximumHeight(300)
self._full_view.setVisible(False)
layout.addWidget(self._full_view)
# --- Button row ---
btn_row = QHBoxLayout()
self._toggle_btn = QPushButton("Show all checks")
self._toggle_btn.setFixedWidth(130)
self._toggle_btn.setStyleSheet(
"QPushButton { font-size: 11px; color: #ccc; background: #3a3a3a; "
"border: 1px solid #555; border-radius: 4px; padding: 4px 8px; }"
"QPushButton:hover { background: #4a4a4a; color: #fff; }"
)
self._toggle_btn.setCursor(Qt.PointingHandCursor)
self._toggle_btn.clicked.connect(self._toggle_full_report)
btn_row.addWidget(self._toggle_btn)
btn_row.addStretch()
copy_btn = QPushButton("Copy Report")
copy_btn.setFixedWidth(110)
copy_btn.setToolTip("Copy plain-text report to clipboard")
copy_btn.clicked.connect(self._copy_report)
btn_row.addWidget(copy_btn)
close_btn = QPushButton("Close")
close_btn.setFixedWidth(80)
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.adjustSize()
# ------------------------------------------------------------------
def _build_issues_html(self, results) -> str:
lines = []
for msg in results.failures:
lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg))
for msg in results.warnings:
lines.append(_html_row("[WARN]", _COLOR_WARN, msg))
if not results.failures and not results.warnings:
lines.append(_html_row("[OK]", _COLOR_OK, "No issues found. Your modlist is correctly configured."))
return "".join(lines)
def _build_full_html(self, results) -> str:
lines = []
if results.failures:
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">FAILURES</span><br>')
for msg in results.failures:
lines.append(_html_row("[FAIL]", _COLOR_FAIL, msg))
lines.append("<br>")
if results.warnings:
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">WARNINGS</span><br>')
for msg in results.warnings:
lines.append(_html_row("[WARN]", _COLOR_WARN, msg))
lines.append("<br>")
if results.passes:
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">PASSED</span><br>')
for msg in results.passes:
lines.append(_html_row("[OK] ", _COLOR_OK, msg))
components = getattr(results, "installed_components", [])
if components:
lines.append("<br>")
lines.append(f'<span style="color:{_COLOR_DIM}; font-size:10px;">INSTALLED COMPONENTS ({len(components)})</span><br>')
for c in components:
method = c.get("method", "unknown")
method_color = _COLOR_OK if method == "native" else _COLOR_DIM
safe_name = c["name"].replace("&", "&amp;").replace("<", "&lt;")
lines.append(
f'<span style="font-family:monospace; font-size:11px;">'
f'{safe_name}'
f'&nbsp;<span style="color:{method_color}; font-size:10px;">({method})</span>'
f'</span><br>'
)
return "".join(lines)
def _build_plain_text(self) -> str:
r = self._results
n_pass = len(r.passes)
n_warn = len(r.warnings)
n_fail = len(r.failures)
lines = ["Jackify - Installation Verification", "=" * 40]
if n_fail:
lines.append(f"RESULT: FAILED ({n_fail} failures, {n_warn} warnings, {n_pass} passed)")
elif n_warn:
lines.append(f"RESULT: WARNING ({n_warn} warnings, {n_pass} passed)")
else:
lines.append(f"RESULT: OK (all {n_pass} checks passed)")
lines.append("")
if r.failures:
lines.append("FAILURES:")
for msg in r.failures:
lines.append(f" [FAIL] {msg}")
lines.append("")
if r.warnings:
lines.append("WARNINGS:")
for msg in r.warnings:
lines.append(f" [WARN] {msg}")
lines.append("")
if r.passes:
lines.append("PASSED:")
for msg in r.passes:
lines.append(f" [OK] {msg}")
components = getattr(r, "installed_components", [])
if components:
lines.append("")
lines.append("INSTALLED COMPONENTS:")
for c in components:
lines.append(f" {c['name']} ({c.get('method', 'unknown')})")
return "\n".join(lines)
def _toggle_full_report(self):
visible = self._full_view.isVisible()
self._full_view.setVisible(not visible)
self._toggle_btn.setText("Hide all checks" if not visible else "Show all checks")
self.adjustSize()
def _copy_report(self):
QGuiApplication.clipboard().setText(self._build_plain_text())
+17 -17
View File
@@ -49,7 +49,7 @@ class WarningDialog(QDialog):
"QFrame#warningCard { "
" background: #2d2323; "
" border-radius: 12px; "
" border: 2px solid #e67e22; "
" border: 2px solid #f0c040; "
"}"
)
@@ -61,7 +61,7 @@ class WarningDialog(QDialog):
"QLabel { "
" font-size: 36px; "
" font-weight: bold; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 4px; "
"}"
)
@@ -74,7 +74,7 @@ class WarningDialog(QDialog):
"QLabel { "
" font-size: 20px; "
" font-weight: 600; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -106,7 +106,7 @@ class WarningDialog(QDialog):
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -118,11 +118,11 @@ class WarningDialog(QDialog):
self._default_lineedit_style = (
"QLineEdit { "
" font-size: 15px; "
" border: 1px solid #e67e22; "
" border: 1px solid #f0c040; "
" border-radius: 6px; "
" padding: 6px; "
" background: #23272e; "
" color: #e67e22; "
" color: #e0e0e0; "
"}"
)
self.confirm_edit.setStyleSheet(self._default_lineedit_style)
@@ -140,7 +140,7 @@ class WarningDialog(QDialog):
cancel_btn.clicked.connect(self.reject)
cancel_btn.setStyleSheet(
"QPushButton { "
" background-color: #95a5a6; "
" background-color: #4a5568; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -148,10 +148,10 @@ class WarningDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #7f8c8d; "
" background-color: #5a6578; "
"} "
"QPushButton:pressed { "
" background-color: #6c7b7d; "
" background-color: #3fd0ea; "
"}"
)
button_layout.addWidget(cancel_btn)
@@ -161,7 +161,7 @@ class WarningDialog(QDialog):
confirm_btn.clicked.connect(self._on_confirm)
confirm_btn.setStyleSheet(
"QPushButton { "
" background-color: #e67e22; "
" background-color: #8b2020; "
" color: white; "
" border: none; "
" border-radius: 4px; "
@@ -169,10 +169,10 @@ class WarningDialog(QDialog):
" padding: 8px 16px; "
"} "
"QPushButton:hover { "
" background-color: #d35400; "
" background-color: #a02828; "
"} "
"QPushButton:pressed { "
" background-color: #b34700; "
" background-color: #7a1a1a; "
"}"
)
button_layout.addWidget(confirm_btn)
@@ -200,7 +200,7 @@ class WarningDialog(QDialog):
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" color: #f0c040; "
" margin-bottom: 2px; "
"}"
)
@@ -231,7 +231,7 @@ class WarningDialog(QDialog):
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #c0392b; " # Red for error
" color: #e05050; "
" margin-bottom: 2px; "
" font-weight: bold; "
"}"
@@ -242,10 +242,10 @@ class WarningDialog(QDialog):
self.confirm_edit.setStyleSheet(
"QLineEdit { "
" font-size: 15px; "
" border: 2px solid #c0392b; " # Red border for error
" border: 2px solid #8b2020; "
" border-radius: 6px; "
" padding: 6px; "
" background: #3b2323; " # Darker red background
" color: #e67e22; "
" background: #3b2323; "
" color: #e0e0e0; "
"}"
)