Llançador inicial amb GUI PySide6: descàrrega i execució de jocs
This commit is contained in:
+7
-1
@@ -1,3 +1,9 @@
|
|||||||
|
# ---> jlauncher
|
||||||
|
# Datos descargados en tiempo de ejecución (clones git + cache de metadata)
|
||||||
|
jlauncher_data/
|
||||||
|
*.dist/
|
||||||
|
*.build/
|
||||||
|
|
||||||
# ---> macOS
|
# ---> macOS
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -5,7 +11,7 @@
|
|||||||
.LSOverride
|
.LSOverride
|
||||||
|
|
||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|||||||
@@ -1,3 +1,67 @@
|
|||||||
# jlauncher
|
# jlauncher
|
||||||
|
|
||||||
Llançador de jailgames desde gitea
|
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/
|
||||||
|
<id_juego>/
|
||||||
|
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/<rama>` + `git clean -fd`),
|
||||||
|
descartando cualquier cambio local. Después refresca la metadata:
|
||||||
|
- descripción desde la API de Gitea (`/api/v1/repos/<org>/<repo>`),
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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."
|
||||||
+48
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""jlauncher — lanzador de juegos jailgames."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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: <root>/<id>/."""
|
||||||
|
return root / game_id
|
||||||
|
|
||||||
|
|
||||||
|
def repo_dir(root: Path, game_id: str) -> Path:
|
||||||
|
"""Carpeta del clone git: <root>/<id>/repo/."""
|
||||||
|
return game_dir(root, game_id) / "repo"
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_dir(root: Path, game_id: str) -> Path:
|
||||||
|
"""Carpeta de metadata cacheada: <root>/<id>/metadata/."""
|
||||||
|
return game_dir(root, game_id) / "metadata"
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Componentes de interfaz de jlauncher."""
|
||||||
@@ -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;")
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PySide6>=6.6
|
||||||
Reference in New Issue
Block a user