Sync from development - prepare for v0.5.0

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

View File

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

View File

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

View File

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

View File

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