diff --git a/.gitignore b/.gitignore index 5f90fa5..b47393f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # ---> jlauncher # Datos descargados en tiempo de ejecución (clones git + cache de metadata) jlauncher_data/ +# Preferencias locales del usuario +settings.json *.dist/ *.build/ @@ -11,7 +13,7 @@ jlauncher_data/ .LSOverride # Icon must end with two \r -Icon +Icon # Thumbnails ._* diff --git a/games.toml b/games.toml index a88b177..97421cf 100644 --- a/games.toml +++ b/games.toml @@ -42,7 +42,14 @@ run_cmd = "make run" [[game]] id = "projecte_2026" -name = "Projecte 2026 (Orni Attack)" +name = "Projecte 2026" clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git" build_cmd = "" run_cmd = "make run" + +[[game]] +id = "orni_attack" +name = "Orni Attack" +clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/orni_attack.git" +build_cmd = "" +run_cmd = "make run" diff --git a/jlauncher/gitops.py b/jlauncher/gitops.py index 36a8c83..0a99c19 100644 --- a/jlauncher/gitops.py +++ b/jlauncher/gitops.py @@ -52,6 +52,30 @@ def is_installed(root: Path, game: Game) -> bool: return (repo_dir(root, game.id) / ".git").exists() +def check_update(root: Path, game: Game, log: LogFn = _noop) -> bool: + """Hace fetch y devuelve True si el clone local está por detrás del remoto. + + No modifica el árbol de trabajo: solo cuenta los commits de origin/ que no + están en HEAD. Devuelve False si el juego no está descargado. + """ + repo = repo_dir(root, game.id) + if not (repo / ".git").exists(): + return False + _run_git(["fetch", "origin", "--prune"], repo, log) + target = ( + load_meta(root, game.id).default_branch + or _detect_origin_head(repo, log) + or "HEAD" + ) + try: + behind = _run_git( + ["rev-list", "--count", f"HEAD..origin/{target}"], repo, log + ) + except RuntimeError: + return False + return int(behind or "0") > 0 + + def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta: """Clona (si no existe) o trae el remoto forzado (descartando cambios locales). diff --git a/jlauncher/settings.py b/jlauncher/settings.py new file mode 100644 index 0000000..cf2618a --- /dev/null +++ b/jlauncher/settings.py @@ -0,0 +1,45 @@ +"""Preferencias persistentes en settings.json, junto al ejecutable.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path + +from .paths import base_dir + +SETTINGS_NAME = "settings.json" + + +@dataclass +class Settings: + hide_not_downloaded: bool = False + updates_pending: list[str] = field(default_factory=list) # ids con update pendiente + + +def settings_path() -> Path: + return base_dir() / SETTINGS_NAME + + +def load_settings() -> Settings: + path = settings_path() + if not path.exists(): + return Settings() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return Settings() + return Settings( + hide_not_downloaded=bool(data.get("hide_not_downloaded", False)), + updates_pending=list(data.get("updates_pending", [])), + ) + + +def save_settings(settings: Settings) -> None: + try: + settings_path().write_text( + json.dumps(asdict(settings), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError: + pass diff --git a/jlauncher/ui/game_row.py b/jlauncher/ui/game_row.py index 7fd4e41..516ad8d 100644 --- a/jlauncher/ui/game_row.py +++ b/jlauncher/ui/game_row.py @@ -32,6 +32,7 @@ class GameRow(QFrame): super().__init__(parent) self.game = game self.root = root + self._update_available = False self.setObjectName("gameRow") self.setFrameShape(QFrame.StyledPanel) @@ -79,6 +80,14 @@ class GameRow(QFrame): # ----------------------------------------------------------------- estado + def is_installed(self) -> bool: + return (repo_dir(self.root, self.game.id) / ".git").exists() + + def set_update_available(self, available: bool) -> None: + """Marca/desmarca la fila como 'tiene actualización pendiente'.""" + self._update_available = available + self.refresh() + def refresh(self) -> None: """Recarga icono, descripción y estado desde la cache local.""" meta = load_meta(self.root, self.game.id) @@ -104,11 +113,15 @@ class GameRow(QFrame): ) def _set_status(self, meta: GameMeta) -> None: - installed = (repo_dir(self.root, self.game.id) / ".git").exists() - if not installed: + if not self.is_installed(): self.status_label.setText("No instalado") self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;") self.run_btn.setEnabled(True) # se permite, avisará si no está + elif self._update_available: + self.status_label.setText("⬆ Actualización disponible") + self.status_label.setStyleSheet( + "color: #e0a030; font-weight: bold; font-size: 11px;" + ) else: ver = f" {meta.version}" if meta.version else "" self.status_label.setText(f"Instalado{ver}") diff --git a/jlauncher/ui/main_window.py b/jlauncher/ui/main_window.py index 1811785..cebb3e8 100644 --- a/jlauncher/ui/main_window.py +++ b/jlauncher/ui/main_window.py @@ -5,6 +5,7 @@ from __future__ import annotations from pathlib import Path from PySide6.QtCore import QThreadPool, Qt +from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QMainWindow, QPlainTextEdit, @@ -15,7 +16,8 @@ from PySide6.QtWidgets import ( ) from ..config import Config, Game -from ..workers import DownloadWorker, RunWorker +from ..settings import load_settings, save_settings +from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker from .game_row import GameRow APP_NAME = "Jail Launcher" @@ -27,6 +29,7 @@ class MainWindow(QMainWindow): super().__init__(parent) self.config = config self.root = root + self.settings = load_settings() self.pool = QThreadPool.globalInstance() self.rows: dict[str, GameRow] = {} # Mantener referencias a los workers en vuelo: si no, Python los recolecta @@ -37,6 +40,8 @@ class MainWindow(QMainWindow): self.setWindowTitle(WINDOW_TITLE) self.resize(720, 640) + self._build_menu() + splitter = QSplitter(Qt.Vertical) # --- Lista de juegos con scroll --- @@ -68,6 +73,66 @@ class MainWindow(QMainWindow): self.setCentralWidget(splitter) + # 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() + + # --------------------------------------------------------------- menú + + def _build_menu(self) -> None: + menu = self.menuBar().addMenu("Opciones") + + self.action_hide = QAction("Ocultar juegos no descargados", 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("Comprobar actualizaciones", self) + self.action_check.triggered.connect(self._check_updates) + menu.addAction(self.action_check) + + def _on_toggle_hide(self, checked: bool) -> None: + self.settings.hide_not_downloaded = checked + save_settings(self.settings) + self._apply_filter() + + 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("=== Comprobando actualizaciones ===") + worker = CheckUpdatesWorker(self.root, self.config.games) + worker.signals.log.connect(self._log) + worker.signals.result.connect(self._mark_update) + worker.signals.finished.connect(self._check_done) + worker.signals.error.connect(self._check_error) + self._track(worker) + self.pool.start(worker) + + 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._log("=== Comprobación de actualizaciones terminada ===") + + def _check_error(self, msg: str) -> None: + self.action_check.setEnabled(True) + self._log(f"!!! Error comprobando actualizaciones: {msg}") + # --------------------------------------------------------------- helpers def _log(self, text: str) -> None: @@ -99,7 +164,13 @@ class MainWindow(QMainWindow): self._log(f"=== {game.name}: descarga completada ===") row = self.rows[game.id] row.set_busy(False) - row.refresh() + 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] diff --git a/jlauncher/workers.py b/jlauncher/workers.py index 08d13b8..d6b58aa 100644 --- a/jlauncher/workers.py +++ b/jlauncher/workers.py @@ -19,6 +19,7 @@ class _Signals(QObject): log = Signal(str) # línea de log finished = Signal(object) # payload según el worker (GameMeta o int exit code) error = Signal(str) # mensaje de error + result = Signal(str, bool) # (game_id, has_update) — CheckUpdatesWorker class DownloadWorker(QRunnable): @@ -57,3 +58,34 @@ class RunWorker(QRunnable): self.signals.error.emit(str(exc)) return self.signals.finished.emit(code) + + +class CheckUpdatesWorker(QRunnable): + """Comprueba actualizaciones pendientes de los juegos ya descargados. + + Emite `result(game_id, has_update)` por cada juego instalado y `finished` al final. + """ + + def __init__(self, root: Path, games: list[Game]) -> None: + super().__init__() + self.root = root + self.games = games + self.signals = _Signals() + + def run(self) -> None: # noqa: D401 - API de QRunnable + try: + for game in self.games: + if not gitops.is_installed(self.root, game): + continue + try: + has_update = gitops.check_update( + self.root, game, log=self.signals.log.emit + ) + except Exception as exc: # noqa: BLE001 - no abortar el resto + self.signals.log.emit(f"check {game.id}: {exc}") + continue + self.signals.result.emit(game.id, has_update) + except Exception as exc: # noqa: BLE001 - reportar a la UI + self.signals.error.emit(str(exc)) + return + self.signals.finished.emit(None)