Compare commits
9 Commits
9428f335de
...
90b7bb5fb1
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b7bb5fb1 | |||
| 021e865179 | |||
| 667eade660 | |||
| bfa01f31e3 | |||
| 0334e79480 | |||
| 694d67f11e | |||
| 235a3966d2 | |||
| 9d13c2434b | |||
| b71df66e22 |
+9
-1
@@ -1,3 +1,11 @@
|
||||
# ---> jlauncher
|
||||
# Datos descargados en tiempo de ejecución (clones git + cache de metadata)
|
||||
jlauncher_data/
|
||||
# Preferencias locales del usuario
|
||||
settings.json
|
||||
*.dist/
|
||||
*.build/
|
||||
|
||||
# ---> macOS
|
||||
# General
|
||||
.DS_Store
|
||||
@@ -5,7 +13,7 @@
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
@@ -1,3 +1,96 @@
|
||||
# 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 (interfaz en catalán)
|
||||
|
||||
- **Descarrega**: 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`.
|
||||
- **Juga**: 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.
|
||||
- **Esborra**: elimina la descarga local (carpeta del juego en `jlauncher_data/`),
|
||||
sin quitar el juego del `games.toml`.
|
||||
|
||||
Menú **Opcions**: *Amaga els jocs no descarregats* (filtro persistente) y
|
||||
*Comprova actualitzacions* (marca los juegos descargados con commits pendientes).
|
||||
Las preferencias se guardan en `settings.json` junto al ejecutable.
|
||||
|
||||
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, onefile)
|
||||
|
||||
`build.sh` lo hace todo: crea el `.venv`, instala dependencias (PySide6 + Nuitka +
|
||||
zstandard) y compila un único ejecutable comprimido, empaquetándolo en
|
||||
`dist/jlauncher-v<versión>-<os>-<arch>.tar.gz` junto a `games.toml`.
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
# binario: dist/jlauncher (+ dist/games.toml)
|
||||
```
|
||||
|
||||
El binario crea `jlauncher_data/` y `settings.json` **junto a sí mismo** (resuelto vía
|
||||
`NUITKA_ONEFILE_DIRECTORY`). El punto de entrada para empaquetar es `app.py` (desde
|
||||
fuente se ejecuta con `python -m jlauncher`).
|
||||
|
||||
### Prerequisitos del sistema (no los instala el script)
|
||||
|
||||
- **Python 3.11+** (usa `tomllib`).
|
||||
- Un **compilador C**:
|
||||
- Linux: `gcc` y `patchelf` (p. ej. `apt install build-essential patchelf python3-dev`).
|
||||
- macOS: **Xcode Command Line Tools** (`xcode-select --install`); aquí *no* hace falta
|
||||
patchelf (Nuitka usa `install_name_tool`).
|
||||
- `git` en el PATH.
|
||||
|
||||
### macOS
|
||||
|
||||
Compila en el propio Mac (Nuitka no compila cruzado): `./build.sh` genera
|
||||
`jlauncher-v…-darwin-arm64.tar.gz`. Como el binario no va firmado, la primera vez quizá
|
||||
debas hacer `xattr -dr com.apple.quarantine jlauncher` o abrirlo con clic derecho → Abrir.
|
||||
Lánzalo desde terminal (`./jlauncher`).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Punto de entrada para empaquetar con Nuitka.
|
||||
|
||||
Importa el paquete ``jlauncher`` con imports absolutos para que los imports relativos
|
||||
internos (``from .config import …``) resuelvan correctamente al compilar. Para ejecutar
|
||||
desde fuente sigue valiendo ``python -m jlauncher``.
|
||||
"""
|
||||
|
||||
from jlauncher.__main__ import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compila jlauncher a un binario standalone con Nuitka y empaqueta un tar.gz de release.
|
||||
# Requisitos del sistema: python3-dev, gcc, patchelf (ver README).
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
VERSION="$(sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' jlauncher/__init__.py | head -n1)"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "[build] no se pudo leer __version__ de jlauncher/__init__.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
ARCH="$(uname -m)"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE_NAME="jlauncher-v${VERSION}-${OS}-${ARCH}"
|
||||
|
||||
if [ ! -d .venv ]; then
|
||||
echo "[build] creando venv…"
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --quiet --upgrade pip
|
||||
fi
|
||||
|
||||
echo "[build] sincronizando dependencias…"
|
||||
.venv/bin/pip install --quiet -r requirements.txt
|
||||
|
||||
if ! .venv/bin/python -c "import nuitka" 2>/dev/null; then
|
||||
echo "[build] instalando nuitka en el venv…"
|
||||
.venv/bin/pip install --quiet "nuitka[onefile]"
|
||||
fi
|
||||
|
||||
# zstandard habilita la compresión del onefile (binario mucho más pequeño).
|
||||
if ! .venv/bin/python -c "import zstandard" 2>/dev/null; then
|
||||
echo "[build] instalando zstandard (compresión onefile)…"
|
||||
.venv/bin/pip install --quiet zstandard
|
||||
fi
|
||||
|
||||
echo "[build] versión: v${VERSION}"
|
||||
echo "[build] limpiando artefactos previos…"
|
||||
rm -rf dist build app.build app.dist app.onefile-build
|
||||
|
||||
echo "[build] compilando (PySide6 onefile; puede tardar varios minutos)…"
|
||||
.venv/bin/python -m nuitka \
|
||||
--onefile \
|
||||
--assume-yes-for-downloads \
|
||||
--enable-plugin=pyside6 \
|
||||
--include-package=jlauncher \
|
||||
--output-dir=dist \
|
||||
--output-filename=jlauncher \
|
||||
--remove-output \
|
||||
--lto=yes \
|
||||
app.py
|
||||
|
||||
echo "[build] copiando games.toml junto al binario…"
|
||||
cp games.toml dist/games.toml
|
||||
|
||||
echo "[build] empaquetando release ${RELEASE_NAME}.tar.gz…"
|
||||
tar -czf "dist/${RELEASE_NAME}.tar.gz" -C dist jlauncher games.toml
|
||||
|
||||
echo "[build] hecho:"
|
||||
ls -lh "dist/jlauncher" "dist/games.toml" "dist/${RELEASE_NAME}.tar.gz"
|
||||
echo "[build] el binario crea jlauncher_data/ y settings.json junto a sí mismo."
|
||||
echo "[build] distribuir: descomprimir el tar.gz (jlauncher + games.toml juntos)."
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# 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"
|
||||
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"
|
||||
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"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""jlauncher — lanzador de juegos jailgames."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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
|
||||
from .ui.theme import apply_theme, watch_system_theme
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("jlauncher")
|
||||
apply_theme(app)
|
||||
watch_system_theme(app)
|
||||
|
||||
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 carregant games.toml",
|
||||
f"No s'ha pogut llegir la configuració a:\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,228 @@
|
||||
"""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 os
|
||||
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 game_dir, metadata_dir, repo_dir
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
_HTTP_TIMEOUT = 15
|
||||
|
||||
|
||||
def _noop(_: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _auth_args(token: str) -> list[str]:
|
||||
"""Args -c para autenticar git ante Gitea con un token, sin tocar .git/config."""
|
||||
if not token:
|
||||
return []
|
||||
return ["-c", f"http.extraHeader=Authorization: token {token}"]
|
||||
|
||||
|
||||
def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str:
|
||||
"""Ejecuta git capturando salida; lanza RuntimeError si falla.
|
||||
|
||||
Si se pasa `token`, inyecta la cabecera de autorización de Gitea (para clone/fetch
|
||||
de repos privados) y la redacta en el log para no filtrarla.
|
||||
"""
|
||||
cmd = ["git", *_auth_args(token), *args]
|
||||
|
||||
def emit(line: str) -> None:
|
||||
log(line.replace(token, "***") if token else line)
|
||||
|
||||
# Echo del comando: nunca mostramos los args -c con el token.
|
||||
emit("$ " + " ".join(["git", *args]))
|
||||
# GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido
|
||||
# en vez de colgarse esperando credenciales (no hay terminal en la GUI).
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
)
|
||||
if proc.stdout:
|
||||
emit(proc.stdout.rstrip())
|
||||
if proc.stderr:
|
||||
emit(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 check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> 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/<rama> 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, token=token)
|
||||
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 delete_local(root: Path, game: Game, log: LogFn = _noop) -> None:
|
||||
"""Esborra la descàrrega local del joc (clon + metadata), sense tocar el TOML."""
|
||||
target = game_dir(root, game.id)
|
||||
if not target.exists():
|
||||
log(f"{game.name}: no hi ha res a esborrar")
|
||||
return
|
||||
log(f"Esborrant la descàrrega local de {game.name}…")
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
|
||||
def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> 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, token)
|
||||
|
||||
if (repo / ".git").exists():
|
||||
log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…")
|
||||
_run_git(["fetch", "origin", "--prune"], repo, log, token=token)
|
||||
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"Clonant {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, token=token)
|
||||
|
||||
return refresh_metadata(root, game, branch, log, token)
|
||||
|
||||
|
||||
def refresh_metadata(
|
||||
root: Path,
|
||||
game: Game,
|
||||
branch: str | None = None,
|
||||
log: LogFn = _noop,
|
||||
token: str = "",
|
||||
) -> 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, token)
|
||||
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"(sense icona a {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"Icona actualitzada des de {game.icon_rel}")
|
||||
except OSError as exc:
|
||||
log(f"No s'ha pogut copiar la icona: {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 ha fallat: {exc}")
|
||||
return ""
|
||||
out = (proc.stdout or "").strip()
|
||||
return out.splitlines()[0] if out else ""
|
||||
|
||||
|
||||
def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None:
|
||||
if not game.info_url:
|
||||
return None
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
try:
|
||||
req = urllib.request.Request(game.info_url, headers=headers)
|
||||
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 s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None:
|
||||
info = _fetch_gitea_info(game, log, token)
|
||||
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,61 @@
|
||||
"""Resolución de rutas: dónde está games.toml y dónde guardar los datos.
|
||||
|
||||
Compilado con Nuitka, ``__compiled__`` existe. En modo ``--onefile`` la carpeta del
|
||||
binario real la expone ``NUITKA_ONEFILE_DIRECTORY`` (Nuitka 4.x); con versiones que usan
|
||||
``NUITKA_ONEFILE_BINARY`` tomamos su carpeta; si no, ``sys.executable`` (standalone).
|
||||
Ejecutando desde fuente usamos la raíz del proyecto (la carpeta que contiene ``jlauncher``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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():
|
||||
directory = os.environ.get("NUITKA_ONEFILE_DIRECTORY")
|
||||
if directory:
|
||||
return Path(directory).resolve()
|
||||
binary = os.environ.get("NUITKA_ONEFILE_BINARY")
|
||||
if binary:
|
||||
return Path(binary).resolve().parent
|
||||
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à descarregat. Prem Descarrega primer."
|
||||
)
|
||||
|
||||
if game.build_cmd.strip():
|
||||
log(f"Compilant {game.name}…")
|
||||
code = _stream(game.build_cmd, repo, log)
|
||||
if code != 0:
|
||||
log(f"La compilació ha fallat (codi {code}). No s'executa.")
|
||||
return code
|
||||
|
||||
log(f"Executant {game.name}…")
|
||||
return _stream(game.run_cmd, repo, log)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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
|
||||
gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona)
|
||||
|
||||
|
||||
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", [])),
|
||||
gitea_token=str(data.get("gitea_token", "")),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""Componentes de interfaz de jlauncher."""
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Fila de la llista: la fila sencera actua com un botó.
|
||||
|
||||
Un clic fa l'acció principal segons l'estat:
|
||||
- no descarregat → descarrega
|
||||
- update pendent → actualitza
|
||||
- descarregat i al dia → juga
|
||||
Un indicador d'icona a la dreta (passiu) mostra què farà el clic. L'esborrat es
|
||||
gestiona per fora (mètode a definir); aquest widget només exposa la senyal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QPalette, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..config import Game
|
||||
from ..metadata import GameMeta, icon_path, load_meta
|
||||
from ..paths import repo_dir
|
||||
|
||||
ICON_SIZE = 64
|
||||
|
||||
|
||||
class GameRow(QFrame):
|
||||
"""Fila clicable. Emet `activated` en clic i `delete_requested` per al futur esborrat."""
|
||||
|
||||
activated = Signal(object) # Game — clic sobre la fila (acció principal)
|
||||
delete_requested = Signal(object) # Game — esborrar la descàrrega local
|
||||
|
||||
def __init__(self, game: Game, root: Path, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.game = game
|
||||
self.root = root
|
||||
self._update_available = False
|
||||
self._busy = False
|
||||
self._delete_mode = False
|
||||
self.setObjectName("gameRow")
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
# La fila és un botó: cursor de mà i ressaltat en passar el ratolí.
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setStyleSheet(
|
||||
"QFrame#gameRow:hover { background: rgba(127, 127, 127, 0.15); }"
|
||||
)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# --- Icona del joc ---
|
||||
self.icon_label = QLabel()
|
||||
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
|
||||
self.icon_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.icon_label)
|
||||
|
||||
# --- Text (nom + descripció + estat) ---
|
||||
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)
|
||||
# Text atenuat que segueix la paleta (clar/fosc) en lloc d'un gris fix.
|
||||
self.desc_label.setForegroundRole(QPalette.PlaceholderText)
|
||||
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)
|
||||
|
||||
# --- Indicador d'acció (passiu, text): què farà el clic ---
|
||||
self.action_label = QLabel()
|
||||
self.action_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.action_label.setMinimumWidth(96)
|
||||
layout.addWidget(self.action_label)
|
||||
|
||||
# Els fills no intercepten el ratolí: tot clic arriba a la fila.
|
||||
for child in (
|
||||
self.icon_label,
|
||||
self.name_label,
|
||||
self.desc_label,
|
||||
self.status_label,
|
||||
self.action_label,
|
||||
):
|
||||
child.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------- clic fila
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
if (
|
||||
event.button() == Qt.LeftButton
|
||||
and not self._busy
|
||||
and self.rect().contains(event.position().toPoint())
|
||||
):
|
||||
self.activated.emit(self.game)
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
# ----------------------------------------------------------------- estat
|
||||
|
||||
def is_installed(self) -> bool:
|
||||
return (repo_dir(self.root, self.game.id) / ".git").exists()
|
||||
|
||||
def primary_action_is_download(self) -> bool:
|
||||
"""True si el clic ha de descarregar/actualitzar; False si ha de jugar."""
|
||||
return (not self.is_installed()) or self._update_available
|
||||
|
||||
def set_update_available(self, available: bool) -> None:
|
||||
"""Marca/desmarca la fila com a 'té actualització pendent'."""
|
||||
self._update_available = available
|
||||
self.refresh()
|
||||
|
||||
def set_delete_mode(self, on: bool) -> None:
|
||||
"""Activa/desactiva el mode esborrar (canvia el text d'acció a 'Esborra')."""
|
||||
self._delete_mode = on
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Recarrega icona, descripció i estat des de la cache local."""
|
||||
meta = load_meta(self.root, self.game.id)
|
||||
self._set_icon()
|
||||
self.desc_label.setText(meta.description or "(sense descripció encara)")
|
||||
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 = self.is_installed()
|
||||
|
||||
# Text d'estat (independent del mode esborrar).
|
||||
if not installed:
|
||||
self.status_label.setText("No descarregat")
|
||||
self.status_label.setStyleSheet("color: #cc8855; font-size: 11px;")
|
||||
elif self._update_available:
|
||||
self.status_label.setText("⬆ Actualització 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"Descarregat{ver}")
|
||||
self.status_label.setStyleSheet("color: #6fae6f; font-size: 11px;")
|
||||
|
||||
# Text d'acció (què fa el clic).
|
||||
if self._delete_mode:
|
||||
# Només es pot esborrar el que està descarregat.
|
||||
self._set_action("Esborra", "#d9534f") if installed else self.action_label.clear()
|
||||
elif not installed:
|
||||
self._set_action("Descarrega", "#4a90d9")
|
||||
elif self._update_available:
|
||||
self._set_action("Actualitza", "#e0a030")
|
||||
else:
|
||||
self._set_action("Juga", "#6fae6f")
|
||||
|
||||
def _set_action(self, text: str, color: str) -> None:
|
||||
self.action_label.setText(text)
|
||||
self.action_label.setStyleSheet(
|
||||
f"color: {color}; font-weight: bold; font-size: 13px;"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- busy UI
|
||||
|
||||
def set_busy(self, busy: bool, message: str = "") -> None:
|
||||
self._busy = busy
|
||||
if busy and message:
|
||||
self.status_label.setText(message)
|
||||
self.status_label.setStyleSheet("color: #d0d060; font-size: 11px;")
|
||||
@@ -0,0 +1,277 @@
|
||||
"""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.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QInputDialog,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
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)
|
||||
|
||||
# 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("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)
|
||||
|
||||
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 _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)
|
||||
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("=== Comprovació d'actualitzacions acabada ===")
|
||||
|
||||
def _check_error(self, msg: str) -> None:
|
||||
self.action_check.setEnabled(True)
|
||||
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)
|
||||
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
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tema claro/oscuro siguiendo el esquema de color del sistema.
|
||||
|
||||
Usa el estilo Fusion (consistente entre plataformas) y aplica una paleta clara u
|
||||
oscura según ``QStyleHints.colorScheme()``. Los widgets que no fuerzan colores
|
||||
propios (consola de log, fondos) siguen automáticamente esta paleta.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
def _portal_is_dark() -> bool | None:
|
||||
"""Consulta el portal XDG (org.freedesktop.appearance color-scheme).
|
||||
|
||||
Devuelve True (1=dark), False (2=light) o None (0=sin preferencia / no disponible).
|
||||
Es la fuente más fiable en Linux cuando Qt no detecta el esquema.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"gdbus", "call", "--session",
|
||||
"--dest", "org.freedesktop.portal.Desktop",
|
||||
"--object-path", "/org/freedesktop/portal/desktop",
|
||||
"--method", "org.freedesktop.portal.Settings.Read",
|
||||
"org.freedesktop.appearance", "color-scheme",
|
||||
],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
if "uint32 1" in out.stdout:
|
||||
return True
|
||||
if "uint32 2" in out.stdout:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _gsettings_is_dark() -> bool | None:
|
||||
"""Respaldo: gsettings color-scheme de GNOME ('prefer-dark')."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
|
||||
capture_output=True, text=True, timeout=2,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
return "dark" in out.stdout.lower()
|
||||
|
||||
|
||||
def system_is_dark(app: QApplication) -> bool:
|
||||
"""True si el sistema está en modo oscuro.
|
||||
|
||||
Prioriza el aviso de Qt; si Qt dice claro (o no soporta la API), consulta el
|
||||
portal XDG y gsettings, que en sesiones xcb suelen ser más fiables que Qt.
|
||||
"""
|
||||
hints = app.styleHints()
|
||||
if hasattr(hints, "colorScheme"):
|
||||
try:
|
||||
if hints.colorScheme() == Qt.ColorScheme.Dark:
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - APIs viejas/raras: caer al fallback
|
||||
pass
|
||||
for probe in (_portal_is_dark, _gsettings_is_dark):
|
||||
val = probe()
|
||||
if val is not None:
|
||||
return val
|
||||
return app.palette().color(QPalette.Window).lightness() < 128
|
||||
|
||||
|
||||
def _dark_palette() -> QPalette:
|
||||
p = QPalette()
|
||||
window = QColor(0x2b, 0x2b, 0x2b)
|
||||
base = QColor(0x1e, 0x1e, 0x1e)
|
||||
alt = QColor(0x35, 0x35, 0x35)
|
||||
text = QColor(0xdc, 0xdc, 0xdc)
|
||||
disabled = QColor(0x7f, 0x7f, 0x7f)
|
||||
highlight = QColor(0x2a, 0x82, 0xda)
|
||||
|
||||
p.setColor(QPalette.Window, window)
|
||||
p.setColor(QPalette.WindowText, text)
|
||||
p.setColor(QPalette.Base, base)
|
||||
p.setColor(QPalette.AlternateBase, alt)
|
||||
p.setColor(QPalette.ToolTipBase, window)
|
||||
p.setColor(QPalette.ToolTipText, text)
|
||||
p.setColor(QPalette.Text, text)
|
||||
p.setColor(QPalette.Button, alt)
|
||||
p.setColor(QPalette.ButtonText, text)
|
||||
p.setColor(QPalette.BrightText, Qt.red)
|
||||
p.setColor(QPalette.Link, highlight)
|
||||
p.setColor(QPalette.Highlight, highlight)
|
||||
p.setColor(QPalette.HighlightedText, Qt.black)
|
||||
p.setColor(QPalette.PlaceholderText, disabled)
|
||||
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
|
||||
p.setColor(QPalette.Disabled, role, disabled)
|
||||
return p
|
||||
|
||||
|
||||
def apply_theme(app: QApplication) -> None:
|
||||
"""Aplica estilo Fusion + paleta acorde al esquema del sistema."""
|
||||
app.setStyle("Fusion")
|
||||
if system_is_dark(app):
|
||||
app.setPalette(_dark_palette())
|
||||
else:
|
||||
app.setPalette(app.style().standardPalette())
|
||||
|
||||
|
||||
def watch_system_theme(app: QApplication) -> None:
|
||||
"""Re-aplica el tema cuando el sistema cambia entre claro/oscuro en caliente."""
|
||||
hints = app.styleHints()
|
||||
if hasattr(hints, "colorSchemeChanged"):
|
||||
hints.colorSchemeChanged.connect(lambda _scheme: apply_theme(app))
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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
|
||||
result = Signal(str, bool) # (game_id, has_update) — CheckUpdatesWorker
|
||||
|
||||
|
||||
class DownloadWorker(QRunnable):
|
||||
"""Clona o actualiza (forzado) y refresca la metadata de un juego."""
|
||||
|
||||
def __init__(self, root: Path, game: Game, token: str = "") -> None:
|
||||
super().__init__()
|
||||
self.root = root
|
||||
self.game = game
|
||||
self.token = token
|
||||
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, token=self.token
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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], token: str = "") -> None:
|
||||
super().__init__()
|
||||
self.root = root
|
||||
self.games = games
|
||||
self.token = token
|
||||
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, token=self.token
|
||||
)
|
||||
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)
|
||||
@@ -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