From b71df66e22193a6ec818ab269a04e7e08f2d4f03 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Fri, 29 May 2026 20:50:50 +0200 Subject: [PATCH] =?UTF-8?q?Llan=C3=A7ador=20inicial=20amb=20GUI=20PySide6:?= =?UTF-8?q?=20desc=C3=A0rrega=20i=20execuci=C3=B3=20de=20jocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- README.md | 66 +++++++++++++- build.sh | 19 ++++ games.toml | 48 +++++++++++ jlauncher/__init__.py | 3 + jlauncher/__main__.py | 35 ++++++++ jlauncher/config.py | 79 +++++++++++++++++ jlauncher/gitops.py | 167 ++++++++++++++++++++++++++++++++++++ jlauncher/metadata.py | 58 +++++++++++++ jlauncher/paths.py | 53 ++++++++++++ jlauncher/runner.py | 57 ++++++++++++ jlauncher/ui/__init__.py | 1 + jlauncher/ui/game_row.py | 123 ++++++++++++++++++++++++++ jlauncher/ui/main_window.py | 125 +++++++++++++++++++++++++++ jlauncher/workers.py | 59 +++++++++++++ pyproject.toml | 18 ++++ requirements.txt | 1 + 17 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 build.sh create mode 100644 games.toml create mode 100644 jlauncher/__init__.py create mode 100644 jlauncher/__main__.py create mode 100644 jlauncher/config.py create mode 100644 jlauncher/gitops.py create mode 100644 jlauncher/metadata.py create mode 100644 jlauncher/paths.py create mode 100644 jlauncher/runner.py create mode 100644 jlauncher/ui/__init__.py create mode 100644 jlauncher/ui/game_row.py create mode 100644 jlauncher/ui/main_window.py create mode 100644 jlauncher/workers.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index a5cd9d0..5f90fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# ---> jlauncher +# Datos descargados en tiempo de ejecución (clones git + cache de metadata) +jlauncher_data/ +*.dist/ +*.build/ + # ---> macOS # General .DS_Store @@ -5,7 +11,7 @@ .LSOverride # Icon must end with two \r -Icon +Icon # Thumbnails ._* diff --git a/README.md b/README.md index 6b12614..63ce494 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,67 @@ # jlauncher -Llançador de jailgames desde gitea \ No newline at end of file +Lanzador de juegos de **jailgames**. A partir de `games.toml`, lista los juegos, los +clona/actualiza desde sus repos Gitea, lee su icono y descripción, y los compila/ejecuta. + +GUI en **Python + PySide6**, pensada para compilarse a binario nativo con **Nuitka**. + +## Requisitos + +- Python 3.11+ (usa `tomllib` de la stdlib) +- `git` en el PATH +- `pip install PySide6` + +## Ejecutar desde fuente + +```bash +pip install PySide6 +python -m jlauncher +``` + +La app crea una carpeta `jlauncher_data/` junto al proyecto (o junto al binario, si está +compilado) con esta estructura **anidada (Versión 1)**: + +``` +jlauncher_data/ + / + repo/ # git clone del juego + metadata/ + info.json # descripción, versión, rama por defecto, fecha de actualización + icon.png # copiado desde repo/release/icons/icon.png +``` + +## Botones + +- **Download**: si no existe el clone, hace `git clone`. Si existe, trae el remoto + **forzado** (`git fetch` + `git reset --hard origin/` + `git clean -fd`), + descartando cualquier cambio local. Después refresca la metadata: + - descripción desde la API de Gitea (`/api/v1/repos//`), + - versión ejecutando `version_cmd` (por defecto `git describe --tags --always`), + - icono desde `release/icons/icon.png`. +- **Run**: si hay `build_cmd`, compila primero; luego ejecuta `run_cmd`. Para estos + juegos basta `run_cmd = "make run"` (compila y ejecuta), con `build_cmd` vacío. + +Las operaciones corren en segundo plano (QThreadPool); el log aparece en el panel inferior. + +## Configuración: `games.toml` + +Una entrada `[[game]]` por juego. Campos: + +| Campo | Obligatorio | Descripción | +|---------------|-------------|--------------------------------------------------------------------| +| `id` | sí | slug → nombre de carpeta en `jlauncher_data/` | +| `name` | sí | nombre visible | +| `clone_url` | sí | URL de git clone / pull | +| `run_cmd` | sí | comando que ejecuta el juego (cwd = repo) | +| `build_cmd` | no | comando de compilado; vacío = `run_cmd` ya compila | +| `version_cmd` | no | comando que imprime la versión (def. `git describe --tags --always`)| +| `info_url` | no | API Gitea del repo (def. derivada de `clone_url`) | +| `icon_rel` | no | ruta del icono dentro del repo (def. `release/icons/icon.png`) | + +## Compilar a binario (Nuitka) + +```bash +pip install nuitka PySide6 +./build.sh +# binario en dist/jlauncher.dist/jlauncher +``` diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..566b746 --- /dev/null +++ b/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Compila jlauncher a un binario nativo (C, vía Nuitka). +# Requiere: pip install nuitka PySide6 +set -euo pipefail + +cd "$(dirname "$0")" + +python -m nuitka \ + --standalone \ + --assume-yes-for-downloads \ + --enable-plugin=pyside6 \ + --output-dir=dist \ + --output-filename=jlauncher \ + --include-data-files=games.toml=games.toml \ + jlauncher + +echo +echo "Listo. Binario en: dist/jlauncher.dist/jlauncher" +echo "games.toml se incluye junto al binario; jlauncher_data/ se creará al lado al ejecutar." diff --git a/games.toml b/games.toml new file mode 100644 index 0000000..a88b177 --- /dev/null +++ b/games.toml @@ -0,0 +1,48 @@ +# Configuración de jlauncher — lista de juegos. +# +# Campos por juego ([[game]]): +# id (obligatorio) slug interno → nombre de carpeta en jlauncher_data/ +# name (obligatorio) nombre visible en la lista +# clone_url (obligatorio) URL para git clone / git pull +# run_cmd (obligatorio) comando que ejecuta el juego (cwd = repo clonado) +# build_cmd (opcional) comando de compilado. Vacío = run_cmd ya compila (p.ej. "make run") +# version_cmd (opcional) comando que imprime la versión. Default: "git describe --tags --always" +# info_url (opcional) API de Gitea del repo. Default: derivada de clone_url +# icon_rel (opcional) ruta del icono dentro del repo. Default: "release/icons/icon.png" + +data_dir = "jlauncher_data" + +[[game]] +id = "coffee_crisis" +name = "Coffee Crisis" +clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis.git" +build_cmd = "" +run_cmd = "make run" + +[[game]] +id = "coffee_crisis_arcade_edition" +name = "Coffee Crisis Arcade Edition" +clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis_arcade_edition.git" +build_cmd = "" +run_cmd = "make run" + +[[game]] +id = "aee" +name = "Aventures en Egipte (AEE)" +clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/aee.git" +build_cmd = "" +run_cmd = "make run" + +[[game]] +id = "jaildoctors_dilemma" +name = "JailDoctor's Dilemma" +clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/jaildoctors_dilemma.git" +build_cmd = "" +run_cmd = "make run" + +[[game]] +id = "projecte_2026" +name = "Projecte 2026 (Orni Attack)" +clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git" +build_cmd = "" +run_cmd = "make run" diff --git a/jlauncher/__init__.py b/jlauncher/__init__.py new file mode 100644 index 0000000..373828d --- /dev/null +++ b/jlauncher/__init__.py @@ -0,0 +1,3 @@ +"""jlauncher — lanzador de juegos jailgames.""" + +__version__ = "0.1.0" diff --git a/jlauncher/__main__.py b/jlauncher/__main__.py new file mode 100644 index 0000000..a441d5c --- /dev/null +++ b/jlauncher/__main__.py @@ -0,0 +1,35 @@ +"""Punto de entrada: arranca la QApplication y la ventana principal.""" + +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication, QMessageBox + +from .config import load_config +from .paths import config_file, data_root +from .ui.main_window import MainWindow + + +def main() -> int: + app = QApplication(sys.argv) + app.setApplicationName("jlauncher") + + cfg_path = config_file() + try: + config = load_config(cfg_path) + except Exception as exc: # noqa: BLE001 - mostrar cualquier error de carga al usuario + QMessageBox.critical( + None, + "Error cargando games.toml", + f"No se pudo leer la configuración en:\n{cfg_path}\n\n{exc}", + ) + return 1 + + window = MainWindow(config, data_root(config.data_dir)) + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/jlauncher/config.py b/jlauncher/config.py new file mode 100644 index 0000000..0c5a4f1 --- /dev/null +++ b/jlauncher/config.py @@ -0,0 +1,79 @@ +"""Carga de games.toml → objetos Game con valores derivados.""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse + +DEFAULT_VERSION_CMD = "git describe --tags --always" +DEFAULT_ICON_REL = "release/icons/icon.png" + + +@dataclass +class Game: + id: str + name: str + clone_url: str + run_cmd: str + build_cmd: str = "" + version_cmd: str = DEFAULT_VERSION_CMD + info_url: str = "" + icon_rel: str = DEFAULT_ICON_REL + + def __post_init__(self) -> None: + if not self.info_url: + self.info_url = derive_info_url(self.clone_url) + + +@dataclass +class Config: + data_dir: str = "jlauncher_data" + games: list[Game] = field(default_factory=list) + + +def derive_info_url(clone_url: str) -> str: + """De una URL de clone Gitea deriva la URL de la API REST del repo. + + https://host/org/repo.git -> https://host/api/v1/repos/org/repo + """ + parsed = urlparse(clone_url) + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[: -len(".git")] + parts = [p for p in path.split("/") if p] + if len(parts) < 2 or not parsed.scheme or not parsed.netloc: + return "" + owner, repo = parts[-2], parts[-1] + return f"{parsed.scheme}://{parsed.netloc}/api/v1/repos/{owner}/{repo}" + + +def load_config(path: Path) -> Config: + with open(path, "rb") as fh: + raw = tomllib.load(fh) + + games: list[Game] = [] + for entry in raw.get("game", []): + missing = [k for k in ("id", "name", "clone_url", "run_cmd") if not entry.get(k)] + if missing: + raise ValueError( + f"Juego con campos obligatorios faltantes {missing}: {entry!r}" + ) + games.append( + Game( + id=entry["id"], + name=entry["name"], + clone_url=entry["clone_url"], + run_cmd=entry["run_cmd"], + build_cmd=entry.get("build_cmd", ""), + version_cmd=entry.get("version_cmd") or DEFAULT_VERSION_CMD, + info_url=entry.get("info_url", ""), + icon_rel=entry.get("icon_rel") or DEFAULT_ICON_REL, + ) + ) + + if not games: + raise ValueError("games.toml no define ningún [[game]]") + + return Config(data_dir=raw.get("data_dir", "jlauncher_data"), games=games) diff --git a/jlauncher/gitops.py b/jlauncher/gitops.py new file mode 100644 index 0000000..36a8c83 --- /dev/null +++ b/jlauncher/gitops.py @@ -0,0 +1,167 @@ +"""Operaciones git + refresco de metadata desde Gitea. + +Todo lo que toca disco/red vive aquí; los workers (QThread) lo invocan en segundo plano. +Las funciones aceptan un callback ``log(str)`` opcional para emitir progreso a la UI. +""" + +from __future__ import annotations + +import datetime as _dt +import json +import shutil +import subprocess +import urllib.error +import urllib.request +from collections.abc import Callable +from pathlib import Path + +from .config import Game +from .metadata import GameMeta, icon_path, load_meta, save_meta +from .paths import metadata_dir, repo_dir + +LogFn = Callable[[str], None] + +_HTTP_TIMEOUT = 15 + + +def _noop(_: str) -> None: + pass + + +def _run_git(args: list[str], cwd: Path | None, log: LogFn) -> str: + """Ejecuta git capturando salida; lanza RuntimeError si falla.""" + cmd = ["git", *args] + log("$ " + " ".join(cmd)) + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + ) + if proc.stdout: + log(proc.stdout.rstrip()) + if proc.stderr: + log(proc.stderr.rstrip()) + if proc.returncode != 0: + raise RuntimeError(f"git {' '.join(args)} falló (código {proc.returncode})") + return proc.stdout.strip() + + +def is_installed(root: Path, game: Game) -> bool: + """Un juego está instalado si su clone existe y tiene un .git.""" + return (repo_dir(root, game.id) / ".git").exists() + + +def download(root: Path, game: Game, log: LogFn = _noop) -> GameMeta: + """Clona (si no existe) o trae el remoto forzado (descartando cambios locales). + + Luego refresca la metadata (descripción Gitea + versión + icono) y la devuelve. + """ + repo = repo_dir(root, game.id) + repo.parent.mkdir(parents=True, exist_ok=True) + + branch = _fetch_default_branch(game, log) + + if (repo / ".git").exists(): + log(f"Actualizando {game.name} (forzado, descartando cambios locales)…") + _run_git(["fetch", "origin", "--prune"], repo, log) + target = branch or _detect_origin_head(repo, log) or "HEAD" + _run_git(["reset", "--hard", f"origin/{target}"], repo, log) + _run_git(["clean", "-fd"], repo, log) + else: + log(f"Clonando {game.name}…") + if repo.exists(): # carpeta a medias sin .git: limpiarla + shutil.rmtree(repo, ignore_errors=True) + _run_git(["clone", game.clone_url, str(repo)], None, log) + + return refresh_metadata(root, game, branch, log) + + +def refresh_metadata( + root: Path, game: Game, branch: str | None = None, log: LogFn = _noop +) -> GameMeta: + """Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea.""" + repo = repo_dir(root, game.id) + meta = load_meta(root, game.id) + + # Descripción + rama por defecto desde la API de Gitea (best-effort). + api = _fetch_gitea_info(game, log) + if api is not None: + meta.description = api.get("description", meta.description) or meta.description + meta.default_branch = api.get("default_branch", meta.default_branch) + elif branch: + meta.default_branch = branch + + # Versión vía version_cmd dentro del repo (best-effort). + meta.version = _read_version(game, repo, log) or meta.version + + meta.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds") + + # Copiar icono desde el repo a la cache. + _copy_icon(root, game, repo, log) + + save_meta(root, game.id, meta) + return meta + + +def _copy_icon(root: Path, game: Game, repo: Path, log: LogFn) -> None: + src = repo / game.icon_rel + if not src.exists(): + log(f"(sin icono en {game.icon_rel})") + return + metadata_dir(root, game.id).mkdir(parents=True, exist_ok=True) + try: + shutil.copyfile(src, icon_path(root, game.id)) + log(f"Icono actualizado desde {game.icon_rel}") + except OSError as exc: + log(f"No se pudo copiar el icono: {exc}") + + +def _read_version(game: Game, repo: Path, log: LogFn) -> str: + if not game.version_cmd: + return "" + try: + proc = subprocess.run( + game.version_cmd, + cwd=str(repo), + shell=True, + capture_output=True, + text=True, + timeout=20, + ) + except (OSError, subprocess.SubprocessError) as exc: + log(f"version_cmd falló: {exc}") + return "" + out = (proc.stdout or "").strip() + return out.splitlines()[0] if out else "" + + +def _fetch_gitea_info(game: Game, log: LogFn) -> dict | None: + if not game.info_url: + return None + try: + req = urllib.request.Request(game.info_url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc: + log(f"No se pudo leer la info de Gitea ({game.info_url}): {exc}") + return None + + +def _fetch_default_branch(game: Game, log: LogFn) -> str | None: + info = _fetch_gitea_info(game, log) + if info: + return info.get("default_branch") + return None + + +def _detect_origin_head(repo: Path, log: LogFn) -> str | None: + """Fallback: deduce la rama por defecto desde origin/HEAD.""" + try: + ref = _run_git( + ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], repo, log + ) + except RuntimeError: + return None + # ref tiene forma "origin/main" + return ref.split("/", 1)[1] if "/" in ref else ref or None diff --git a/jlauncher/metadata.py b/jlauncher/metadata.py new file mode 100644 index 0000000..dc95e77 --- /dev/null +++ b/jlauncher/metadata.py @@ -0,0 +1,58 @@ +"""Lectura/escritura de la metadata cacheada de cada juego (info.json + icon.png).""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path + +from .paths import metadata_dir + +INFO_NAME = "info.json" +ICON_NAME = "icon.png" + + +@dataclass +class GameMeta: + description: str = "" + version: str = "" + default_branch: str = "main" + updated_at: str = "" # ISO-8601; lo rellena el worker tras un update + + +def info_path(root: Path, game_id: str) -> Path: + return metadata_dir(root, game_id) / INFO_NAME + + +def icon_path(root: Path, game_id: str) -> Path: + return metadata_dir(root, game_id) / ICON_NAME + + +def load_meta(root: Path, game_id: str) -> GameMeta: + """Lee info.json cacheado; devuelve GameMeta vacío si no existe o es ilegible.""" + path = info_path(root, game_id) + if not path.exists(): + return GameMeta() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return GameMeta() + return GameMeta( + description=data.get("description", ""), + version=data.get("version", ""), + default_branch=data.get("default_branch", "main"), + updated_at=data.get("updated_at", ""), + ) + + +def save_meta(root: Path, game_id: str, meta: GameMeta) -> None: + path = info_path(root, game_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(asdict(meta), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def has_icon(root: Path, game_id: str) -> bool: + return icon_path(root, game_id).exists() diff --git a/jlauncher/paths.py b/jlauncher/paths.py new file mode 100644 index 0000000..7b628bc --- /dev/null +++ b/jlauncher/paths.py @@ -0,0 +1,53 @@ +"""Resolución de rutas: dónde está games.toml y dónde guardar los datos. + +Cuando se compila con Nuitka (``--standalone``) el atributo global ``__compiled__`` +existe, así que usamos la carpeta del ejecutable. Ejecutando desde fuente usamos la +raíz del proyecto (la carpeta que contiene el paquete ``jlauncher``). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +CONFIG_NAME = "games.toml" + + +def is_compiled() -> bool: + """True si corremos como binario Nuitka (define ``__compiled__`` en cada módulo).""" + return "__compiled__" in globals() + + +def base_dir() -> Path: + """Carpeta base junto a la que viven games.toml y jlauncher_data.""" + if is_compiled(): + return Path(sys.executable).resolve().parent + # Desde fuente: raíz del proyecto = padre del paquete jlauncher/ + return Path(__file__).resolve().parent.parent + + +def config_file() -> Path: + """Ruta a games.toml (junto al ejecutable / raíz del proyecto).""" + return base_dir() / CONFIG_NAME + + +def data_root(data_dir: str = "jlauncher_data") -> Path: + """Carpeta raíz de datos; se crea si no existe.""" + root = base_dir() / data_dir + root.mkdir(parents=True, exist_ok=True) + return root + + +def game_dir(root: Path, game_id: str) -> Path: + """Carpeta de un juego: //.""" + return root / game_id + + +def repo_dir(root: Path, game_id: str) -> Path: + """Carpeta del clone git: //repo/.""" + return game_dir(root, game_id) / "repo" + + +def metadata_dir(root: Path, game_id: str) -> Path: + """Carpeta de metadata cacheada: //metadata/.""" + return game_dir(root, game_id) / "metadata" diff --git a/jlauncher/runner.py b/jlauncher/runner.py new file mode 100644 index 0000000..5228d26 --- /dev/null +++ b/jlauncher/runner.py @@ -0,0 +1,57 @@ +"""Compilar y ejecutar un juego vía subprocess.""" + +from __future__ import annotations + +import subprocess +from collections.abc import Callable +from pathlib import Path + +from .config import Game +from .paths import repo_dir + +LogFn = Callable[[str], None] + + +def _noop(_: str) -> None: + pass + + +def _stream(cmd: str, cwd: Path, log: LogFn) -> int: + """Ejecuta un comando de shell en cwd, retransmitiendo stdout/err línea a línea.""" + log(f"$ {cmd} (cwd={cwd})") + proc = subprocess.Popen( + cmd, + cwd=str(cwd), + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert proc.stdout is not None + for line in proc.stdout: + log(line.rstrip()) + return proc.wait() + + +def run_game(root: Path, game: Game, log: LogFn = _noop) -> int: + """Compila (si hay build_cmd) y ejecuta el juego. Devuelve el código de salida. + + Si build_cmd falla, aborta sin ejecutar. Lanza FileNotFoundError si el repo + no está clonado. + """ + repo = repo_dir(root, game.id) + if not (repo / ".git").exists(): + raise FileNotFoundError( + f"{game.name} no está descargado. Pulsa Download primero." + ) + + if game.build_cmd.strip(): + log(f"Compilando {game.name}…") + code = _stream(game.build_cmd, repo, log) + if code != 0: + log(f"Compilación falló (código {code}). No se ejecuta.") + return code + + log(f"Ejecutando {game.name}…") + return _stream(game.run_cmd, repo, log) diff --git a/jlauncher/ui/__init__.py b/jlauncher/ui/__init__.py new file mode 100644 index 0000000..f92f60a --- /dev/null +++ b/jlauncher/ui/__init__.py @@ -0,0 +1 @@ +"""Componentes de interfaz de jlauncher.""" diff --git a/jlauncher/ui/game_row.py b/jlauncher/ui/game_row.py new file mode 100644 index 0000000..b3e14a3 --- /dev/null +++ b/jlauncher/ui/game_row.py @@ -0,0 +1,123 @@ +"""Fila de la lista: icono, nombre, descripción y botones Download/Run.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, +) + +from ..config import Game +from ..metadata import GameMeta, icon_path, load_meta +from ..paths import repo_dir + +ICON_SIZE = 64 + + +class GameRow(QFrame): + """Widget de una fila. Emite señales cuando se pulsan los botones.""" + + download_requested = Signal(object) # Game + run_requested = Signal(object) # Game + + def __init__(self, game: Game, root: Path, parent=None) -> None: + super().__init__(parent) + self.game = game + self.root = root + self.setObjectName("gameRow") + self.setFrameShape(QFrame.StyledPanel) + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 8, 10, 8) + layout.setSpacing(12) + + # --- Icono --- + self.icon_label = QLabel() + self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE) + self.icon_label.setAlignment(Qt.AlignCenter) + layout.addWidget(self.icon_label) + + # --- Texto (nombre + descripción + estado) --- + text_box = QVBoxLayout() + text_box.setSpacing(2) + self.name_label = QLabel(game.name) + self.name_label.setStyleSheet("font-weight: bold; font-size: 14px;") + self.desc_label = QLabel("") + self.desc_label.setWordWrap(True) + self.desc_label.setStyleSheet("color: #aaaaaa;") + self.status_label = QLabel("") + self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;") + text_box.addWidget(self.name_label) + text_box.addWidget(self.desc_label) + text_box.addWidget(self.status_label) + text_box.addStretch(1) + layout.addLayout(text_box, stretch=1) + + # --- Botones --- + self.download_btn = QPushButton("Download") + self.run_btn = QPushButton("Run") + for btn in (self.download_btn, self.run_btn): + btn.setMinimumWidth(96) + btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.download_btn.clicked.connect(lambda: self.download_requested.emit(self.game)) + self.run_btn.clicked.connect(lambda: self.run_requested.emit(self.game)) + btn_box = QVBoxLayout() + btn_box.addWidget(self.download_btn) + btn_box.addWidget(self.run_btn) + layout.addLayout(btn_box) + + self.refresh() + + # ----------------------------------------------------------------- estado + + def refresh(self) -> None: + """Recarga icono, descripción y estado desde la cache local.""" + meta = load_meta(self.root, self.game.id) + self._set_icon() + self.desc_label.setText(meta.description or "(sin descripción todavía)") + self._set_status(meta) + + def _set_icon(self) -> None: + path = icon_path(self.root, self.game.id) + pixmap = QPixmap(str(path)) if path.exists() else QPixmap() + if pixmap.isNull(): + self.icon_label.setText("🎮") + self.icon_label.setStyleSheet("font-size: 32px;") + else: + self.icon_label.setStyleSheet("") + self.icon_label.setPixmap( + pixmap.scaled( + ICON_SIZE, + ICON_SIZE, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + + def _set_status(self, meta: GameMeta) -> None: + installed = (repo_dir(self.root, self.game.id) / ".git").exists() + if not 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á + else: + ver = f" {meta.version}" if meta.version else "" + self.status_label.setText(f"Instalado{ver}") + self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;") + + # --------------------------------------------------------------- busy UI + + def set_busy(self, busy: bool, message: str = "") -> None: + self.download_btn.setEnabled(not busy) + self.run_btn.setEnabled(not busy) + if busy and message: + self.status_label.setText(message) + self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;") diff --git a/jlauncher/ui/main_window.py b/jlauncher/ui/main_window.py new file mode 100644 index 0000000..58d06fd --- /dev/null +++ b/jlauncher/ui/main_window.py @@ -0,0 +1,125 @@ +"""Ventana principal: lista de juegos con scroll + panel de log.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QThreadPool, Qt +from PySide6.QtWidgets import ( + QMainWindow, + QPlainTextEdit, + QScrollArea, + QSplitter, + QVBoxLayout, + QWidget, +) + +from ..config import Config, Game +from ..workers import DownloadWorker, RunWorker +from .game_row import GameRow + + +class MainWindow(QMainWindow): + def __init__(self, config: Config, root: Path, parent=None) -> None: + super().__init__(parent) + self.config = config + self.root = root + self.pool = QThreadPool.globalInstance() + self.rows: dict[str, GameRow] = {} + # 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("jlauncher — Juegos jailgames") + self.resize(720, 640) + + 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.download_requested.connect(self._on_download) + row.run_requested.connect(self._on_run) + 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; background:#1e1e1e; color:#d4d4d4;" + ) + splitter.addWidget(self.log_view) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + + self.setCentralWidget(splitter) + + # --------------------------------------------------------------- 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)) + + # --------------------------------------------------------------- acciones + + def _on_download(self, game: Game) -> None: + row = self.rows[game.id] + row.set_busy(True, "Descargando…") + self._log(f"=== Download: {game.name} ===") + + worker = DownloadWorker(self.root, game) + 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}: descarga completada ===") + row = self.rows[game.id] + row.set_busy(False) + row.refresh() + + def _on_run(self, game: Game) -> None: + row = self.rows[game.id] + row.set_busy(True, "Ejecutando…") + self._log(f"=== Run: {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}: finalizó con código {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() diff --git a/jlauncher/workers.py b/jlauncher/workers.py new file mode 100644 index 0000000..08d13b8 --- /dev/null +++ b/jlauncher/workers.py @@ -0,0 +1,59 @@ +"""Workers QThread para no congelar la GUI durante git/build/run. + +Cada worker es un QRunnable que ejecuta una operación bloqueante (download o run) y +emite señales hacia la UI a través de un objeto de señales propio. +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, QRunnable, Signal + +from . import gitops, runner +from .config import Game +from .metadata import GameMeta + + +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 + + +class DownloadWorker(QRunnable): + """Clona o actualiza (forzado) y refresca la metadata de un juego.""" + + def __init__(self, root: Path, game: Game) -> None: + super().__init__() + self.root = root + self.game = game + self.signals = _Signals() + + def run(self) -> None: # noqa: D401 - API de QRunnable + try: + meta: GameMeta = gitops.download( + self.root, self.game, log=self.signals.log.emit + ) + except Exception as exc: # noqa: BLE001 - reportar a la UI + self.signals.error.emit(str(exc)) + return + self.signals.finished.emit(meta) + + +class RunWorker(QRunnable): + """Compila (si procede) y ejecuta el juego.""" + + def __init__(self, root: Path, game: Game) -> None: + super().__init__() + self.root = root + self.game = game + self.signals = _Signals() + + def run(self) -> None: # noqa: D401 - API de QRunnable + try: + code = runner.run_game(self.root, self.game, log=self.signals.log.emit) + except Exception as exc: # noqa: BLE001 - reportar a la UI + self.signals.error.emit(str(exc)) + return + self.signals.finished.emit(code) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3508b04 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "jlauncher" +version = "0.1.0" +description = "Lanzador de juegos jailgames: clona, compila y ejecuta repos Gitea" +requires-python = ">=3.11" +dependencies = [ + "PySide6>=6.6", +] + +[project.scripts] +jlauncher = "jlauncher.__main__:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["jlauncher", "jlauncher.ui"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1966c69 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PySide6>=6.6