e9f0098df8
- CheckUpdatesWorker emet progress(done, total) per cada joc intentat (èxit o error), amb total = jocs descarregats; així la barra arriba al final encara que algun repo doni timeout. - QProgressBar a la status bar (amagada per defecte) que es mostra durant la comprovació i s'amaga en acabar o en error. Reutilitzada pel check manual i l'automàtic a l'inici. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Ventana principal: lista de juegos con scroll + panel de log."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QThreadPool, Qt, QTimer
|
|
from PySide6.QtGui import QAction
|
|
from PySide6.QtWidgets import (
|
|
QInputDialog,
|
|
QLineEdit,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QPlainTextEdit,
|
|
QProgressBar,
|
|
QScrollArea,
|
|
QSplitter,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from .. import gitops
|
|
from ..config import Config, Game
|
|
from ..settings import load_settings, save_settings
|
|
from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker
|
|
from .game_row import GameRow
|
|
|
|
APP_NAME = "Jail Launcher"
|
|
WINDOW_TITLE = f"© 2026 {APP_NAME} — JailDesigner"
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self, config: Config, root: Path, parent=None) -> None:
|
|
super().__init__(parent)
|
|
self.config = config
|
|
self.root = root
|
|
self.settings = load_settings()
|
|
self.pool = QThreadPool.globalInstance()
|
|
self.rows: dict[str, GameRow] = {}
|
|
self._delete_mode = False
|
|
# Mantener referencias a los workers en vuelo: si no, Python los recolecta
|
|
# (junto a su objeto de señales) antes de que la señal en cola `finished`
|
|
# llegue al hilo principal, y la UI nunca se refresca.
|
|
self._workers: set = set()
|
|
|
|
self.setWindowTitle(WINDOW_TITLE)
|
|
self.resize(720, 640)
|
|
|
|
self._build_menu()
|
|
|
|
splitter = QSplitter(Qt.Vertical)
|
|
|
|
# --- Lista de juegos con scroll ---
|
|
list_container = QWidget()
|
|
list_layout = QVBoxLayout(list_container)
|
|
list_layout.setContentsMargins(6, 6, 6, 6)
|
|
list_layout.setSpacing(6)
|
|
for game in config.games:
|
|
row = GameRow(game, root)
|
|
row.activated.connect(self._on_activate)
|
|
row.delete_requested.connect(self._on_delete)
|
|
self.rows[game.id] = row
|
|
list_layout.addWidget(row)
|
|
list_layout.addStretch(1)
|
|
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setWidget(list_container)
|
|
splitter.addWidget(scroll)
|
|
|
|
# --- Panel de log ---
|
|
self.log_view = QPlainTextEdit()
|
|
self.log_view.setReadOnly(True)
|
|
self.log_view.setMaximumBlockCount(5000)
|
|
self.log_view.setStyleSheet("font-family: monospace; font-size: 11px;")
|
|
splitter.addWidget(self.log_view)
|
|
splitter.setStretchFactor(0, 3)
|
|
splitter.setStretchFactor(1, 1)
|
|
|
|
self.setCentralWidget(splitter)
|
|
|
|
# Barra de progrés (cantonada de la status bar) per a la comprovació d'updates;
|
|
# amagada fins que arrenca una comprovació.
|
|
self.progress = QProgressBar()
|
|
self.progress.setMaximumWidth(220)
|
|
self.progress.setFormat("Comprovant %v/%m")
|
|
self.progress.hide()
|
|
self.statusBar().addPermanentWidget(self.progress)
|
|
|
|
# Estado persistido: marcas de update + filtro de ocultar no descargados.
|
|
for game_id in self.settings.updates_pending:
|
|
if game_id in self.rows:
|
|
self.rows[game_id].set_update_available(True)
|
|
self._apply_filter()
|
|
|
|
# Comprobación automática de updates al iniciar (si está activada). Se difiere
|
|
# con un timer 0 para que la ventana se muestre antes de lanzar el worker.
|
|
if self.settings.check_updates_on_start:
|
|
QTimer.singleShot(0, self._check_updates)
|
|
|
|
def _net_config(self) -> gitops.NetConfig:
|
|
"""Construye la config de red/timeouts a partir de las preferencias guardadas."""
|
|
s = self.settings
|
|
return gitops.NetConfig(
|
|
fetch_timeout=s.git_fetch_timeout,
|
|
clone_timeout=s.git_clone_timeout,
|
|
http_timeout=s.http_timeout,
|
|
stall_limit=s.git_stall_limit,
|
|
stall_time=s.git_stall_time,
|
|
)
|
|
|
|
# --------------------------------------------------------------- menú
|
|
|
|
def _build_menu(self) -> None:
|
|
menu = self.menuBar().addMenu("Opcions")
|
|
|
|
self.action_hide = QAction("Amaga els jocs no descarregats", self, checkable=True)
|
|
self.action_hide.setChecked(self.settings.hide_not_downloaded)
|
|
self.action_hide.toggled.connect(self._on_toggle_hide)
|
|
menu.addAction(self.action_hide)
|
|
|
|
self.action_check = QAction("Comprova actualitzacions", self)
|
|
self.action_check.triggered.connect(self._check_updates)
|
|
menu.addAction(self.action_check)
|
|
|
|
self.action_check_on_start = QAction(
|
|
"Comprova actualitzacions a l'inici", self, checkable=True
|
|
)
|
|
self.action_check_on_start.setChecked(self.settings.check_updates_on_start)
|
|
self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start)
|
|
menu.addAction(self.action_check_on_start)
|
|
|
|
menu.addSeparator()
|
|
self.action_delete = QAction("Esborra un joc", self, checkable=True)
|
|
self.action_delete.toggled.connect(self._set_delete_mode)
|
|
menu.addAction(self.action_delete)
|
|
|
|
menu.addSeparator()
|
|
self.action_token = QAction("Configura el token de Gitea…", self)
|
|
self.action_token.triggered.connect(self._configure_token)
|
|
menu.addAction(self.action_token)
|
|
|
|
def _configure_token(self) -> None:
|
|
token, ok = QInputDialog.getText(
|
|
self,
|
|
"Token de Gitea",
|
|
"Token personal d'accés (per a repos privats).\n"
|
|
"Es guarda local a settings.json (no es versiona).",
|
|
QLineEdit.Password,
|
|
self.settings.gitea_token,
|
|
)
|
|
if not ok:
|
|
return
|
|
self.settings.gitea_token = token.strip()
|
|
save_settings(self.settings)
|
|
estat = "configurat" if self.settings.gitea_token else "esborrat"
|
|
self._log(f"Token de Gitea {estat}.")
|
|
|
|
def _on_toggle_hide(self, checked: bool) -> None:
|
|
self.settings.hide_not_downloaded = checked
|
|
save_settings(self.settings)
|
|
self._apply_filter()
|
|
|
|
def _on_toggle_check_on_start(self, checked: bool) -> None:
|
|
self.settings.check_updates_on_start = checked
|
|
save_settings(self.settings)
|
|
|
|
def _apply_filter(self) -> None:
|
|
hide = self.action_hide.isChecked()
|
|
for row in self.rows.values():
|
|
row.setVisible(not (hide and not row.is_installed()))
|
|
|
|
# ------------------------------------------------------ comprobar updates
|
|
|
|
def _check_updates(self) -> None:
|
|
self.action_check.setEnabled(False)
|
|
self._log("=== Comprovant actualitzacions ===")
|
|
worker = CheckUpdatesWorker(
|
|
self.root, self.config.games, self.settings.gitea_token, self._net_config()
|
|
)
|
|
worker.signals.log.connect(self._log)
|
|
worker.signals.result.connect(self._mark_update)
|
|
worker.signals.progress.connect(self._check_progress)
|
|
worker.signals.finished.connect(self._check_done)
|
|
worker.signals.error.connect(self._check_error)
|
|
self._track(worker)
|
|
self.pool.start(worker)
|
|
|
|
def _check_progress(self, done: int, total: int) -> None:
|
|
if total <= 0:
|
|
return # res descarregat a comprovar: no mostrem barra
|
|
self.progress.setMaximum(total)
|
|
self.progress.setValue(done)
|
|
self.progress.show()
|
|
|
|
def _mark_update(self, game_id: str, has_update: bool) -> None:
|
|
row = self.rows.get(game_id)
|
|
if row is not None:
|
|
row.set_update_available(has_update)
|
|
pending = set(self.settings.updates_pending)
|
|
pending.add(game_id) if has_update else pending.discard(game_id)
|
|
self.settings.updates_pending = sorted(pending)
|
|
save_settings(self.settings)
|
|
|
|
def _check_done(self, _payload) -> None:
|
|
self.action_check.setEnabled(True)
|
|
self.progress.hide()
|
|
self._log("=== Comprovació d'actualitzacions acabada ===")
|
|
|
|
def _check_error(self, msg: str) -> None:
|
|
self.action_check.setEnabled(True)
|
|
self.progress.hide()
|
|
self._log(f"!!! Error comprovant actualitzacions: {msg}")
|
|
|
|
# --------------------------------------------------------------- helpers
|
|
|
|
def _log(self, text: str) -> None:
|
|
self.log_view.appendPlainText(text)
|
|
|
|
def _track(self, worker) -> None:
|
|
"""Retiene el worker hasta que emite finished/error, evitando que el GC
|
|
se lleve su objeto de señales antes de entregar la señal en cola."""
|
|
worker.setAutoDelete(False)
|
|
self._workers.add(worker)
|
|
worker.signals.finished.connect(lambda *_: self._workers.discard(worker))
|
|
worker.signals.error.connect(lambda *_: self._workers.discard(worker))
|
|
|
|
# --------------------------------------------------------------- accions
|
|
|
|
def _set_delete_mode(self, on: bool) -> None:
|
|
self._delete_mode = on
|
|
for row in self.rows.values():
|
|
row.set_delete_mode(on)
|
|
if on:
|
|
self._log(
|
|
"Mode esborrar: tria un joc descarregat per eliminar-lo "
|
|
"(o desmarca «Esborra un joc» per cancel·lar)."
|
|
)
|
|
|
|
def _on_activate(self, game: Game) -> None:
|
|
"""Clic sobre la fila. En mode esborrar elimina; si no, descarrega/actualitza o juga."""
|
|
row = self.rows[game.id]
|
|
if self._delete_mode:
|
|
if not row.is_installed():
|
|
return # res a esborrar; segueix en mode esborrar
|
|
if self._on_delete(game):
|
|
self.action_delete.setChecked(False) # surt del mode esborrar
|
|
return
|
|
if row.primary_action_is_download():
|
|
self._on_download(game)
|
|
else:
|
|
self._on_run(game)
|
|
|
|
def _on_download(self, game: Game) -> None:
|
|
row = self.rows[game.id]
|
|
row.set_busy(True, "Descarregant…")
|
|
self._log(f"=== Descàrrega: {game.name} ===")
|
|
|
|
worker = DownloadWorker(
|
|
self.root, game, self.settings.gitea_token, self._net_config()
|
|
)
|
|
worker.signals.log.connect(self._log)
|
|
worker.signals.finished.connect(lambda _meta, g=game: self._download_done(g))
|
|
worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg))
|
|
self._track(worker)
|
|
self.pool.start(worker)
|
|
|
|
def _download_done(self, game: Game) -> None:
|
|
self._log(f"=== {game.name}: descàrrega completada ===")
|
|
row = self.rows[game.id]
|
|
row.set_busy(False)
|
|
row.set_update_available(False) # recién traído del remoto → al día
|
|
if game.id in self.settings.updates_pending:
|
|
self.settings.updates_pending = [
|
|
g for g in self.settings.updates_pending if g != game.id
|
|
]
|
|
save_settings(self.settings)
|
|
self._apply_filter() # un juego antes no instalado puede aparecer ahora
|
|
|
|
def _on_run(self, game: Game) -> None:
|
|
row = self.rows[game.id]
|
|
row.set_busy(True, "Executant…")
|
|
self._log(f"=== Juga: {game.name} ===")
|
|
|
|
worker = RunWorker(self.root, game)
|
|
worker.signals.log.connect(self._log)
|
|
worker.signals.finished.connect(lambda code, g=game: self._run_done(g, code))
|
|
worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg))
|
|
self._track(worker)
|
|
self.pool.start(worker)
|
|
|
|
def _run_done(self, game: Game, code: int) -> None:
|
|
self._log(f"=== {game.name}: ha finalitzat amb codi {code} ===")
|
|
row = self.rows[game.id]
|
|
row.set_busy(False)
|
|
row.refresh()
|
|
|
|
def _op_error(self, game: Game, msg: str) -> None:
|
|
self._log(f"!!! {game.name}: ERROR: {msg}")
|
|
row = self.rows[game.id]
|
|
row.set_busy(False)
|
|
row.refresh()
|
|
|
|
def _on_delete(self, game: Game) -> bool:
|
|
"""Esborra la descàrrega local (amb confirmació). Retorna True si s'ha esborrat."""
|
|
resp = QMessageBox.question(
|
|
self,
|
|
"Esborrar descàrrega",
|
|
f"Segur que vols esborrar la descàrrega local de «{game.name}»?\n\n"
|
|
"S'eliminarà el clon i les dades en local (no es treu de la llista).",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No,
|
|
)
|
|
if resp != QMessageBox.Yes:
|
|
return False
|
|
self._log(f"=== Esborra: {game.name} ===")
|
|
gitops.delete_local(self.root, game, log=self._log)
|
|
if game.id in self.settings.updates_pending:
|
|
self.settings.updates_pending = [
|
|
g for g in self.settings.updates_pending if g != game.id
|
|
]
|
|
save_settings(self.settings)
|
|
row = self.rows[game.id]
|
|
row.set_update_available(False)
|
|
row.refresh()
|
|
self._apply_filter() # si està actiu "amaga no descarregats", ara s'amaga
|
|
return True
|