Compare commits
32 Commits
9428f335de
..
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e7c620f7 | |||
| adcc230b38 | |||
| e60c3cc6eb | |||
| 1e530a413d | |||
| 9147eb56fb | |||
| ecdf389c7c | |||
| c055b98d15 | |||
| fb65f4f249 | |||
| be8c886ad1 | |||
| fd8eedab76 | |||
| a71a1be88d | |||
| 34811038eb | |||
| 95a76d0d76 | |||
| c879127401 | |||
| be3cb44ae2 | |||
| 0085c63ace | |||
| cebc76b6e3 | |||
| 38c4f50965 | |||
| 962e5b054f | |||
| 93efbb06c4 | |||
| e0a93a9c28 | |||
| e9f0098df8 | |||
| c51b7b74ed | |||
| 90b7bb5fb1 | |||
| 021e865179 | |||
| 667eade660 | |||
| bfa01f31e3 | |||
| 0334e79480 | |||
| 694d67f11e | |||
| 235a3966d2 | |||
| 9d13c2434b | |||
| b71df66e22 |
+10
@@ -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
|
# ---> macOS
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -6,6 +14,8 @@
|
|||||||
|
|
||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
# En FS case-insensitive (macOS) el patrón «Icon» se traga la carpeta icon/; re-inclúyela.
|
||||||
|
!/icon/
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|||||||
@@ -1,3 +1,113 @@
|
|||||||
# 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 (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 (.app + .dmg)
|
||||||
|
|
||||||
|
Compila en el propio Mac (Nuitka no compila cruzado). En macOS, `./build.sh` no genera un
|
||||||
|
binario suelto sino una **app nativa**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh
|
||||||
|
# -> dist/jlauncher.app
|
||||||
|
# -> dist/jlauncher-v<versión>-macos-<arch>.dmg (arrastrar la app a Aplicaciones)
|
||||||
|
```
|
||||||
|
|
||||||
|
El icono vive en `icon/`: el build usa `icon/icon.icns` para el bundle y copia
|
||||||
|
`icon/icon.png` (1024×1024) dentro del `.app` para el diálogo «Quant a». Para cambiarlo,
|
||||||
|
sustituye esos ficheros (regenerables con `icon/create_icons.py`).
|
||||||
|
|
||||||
|
A diferencia del onefile, la `.app` **no** escribe junto a sí misma (rompería al moverla a
|
||||||
|
`/Applications`): guarda sus datos en
|
||||||
|
`~/Library/Application Support/jailgames/jlauncher/` (`jlauncher_data/`, `settings.json` y
|
||||||
|
una copia editable de `games.toml`, sembrada la primera vez desde el bundle).
|
||||||
|
|
||||||
|
La app va **sin firma Developer ID** (firma ad-hoc), así que Gatekeeper avisará la primera
|
||||||
|
vez: ábrela con **clic derecho → Abrir**, o ejecuta
|
||||||
|
`xattr -dr com.apple.quarantine /Applications/jlauncher.app`.
|
||||||
|
|||||||
@@ -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,138 @@
|
|||||||
|
# Compila jlauncher con Nuitka y empaqueta un release para Windows.
|
||||||
|
# - jlauncher.exe (onefile, GUI sin consola, con icono) + games.toml + .zip.
|
||||||
|
# Equivalente nativo de build.sh (que cubre Linux/macOS).
|
||||||
|
# Requisitos del sistema (no los instala el script):
|
||||||
|
# - Python 3.10+ (con el launcher «py» o «python» en el PATH).
|
||||||
|
# - Un compilador C: Nuitka usa MSVC (Build Tools de Visual Studio) si existe;
|
||||||
|
# si no, ofrece descargar MinGW automáticamente (-AssumeYesForDownloads).
|
||||||
|
# - git en el PATH (lo usa la app en tiempo de ejecución, no el build).
|
||||||
|
#
|
||||||
|
# Uso: pwsh -File build.ps1 (o: .\build.ps1 desde PowerShell)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
|
||||||
|
# Situarnos en la carpeta del script.
|
||||||
|
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Set-Location $Here
|
||||||
|
|
||||||
|
# --- Versión (leída de jlauncher/__init__.py) ---------------------------------
|
||||||
|
$initPath = Join-Path $Here 'jlauncher\__init__.py'
|
||||||
|
$verMatch = Select-String -Path $initPath -Pattern '^__version__\s*=\s*"([^"]*)"' |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $verMatch) {
|
||||||
|
Write-Error '[build] no se pudo leer __version__ de jlauncher/__init__.py'
|
||||||
|
}
|
||||||
|
$Version = $verMatch.Matches[0].Groups[1].Value
|
||||||
|
|
||||||
|
# Arquitectura al estilo uname -m (x86_64 / arm64).
|
||||||
|
$Arch = switch ($env:PROCESSOR_ARCHITECTURE) {
|
||||||
|
'AMD64' { 'x86_64' }
|
||||||
|
'ARM64' { 'arm64' }
|
||||||
|
default { $env:PROCESSOR_ARCHITECTURE.ToLower() }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Intérprete de Python -----------------------------------------------------
|
||||||
|
# Preferimos el launcher «py -3»; si no, «python».
|
||||||
|
function Resolve-Python {
|
||||||
|
if (Get-Command py -ErrorAction SilentlyContinue) { return @('py', '-3') }
|
||||||
|
if (Get-Command python -ErrorAction SilentlyContinue) { return @('python') }
|
||||||
|
Write-Error '[build] no se encontró Python (ni «py» ni «python» en el PATH).'
|
||||||
|
}
|
||||||
|
$Py = Resolve-Python
|
||||||
|
|
||||||
|
# Comprueba si un módulo se puede importar en el venv, SIN abortar el script:
|
||||||
|
# bajo $ErrorActionPreference='Stop', un código de salida ≠ 0 (módulo ausente)
|
||||||
|
# se trataría como error terminante. Aquí relajamos eso solo para la sonda.
|
||||||
|
function Test-PyModule([string]$Module) {
|
||||||
|
$prev = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try {
|
||||||
|
& $VenvPython -c "import $Module" 2>&1 | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$ErrorActionPreference = $prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- venv (en Windows los ejecutables viven en .venv\Scripts) -----------------
|
||||||
|
$VenvPython = Join-Path $Here '.venv\Scripts\python.exe'
|
||||||
|
if (-not (Test-Path $VenvPython)) {
|
||||||
|
Write-Host '[build] creando venv…'
|
||||||
|
& $Py[0] $Py[1..($Py.Count - 1)] -m venv .venv
|
||||||
|
& $VenvPython -m pip install --quiet --upgrade pip
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host '[build] sincronizando dependencias…'
|
||||||
|
& $VenvPython -m pip install --quiet -r requirements.txt
|
||||||
|
|
||||||
|
# Nuitka + zstandard (compresión del onefile → binario más pequeño).
|
||||||
|
if (-not (Test-PyModule 'nuitka')) {
|
||||||
|
Write-Host '[build] instalando nuitka en el venv…'
|
||||||
|
& $VenvPython -m pip install --quiet 'nuitka[onefile]'
|
||||||
|
}
|
||||||
|
if (-not (Test-PyModule 'zstandard')) {
|
||||||
|
Write-Host '[build] instalando zstandard (compresión onefile)…'
|
||||||
|
& $VenvPython -m pip install --quiet zstandard
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[build] versión: v$Version"
|
||||||
|
Write-Host '[build] limpiando artefactos previos…'
|
||||||
|
foreach ($d in 'dist', 'build', 'app.build', 'app.dist', 'app.onefile-build') {
|
||||||
|
if (Test-Path $d) { Remove-Item -Recurse -Force $d }
|
||||||
|
}
|
||||||
|
|
||||||
|
$IconIco = 'icon\icon.ico'
|
||||||
|
$IconPng = 'icon\icon.png'
|
||||||
|
if (-not (Test-Path $IconIco)) {
|
||||||
|
Write-Error "[build] falta $IconIco"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Compilación onefile ------------------------------------------------------
|
||||||
|
Write-Host '[build] compilando jlauncher.exe (PySide6 onefile; puede tardar varios minutos)…'
|
||||||
|
& $VenvPython -m nuitka `
|
||||||
|
--onefile `
|
||||||
|
--assume-yes-for-downloads `
|
||||||
|
--enable-plugin=pyside6 `
|
||||||
|
--include-package=jlauncher `
|
||||||
|
--windows-icon-from-ico=$IconIco `
|
||||||
|
--windows-console-mode=disable `
|
||||||
|
--company-name=jailgames `
|
||||||
|
--product-name=jlauncher `
|
||||||
|
--product-version=$Version `
|
||||||
|
--file-version=$Version `
|
||||||
|
--output-dir=dist `
|
||||||
|
--output-filename=jlauncher `
|
||||||
|
--remove-output `
|
||||||
|
app.py
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error '[build] Nuitka falló.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$Exe = 'dist\jlauncher.exe'
|
||||||
|
if (-not (Test-Path $Exe)) {
|
||||||
|
Write-Error '[build] Nuitka no produjo dist\jlauncher.exe'
|
||||||
|
}
|
||||||
|
|
||||||
|
# games.toml junto al .exe (la app lo lee desde ahí: base_dir junto al binario).
|
||||||
|
Write-Host '[build] copiando games.toml junto al ejecutable…'
|
||||||
|
Copy-Item games.toml dist\games.toml -Force
|
||||||
|
|
||||||
|
# icon.png para el diálogo «Quant a» (paths.app_icon_path → base_dir()\icon\icon.png).
|
||||||
|
Write-Host '[build] sembrando icon\icon.png para el diálogo «Quant a»…'
|
||||||
|
New-Item -ItemType Directory -Force -Path dist\icon | Out-Null
|
||||||
|
Copy-Item $IconPng dist\icon\icon.png -Force
|
||||||
|
|
||||||
|
# --- Empaquetado .zip ---------------------------------------------------------
|
||||||
|
$ReleaseName = "jlauncher-v$Version-windows-$Arch"
|
||||||
|
$Zip = "dist\$ReleaseName.zip"
|
||||||
|
Write-Host "[build] empaquetando release $ReleaseName.zip…"
|
||||||
|
Compress-Archive -Path dist\jlauncher.exe, dist\games.toml, dist\icon `
|
||||||
|
-DestinationPath $Zip -Force
|
||||||
|
|
||||||
|
Write-Host '[build] hecho:'
|
||||||
|
Get-Item dist\jlauncher.exe, dist\games.toml, $Zip |
|
||||||
|
Format-Table -AutoSize Name, @{Name = 'Size'; Expression = { '{0:N0} B' -f $_.Length } }
|
||||||
|
Write-Host '[build] el binario crea jlauncher_data\ y settings.json junto a sí mismo.'
|
||||||
|
Write-Host '[build] distribuir: descomprimir el .zip (jlauncher.exe + games.toml + icon\ juntos).'
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compila jlauncher con Nuitka y empaqueta un release.
|
||||||
|
# - Linux: binario onefile + tar.gz.
|
||||||
|
# - macOS: jlauncher.app (con icono) dentro de un .dmg arrastrable a /Applications.
|
||||||
|
# Requisitos del sistema (no los instala el script): ver README (compilador C, git…).
|
||||||
|
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:]')"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
ICON_ICNS="icon/icon.icns"
|
||||||
|
ICON_PNG="icon/icon.png"
|
||||||
|
|
||||||
|
if [ "$OS" = "darwin" ]; then
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# macOS: app bundle + DMG
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
if [ ! -f "$ICON_ICNS" ]; then
|
||||||
|
echo "[build] falta ${ICON_ICNS}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[build] compilando jlauncher.app (PySide6; puede tardar varios minutos)…"
|
||||||
|
.venv/bin/python -m nuitka \
|
||||||
|
--standalone \
|
||||||
|
--macos-create-app-bundle \
|
||||||
|
--macos-app-icon="$ICON_ICNS" \
|
||||||
|
--macos-app-name="Jail Launcher" \
|
||||||
|
--macos-app-version="$VERSION" \
|
||||||
|
--macos-signed-app-name=com.jailgames.jlauncher \
|
||||||
|
--company-name=jailgames \
|
||||||
|
--product-name=jlauncher \
|
||||||
|
--product-version="$VERSION" \
|
||||||
|
--assume-yes-for-downloads \
|
||||||
|
--enable-plugin=pyside6 \
|
||||||
|
--include-package=jlauncher \
|
||||||
|
--output-dir=dist \
|
||||||
|
--output-filename=jlauncher \
|
||||||
|
--remove-output \
|
||||||
|
app.py
|
||||||
|
|
||||||
|
# Según la versión, Nuitka nombra el bundle a partir del script de entrada
|
||||||
|
# (app.py -> app.app). Lo normalizamos a jlauncher.app.
|
||||||
|
APP="dist/jlauncher.app"
|
||||||
|
if [ ! -d "$APP" ]; then
|
||||||
|
PRODUCED="$(find dist -maxdepth 1 -name '*.app' | head -n1)"
|
||||||
|
if [ -z "$PRODUCED" ]; then
|
||||||
|
echo "[build] Nuitka no produjo ningún .app en dist/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf "$APP"
|
||||||
|
mv "$PRODUCED" "$APP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[build] sembrando games.toml y icon.png en Contents/Resources…"
|
||||||
|
cp games.toml "$APP/Contents/Resources/games.toml"
|
||||||
|
cp "$ICON_PNG" "$APP/Contents/Resources/icon.png" # usado por el diálogo «Quant a»
|
||||||
|
|
||||||
|
# Bundle ad-hoc (sin Developer ID): quitamos quarantine para abrir sin fricción local.
|
||||||
|
xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true
|
||||||
|
|
||||||
|
DMG="dist/jlauncher-v${VERSION}-macos-${ARCH}.dmg"
|
||||||
|
echo "[build] empaquetando ${DMG}…"
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
cp -R "$APP" "$STAGE/"
|
||||||
|
ln -s /Applications "$STAGE/Applications"
|
||||||
|
rm -f "$DMG"
|
||||||
|
hdiutil create -volname "jlauncher" -srcfolder "$STAGE" \
|
||||||
|
-ov -format UDZO "$DMG" >/dev/null
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
|
||||||
|
echo "[build] hecho:"
|
||||||
|
du -sh "$APP" | sed 's/^/[build] /'
|
||||||
|
ls -lh "$DMG"
|
||||||
|
echo "[build] la app guarda datos en ~/Library/Application Support/jailgames/jlauncher/."
|
||||||
|
echo "[build] distribuir: el .dmg (arrastrar jlauncher.app a Aplicaciones)."
|
||||||
|
echo "[build] sin firma Developer ID: primera apertura con clic derecho → Abrir."
|
||||||
|
else
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Linux (y resto): binario onefile + tar.gz
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
RELEASE_NAME="jlauncher-v${VERSION}-${OS}-${ARCH}"
|
||||||
|
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)."
|
||||||
|
fi
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
# 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"
|
||||||
|
# players (opcional) texto del pill de jugadores, p.ej. "1-2 jugadors" (Gitea no lo da)
|
||||||
|
# author (opcional) texto del pill de autor, p.ej. "JailDesigner"
|
||||||
|
#
|
||||||
|
# Otros pills (topics, descripción, fecha de lanzamiento, versión) salen
|
||||||
|
# automáticamente de Gitea / git; no hace falta escribirlos aquí.
|
||||||
|
|
||||||
|
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"
|
||||||
|
players = "1-2 jugadors"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
|
[[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"
|
||||||
|
players = "1-2 jugadors"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
|
[[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"
|
||||||
|
players = "1 jugador"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
|
[[game]]
|
||||||
|
id = "aee"
|
||||||
|
name = "Aventures en Egipte"
|
||||||
|
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/aee.git"
|
||||||
|
build_cmd = ""
|
||||||
|
run_cmd = "make run"
|
||||||
|
players = "1 jugador"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
|
[[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"
|
||||||
|
players = "1-2 jugadors"
|
||||||
|
author = "JailDesigner"
|
||||||
|
|
||||||
|
[[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"
|
||||||
|
players = "1 jugador"
|
||||||
|
author = "JailDesigner"
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependencies():
|
||||||
|
"""Verifica que ImageMagick esté instalado"""
|
||||||
|
try:
|
||||||
|
subprocess.run(['magick', '--version'], capture_output=True, check=True)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
print("Error: ImageMagick no está instalado o no se encuentra en el PATH")
|
||||||
|
print("Instala ImageMagick desde: https://imagemagick.org/script/download.php")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Verificar iconutil solo en macOS
|
||||||
|
if sys.platform == 'darwin':
|
||||||
|
try:
|
||||||
|
# iconutil no tiene --version, mejor usar which o probar con -h
|
||||||
|
result = subprocess.run(['which', 'iconutil'], capture_output=True, check=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("✓ iconutil disponible - se crearán archivos .ico e .icns")
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
print("Error: iconutil no está disponible (solo funciona en macOS)")
|
||||||
|
print("Solo se creará el archivo .ico")
|
||||||
|
else:
|
||||||
|
print("ℹ️ Sistema no-macOS detectado - solo se creará archivo .ico")
|
||||||
|
|
||||||
|
|
||||||
|
def create_icons(input_file):
|
||||||
|
"""Crea archivos .icns e .ico a partir de un PNG"""
|
||||||
|
|
||||||
|
# Verificar que el archivo existe
|
||||||
|
if not os.path.isfile(input_file):
|
||||||
|
print(f"Error: El archivo {input_file} no existe.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Obtener información del archivo
|
||||||
|
file_path = Path(input_file)
|
||||||
|
file_dir = file_path.parent
|
||||||
|
file_name = file_path.stem # Nombre sin extensión
|
||||||
|
file_extension = file_path.suffix
|
||||||
|
|
||||||
|
if file_extension.lower() not in ['.png', '.jpg', '.jpeg', '.bmp', '.tiff']:
|
||||||
|
print(f"Advertencia: {file_extension} puede no ser compatible. Se recomienda usar PNG.")
|
||||||
|
|
||||||
|
# Crear archivo .ico usando el método simplificado
|
||||||
|
ico_output = file_dir / f"{file_name}.ico"
|
||||||
|
try:
|
||||||
|
print(f"Creando {ico_output}...")
|
||||||
|
subprocess.run([
|
||||||
|
'magick', str(input_file),
|
||||||
|
'-define', 'icon:auto-resize=256,128,64,48,32,16',
|
||||||
|
str(ico_output)
|
||||||
|
], check=True)
|
||||||
|
print(f"✓ Archivo .ico creado: {ico_output}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error creando archivo .ico: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Crear archivo .icns (solo en macOS)
|
||||||
|
if sys.platform == 'darwin':
|
||||||
|
try:
|
||||||
|
# Crear carpeta temporal para iconset
|
||||||
|
temp_folder = file_dir / "icon.iconset"
|
||||||
|
|
||||||
|
# Eliminar carpeta temporal si existe
|
||||||
|
if temp_folder.exists():
|
||||||
|
shutil.rmtree(temp_folder)
|
||||||
|
|
||||||
|
# Crear carpeta temporal
|
||||||
|
temp_folder.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Definir los tamaños y nombres de archivo para .icns
|
||||||
|
icon_sizes = [
|
||||||
|
(16, "icon_16x16.png"),
|
||||||
|
(32, "icon_16x16@2x.png"),
|
||||||
|
(32, "icon_32x32.png"),
|
||||||
|
(64, "icon_32x32@2x.png"),
|
||||||
|
(128, "icon_128x128.png"),
|
||||||
|
(256, "icon_128x128@2x.png"),
|
||||||
|
(256, "icon_256x256.png"),
|
||||||
|
(512, "icon_256x256@2x.png"),
|
||||||
|
(512, "icon_512x512.png"),
|
||||||
|
(1024, "icon_512x512@2x.png")
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Generando imágenes para .icns...")
|
||||||
|
# Crear cada tamaño de imagen
|
||||||
|
for size, output_name in icon_sizes:
|
||||||
|
output_path = temp_folder / output_name
|
||||||
|
subprocess.run([
|
||||||
|
'magick', str(input_file),
|
||||||
|
'-resize', f'{size}x{size}',
|
||||||
|
str(output_path)
|
||||||
|
], check=True)
|
||||||
|
|
||||||
|
# Crear archivo .icns usando iconutil
|
||||||
|
icns_output = file_dir / f"{file_name}.icns"
|
||||||
|
print(f"Creando {icns_output}...")
|
||||||
|
subprocess.run([
|
||||||
|
'iconutil', '-c', 'icns',
|
||||||
|
str(temp_folder),
|
||||||
|
'-o', str(icns_output)
|
||||||
|
], check=True)
|
||||||
|
|
||||||
|
# Limpiar carpeta temporal
|
||||||
|
if temp_folder.exists():
|
||||||
|
shutil.rmtree(temp_folder)
|
||||||
|
|
||||||
|
print(f"✓ Archivo .icns creado: {icns_output}")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error creando archivo .icns: {e}")
|
||||||
|
# Limpiar carpeta temporal en caso de error
|
||||||
|
if temp_folder.exists():
|
||||||
|
shutil.rmtree(temp_folder)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("ℹ️ Archivo .icns no creado (solo disponible en macOS)")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Función principal"""
|
||||||
|
# Verificar argumentos
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f"Uso: {sys.argv[0]} ARCHIVO")
|
||||||
|
print("Ejemplo: python3 create_icons.py imagen.png")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
input_file = sys.argv[1]
|
||||||
|
|
||||||
|
# Verificar dependencias
|
||||||
|
check_dependencies()
|
||||||
|
|
||||||
|
# Crear iconos
|
||||||
|
if create_icons(input_file):
|
||||||
|
print("\n✅ Proceso completado exitosamente")
|
||||||
|
else:
|
||||||
|
print("\n❌ El proceso falló")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
"""jlauncher — lanzador de juegos jailgames."""
|
||||||
|
|
||||||
|
__version__ = "1.0.5"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Punto de entrada: arranca la QApplication y la ventana principal."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||||
|
|
||||||
|
from .config import load_config
|
||||||
|
from .paths import app_icon_path, config_file, data_root
|
||||||
|
from .ui.main_window import MainWindow
|
||||||
|
from .ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("jlauncher")
|
||||||
|
app.setApplicationDisplayName("Jail Launcher")
|
||||||
|
icon_path = app_icon_path()
|
||||||
|
if icon_path is not None:
|
||||||
|
app.setWindowIcon(QIcon(str(icon_path)))
|
||||||
|
# Tema del sistema para el posible diálogo de error previo a la ventana;
|
||||||
|
# MainWindow re-aplica el modo guardado (system/light/dark) y vigila los cambios.
|
||||||
|
apply_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,83 @@
|
|||||||
|
"""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
|
||||||
|
players: str = "" # texto manual para el pill de jugadores (Gitea no lo tiene)
|
||||||
|
author: str = "" # texto manual para el pill de autor
|
||||||
|
|
||||||
|
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,
|
||||||
|
players=entry.get("players", ""),
|
||||||
|
author=entry.get("author", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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,352 @@
|
|||||||
|
"""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 stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tolerancia a repos offline / inalcanzables ----------------------------
|
||||||
|
# Dos mecanismos complementarios para que git no se cuelgue:
|
||||||
|
# 1) low-speed abort: si una transferencia baja de `stall_limit` bytes/s durante
|
||||||
|
# `stall_time` segundos, git la aborta (transferencias que se estancan a media
|
||||||
|
# descarga).
|
||||||
|
# 2) techo duro: timeout en subprocess.run que mata git pase lo que pase (cubre
|
||||||
|
# cuelgues de conexión TCP/DNS cuando el host está offline, donde aún no fluyen
|
||||||
|
# bytes y el low-speed no llega a dispararse).
|
||||||
|
# Los valores son configurables desde settings.json (ver settings.Settings).
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NetConfig:
|
||||||
|
fetch_timeout: float = 60 # techo para fetch / comprobar update (operación ligera)
|
||||||
|
clone_timeout: float = 900 # techo para clone (puede traer un repo grande)
|
||||||
|
http_timeout: float = 15 # techo para la API de Gitea (urllib)
|
||||||
|
stall_limit: int = 1000 # bytes/s por debajo de los cuales se considera estancado
|
||||||
|
stall_time: int = 20 # segundos por debajo del límite -> abortar
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_NET = NetConfig()
|
||||||
|
|
||||||
|
# Windows: evita que cada `git` (aplicación de consola) abra una ventana negra
|
||||||
|
# cuando el lanzador corre como GUI sin consola (el .exe compilado con Nuitka
|
||||||
|
# --windows-console-mode=disable). En el resto de SO es 0 (sin efecto).
|
||||||
|
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _noop(_: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _force_rmtree(path: Path, log: LogFn = _noop) -> None:
|
||||||
|
"""Esborra un arbre de fitxers, fins i tot amb fitxers de només-lectura.
|
||||||
|
|
||||||
|
A Windows els objectes de ``.git`` es creen com a només-lectura i fan que
|
||||||
|
``shutil.rmtree`` falli amb ``PermissionError``; cal llevar el bit d'escriptura
|
||||||
|
i reintentar. A diferència de ``ignore_errors=True``, aquí els errors que no
|
||||||
|
puguem resoldre es registren al log en lloc d'empassar-se en silenci.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle(func: Callable, p: str, _exc: object) -> None:
|
||||||
|
try:
|
||||||
|
os.chmod(p, stat.S_IWRITE)
|
||||||
|
func(p) # reintenta l'operació (unlink/rmdir) que havia fallat
|
||||||
|
except OSError as exc:
|
||||||
|
log(f"No s'ha pogut esborrar {p}: {exc}")
|
||||||
|
|
||||||
|
# Python 3.12 va renombrar el paràmetre `onerror` a `onexc`; suportem tots dos.
|
||||||
|
if sys.version_info >= (3, 12):
|
||||||
|
shutil.rmtree(path, onexc=handle)
|
||||||
|
else:
|
||||||
|
shutil.rmtree(path, onerror=handle)
|
||||||
|
|
||||||
|
|
||||||
|
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 _net_args(net: NetConfig | None) -> list[str]:
|
||||||
|
"""Args -c para abortar transferencias estancadas (solo afectan al transporte http)."""
|
||||||
|
if net is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
"-c",
|
||||||
|
f"http.lowSpeedLimit={net.stall_limit}",
|
||||||
|
"-c",
|
||||||
|
f"http.lowSpeedTime={net.stall_time}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(
|
||||||
|
args: list[str],
|
||||||
|
cwd: Path | None,
|
||||||
|
log: LogFn,
|
||||||
|
token: str = "",
|
||||||
|
timeout: float | None = None,
|
||||||
|
net: NetConfig | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Ejecuta git capturando salida; lanza RuntimeError si falla o agota `timeout`.
|
||||||
|
|
||||||
|
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. Si se pasa `net`
|
||||||
|
añade el low-speed abort; `timeout` impone un techo duro (mata git si se cuelga).
|
||||||
|
"""
|
||||||
|
cmd = ["git", *_auth_args(token), *_net_args(net), *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).
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(cwd) if cwd else None,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||||
|
timeout=timeout,
|
||||||
|
creationflags=_NO_WINDOW,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
emit(
|
||||||
|
f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s): "
|
||||||
|
"el repositori no respon o no és accessible."
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s)"
|
||||||
|
) from None
|
||||||
|
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 = "",
|
||||||
|
net: NetConfig = DEFAULT_NET,
|
||||||
|
) -> 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,
|
||||||
|
timeout=net.fetch_timeout,
|
||||||
|
net=net,
|
||||||
|
)
|
||||||
|
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}…")
|
||||||
|
_force_rmtree(target, log)
|
||||||
|
|
||||||
|
|
||||||
|
def download(
|
||||||
|
root: Path,
|
||||||
|
game: Game,
|
||||||
|
log: LogFn = _noop,
|
||||||
|
token: str = "",
|
||||||
|
net: NetConfig = DEFAULT_NET,
|
||||||
|
) -> 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, net)
|
||||||
|
|
||||||
|
if (repo / ".git").exists():
|
||||||
|
log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…")
|
||||||
|
_run_git(
|
||||||
|
["fetch", "origin", "--prune"],
|
||||||
|
repo,
|
||||||
|
log,
|
||||||
|
token=token,
|
||||||
|
timeout=net.fetch_timeout,
|
||||||
|
net=net,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
_force_rmtree(repo, log)
|
||||||
|
_run_git(
|
||||||
|
["clone", game.clone_url, str(repo)],
|
||||||
|
None,
|
||||||
|
log,
|
||||||
|
token=token,
|
||||||
|
timeout=net.clone_timeout,
|
||||||
|
net=net,
|
||||||
|
)
|
||||||
|
|
||||||
|
return refresh_metadata(root, game, branch, log, token, net)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_metadata(
|
||||||
|
root: Path,
|
||||||
|
game: Game,
|
||||||
|
branch: str | None = None,
|
||||||
|
log: LogFn = _noop,
|
||||||
|
token: str = "",
|
||||||
|
net: NetConfig = DEFAULT_NET,
|
||||||
|
) -> 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 + topics + fecha de creación desde la API de Gitea (best-effort).
|
||||||
|
api = _fetch_gitea_info(game, log, token, net)
|
||||||
|
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)
|
||||||
|
meta.topics = list(api.get("topics") or [])
|
||||||
|
meta.created_at = api.get("created_at", meta.created_at) or meta.created_at
|
||||||
|
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,
|
||||||
|
creationflags=_NO_WINDOW,
|
||||||
|
)
|
||||||
|
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 = "", net: NetConfig = DEFAULT_NET
|
||||||
|
) -> 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=net.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 = "", net: NetConfig = DEFAULT_NET
|
||||||
|
) -> str | None:
|
||||||
|
info = _fetch_gitea_info(game, log, token, net)
|
||||||
|
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,62 @@
|
|||||||
|
"""Lectura/escritura de la metadata cacheada de cada juego (info.json + icon.png)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
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
|
||||||
|
topics: list[str] = field(default_factory=list) # tags de Gitea (controller, sdl3…)
|
||||||
|
created_at: str = "" # ISO-8601 de creación del repo en Gitea (≈ lanzamiento)
|
||||||
|
|
||||||
|
|
||||||
|
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", ""),
|
||||||
|
topics=list(data.get("topics", [])),
|
||||||
|
created_at=data.get("created_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,128 @@
|
|||||||
|
"""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 shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONFIG_NAME = "games.toml"
|
||||||
|
|
||||||
|
# Carpeta de soporte en macOS cuando corremos como .app: ~/Library/Application Support/…
|
||||||
|
APP_SUPPORT_VENDOR = "jailgames"
|
||||||
|
APP_SUPPORT_APP = "jlauncher"
|
||||||
|
|
||||||
|
|
||||||
|
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 macos_app_bundle() -> Path | None:
|
||||||
|
"""Ruta del .app si corremos como bundle de macOS (``…/jlauncher.app``); si no, None.
|
||||||
|
|
||||||
|
En un app bundle el ejecutable vive en ``…/jlauncher.app/Contents/MacOS/jlauncher``,
|
||||||
|
dentro de un árbol no escribible al instalarse en ``/Applications``. Detectarlo nos
|
||||||
|
deja redirigir datos y settings a ``~/Library/Application Support``.
|
||||||
|
"""
|
||||||
|
if not is_compiled() or sys.platform != "darwin":
|
||||||
|
return None
|
||||||
|
exe = Path(sys.executable).resolve()
|
||||||
|
for parent in exe.parents:
|
||||||
|
if parent.suffix == ".app":
|
||||||
|
return parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon_path() -> Path | None:
|
||||||
|
"""Ruta al PNG del icono para la UI (icono de ventana/Dock y diálogo «Quant a»).
|
||||||
|
|
||||||
|
En .app vive en ``Contents/Resources/icon.png``; desde fuente, en ``assets/icon.png``.
|
||||||
|
Devuelve None si no se encuentra.
|
||||||
|
"""
|
||||||
|
bundle = macos_app_bundle()
|
||||||
|
if bundle is not None:
|
||||||
|
candidate = bundle / "Contents" / "Resources" / "icon.png"
|
||||||
|
else:
|
||||||
|
candidate = base_dir() / "icon" / "icon.png"
|
||||||
|
return candidate if candidate.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def support_dir() -> Path:
|
||||||
|
"""Carpeta de soporte escribible en macOS (.app); se crea si no existe."""
|
||||||
|
root = (
|
||||||
|
Path.home()
|
||||||
|
/ "Library"
|
||||||
|
/ "Application Support"
|
||||||
|
/ APP_SUPPORT_VENDOR
|
||||||
|
/ APP_SUPPORT_APP
|
||||||
|
)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def writable_base() -> Path:
|
||||||
|
"""Base donde escribir datos/settings: Application Support si .app, si no base_dir()."""
|
||||||
|
if macos_app_bundle() is not None:
|
||||||
|
return support_dir()
|
||||||
|
return base_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def config_file() -> Path:
|
||||||
|
"""Ruta a games.toml.
|
||||||
|
|
||||||
|
En .app vive en Application Support (editable y persistente); se siembra la primera vez
|
||||||
|
desde ``Contents/Resources/games.toml`` del bundle. Si no, junto al ejecutable / raíz.
|
||||||
|
"""
|
||||||
|
bundle = macos_app_bundle()
|
||||||
|
if bundle is not None:
|
||||||
|
target = writable_base() / CONFIG_NAME
|
||||||
|
if not target.exists():
|
||||||
|
seed = bundle / "Contents" / "Resources" / CONFIG_NAME
|
||||||
|
if seed.exists():
|
||||||
|
shutil.copy2(seed, target)
|
||||||
|
return target
|
||||||
|
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 = writable_base() / 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,127 @@
|
|||||||
|
"""Compilar y ejecutar un juego vía subprocess."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import Game
|
||||||
|
from .paths import repo_dir
|
||||||
|
|
||||||
|
LogFn = Callable[[str], None]
|
||||||
|
|
||||||
|
# Windows: evita que la compilación/ejecución (shell + make/gcc, apps de consola)
|
||||||
|
# abra ventanas negras cuando el lanzador corre como GUI sin consola (el .exe).
|
||||||
|
# En el resto de SO es 0 (sin efecto).
|
||||||
|
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||||
|
|
||||||
|
# Directorios habituales de herramientas de línea de comandos (Homebrew, MacPorts…).
|
||||||
|
# Una .app de macOS lanzada desde Finder/Dock NO hereda el PATH del shell de login,
|
||||||
|
# así que herramientas como cmake, instaladas con Homebrew en /opt/homebrew/bin
|
||||||
|
# (Apple Silicon) o /usr/local/bin (Intel), quedan fuera del PATH y `make` no las
|
||||||
|
# encuentra. Las añadimos explícitamente. En Linux estos paths no existen y se
|
||||||
|
# filtran solos.
|
||||||
|
_EXTRA_PATH_DIRS = (
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/opt/homebrew/sbin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/local/sbin",
|
||||||
|
"/opt/local/bin",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prefijos de Homebrew/MacPorts. Una .app lanzada desde Finder/Dock tampoco
|
||||||
|
# hereda CPATH/LIBRARY_PATH/PKG_CONFIG_PATH del shell, así que el compilador no
|
||||||
|
# encuentra las cabeceras (p.ej. <SDL3/SDL_filesystem.h>) ni las librerías
|
||||||
|
# instaladas con `brew`. Añadimos sus include/lib explícitamente. En Linux estos
|
||||||
|
# paths no existen y se filtran solos.
|
||||||
|
_EXTRA_PREFIXES = (
|
||||||
|
"/opt/homebrew",
|
||||||
|
"/usr/local",
|
||||||
|
"/opt/local",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _noop(_: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_env() -> dict[str, str]:
|
||||||
|
"""Entorno para los subprocesos con un PATH que incluye los directorios de
|
||||||
|
herramientas habituales aunque la app se lance desde Finder/Dock."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
parts = env.get("PATH", "").split(os.pathsep)
|
||||||
|
parts = [p for p in parts if p]
|
||||||
|
for d in _EXTRA_PATH_DIRS:
|
||||||
|
if d not in parts and os.path.isdir(d):
|
||||||
|
parts.append(d)
|
||||||
|
env["PATH"] = os.pathsep.join(parts)
|
||||||
|
|
||||||
|
# Rutas de cabeceras (CPATH), librerías (LIBRARY_PATH) y pkg-config para que
|
||||||
|
# el compilador encuentre dependencias instaladas con brew/MacPorts.
|
||||||
|
includes, libs, pkgconfigs = [], [], []
|
||||||
|
for prefix in _EXTRA_PREFIXES:
|
||||||
|
inc, lib = f"{prefix}/include", f"{prefix}/lib"
|
||||||
|
if os.path.isdir(inc):
|
||||||
|
includes.append(inc)
|
||||||
|
if os.path.isdir(lib):
|
||||||
|
libs.append(lib)
|
||||||
|
pkgconfigs.append(f"{lib}/pkgconfig")
|
||||||
|
_prepend(env, "CPATH", includes)
|
||||||
|
_prepend(env, "LIBRARY_PATH", libs)
|
||||||
|
_prepend(env, "PKG_CONFIG_PATH", pkgconfigs)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _prepend(env: dict[str, str], var: str, dirs: list[str]) -> None:
|
||||||
|
"""Antepone dirs al valor de env[var] (lista separada por os.pathsep), sin
|
||||||
|
duplicar entradas ya presentes."""
|
||||||
|
existing = [p for p in env.get(var, "").split(os.pathsep) if p]
|
||||||
|
new = [d for d in dirs if d not in existing]
|
||||||
|
if new:
|
||||||
|
env[var] = os.pathsep.join(new + existing)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
env=_launch_env(),
|
||||||
|
creationflags=_NO_WINDOW,
|
||||||
|
)
|
||||||
|
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,89 @@
|
|||||||
|
"""Preferencias persistentes en settings.json, junto a los datos de la app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import writable_base
|
||||||
|
|
||||||
|
SETTINGS_NAME = "settings.json"
|
||||||
|
|
||||||
|
_THEMES = ("system", "light", "dark")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_theme(value) -> str:
|
||||||
|
"""Normaliza el tema a uno válido; cae a 'system' si es desconocido."""
|
||||||
|
return value if value in _THEMES else "system"
|
||||||
|
|
||||||
|
|
||||||
|
_CONSOLE_MODES = ("show", "auto", "hide")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_console_mode(value) -> str:
|
||||||
|
"""Normaliza el modo de consola; cae a 'auto' si es desconocido."""
|
||||||
|
return value if value in _CONSOLE_MODES else "auto"
|
||||||
|
|
||||||
|
|
||||||
|
_SORT_ORDERS = ("default", "name")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_sort_order(value) -> str:
|
||||||
|
"""Normaliza el orden de la lista; cae a 'default' (orden del TOML) si es desconocido."""
|
||||||
|
return value if value in _SORT_ORDERS else "default"
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
check_updates_on_start: bool = False # comprobar updates automáticamente al iniciar
|
||||||
|
theme: str = "system" # tema de la UI: "system" | "light" | "dark"
|
||||||
|
console_mode: str = "auto" # consola de log: "show" | "auto" | "hide"
|
||||||
|
sort_order: str = "default" # orden de la lista: "default" (TOML) | "name"
|
||||||
|
# Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s).
|
||||||
|
git_fetch_timeout: int = 60 # techo para fetch / comprobar update
|
||||||
|
git_clone_timeout: int = 900 # techo para clone (repo grande)
|
||||||
|
http_timeout: int = 15 # techo para la API de Gitea
|
||||||
|
git_stall_limit: int = 1000 # bytes/s: por debajo se considera transferencia estancada
|
||||||
|
git_stall_time: int = 20 # segundos estancado antes de abortar
|
||||||
|
|
||||||
|
|
||||||
|
def settings_path() -> Path:
|
||||||
|
return writable_base() / 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", "")),
|
||||||
|
check_updates_on_start=bool(data.get("check_updates_on_start", False)),
|
||||||
|
theme=_valid_theme(data.get("theme", "system")),
|
||||||
|
console_mode=_valid_console_mode(data.get("console_mode", "auto")),
|
||||||
|
sort_order=_valid_sort_order(data.get("sort_order", "default")),
|
||||||
|
git_fetch_timeout=int(data.get("git_fetch_timeout", 60)),
|
||||||
|
git_clone_timeout=int(data.get("git_clone_timeout", 900)),
|
||||||
|
http_timeout=int(data.get("http_timeout", 15)),
|
||||||
|
git_stall_limit=int(data.get("git_stall_limit", 1000)),
|
||||||
|
git_stall_time=int(data.get("git_stall_time", 20)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,71 @@
|
|||||||
|
"""Layout que coloca los widgets en fila y salta de línea cuando no caben.
|
||||||
|
|
||||||
|
Port mínimo del ejemplo FlowLayout de Qt: se usa para los 'pills' de cada fila,
|
||||||
|
que pueden ser muchos (topics) y deben envolver sin desbordar horizontalmente.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt
|
||||||
|
from PySide6.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QWidget
|
||||||
|
|
||||||
|
|
||||||
|
class FlowLayout(QLayout):
|
||||||
|
def __init__(self, parent: QWidget | None = None, spacing: int = 6) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._items: list[QLayoutItem] = []
|
||||||
|
self._spacing = spacing
|
||||||
|
self.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - API Qt
|
||||||
|
self._items.append(item)
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self._items)
|
||||||
|
|
||||||
|
def itemAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - API Qt
|
||||||
|
return self._items[index] if 0 <= index < len(self._items) else None
|
||||||
|
|
||||||
|
def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - API Qt
|
||||||
|
return self._items.pop(index) if 0 <= index < len(self._items) else None
|
||||||
|
|
||||||
|
def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - API Qt
|
||||||
|
return Qt.Orientation(0)
|
||||||
|
|
||||||
|
def hasHeightForWidth(self) -> bool: # noqa: N802 - API Qt
|
||||||
|
return True
|
||||||
|
|
||||||
|
def heightForWidth(self, width: int) -> int: # noqa: N802 - API Qt
|
||||||
|
return self._layout(QRect(0, 0, width, 0), apply=False)
|
||||||
|
|
||||||
|
def setGeometry(self, rect: QRect) -> None: # noqa: N802 - API Qt
|
||||||
|
super().setGeometry(rect)
|
||||||
|
self._layout(rect, apply=True)
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize: # noqa: N802 - API Qt
|
||||||
|
return self.minimumSize()
|
||||||
|
|
||||||
|
def minimumSize(self) -> QSize: # noqa: N802 - API Qt
|
||||||
|
size = QSize()
|
||||||
|
for item in self._items:
|
||||||
|
size = size.expandedTo(item.minimumSize())
|
||||||
|
m: QMargins = self.contentsMargins()
|
||||||
|
return size + QSize(m.left() + m.right(), m.top() + m.bottom())
|
||||||
|
|
||||||
|
def _layout(self, rect: QRect, apply: bool) -> int:
|
||||||
|
m: QMargins = self.contentsMargins()
|
||||||
|
eff = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())
|
||||||
|
x, y, line_h = eff.x(), eff.y(), 0
|
||||||
|
for item in self._items:
|
||||||
|
hint = item.sizeHint()
|
||||||
|
next_x = x + hint.width() + self._spacing
|
||||||
|
if next_x - self._spacing > eff.right() and line_h > 0:
|
||||||
|
x = eff.x()
|
||||||
|
y = y + line_h + self._spacing
|
||||||
|
next_x = x + hint.width() + self._spacing
|
||||||
|
line_h = 0
|
||||||
|
if apply:
|
||||||
|
item.setGeometry(QRect(QPoint(x, y), hint))
|
||||||
|
x = next_x
|
||||||
|
line_h = max(line_h, hint.height())
|
||||||
|
return y + line_h - rect.y() + m.bottom()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Fila de la llista: la fila sencera actua com un botó, amb estil de targeta.
|
||||||
|
|
||||||
|
Un clic fa l'acció principal segons l'estat:
|
||||||
|
- no descarregat → descarrega
|
||||||
|
- update pendent → actualitza
|
||||||
|
- descarregat i al dia → juga
|
||||||
|
A la dreta un indicador de text (passiu) mostra què farà el clic. Sota el títol i la
|
||||||
|
descripció es pinten 'pills' amb la metadata: estat, versió, data, jugadors, autor i
|
||||||
|
els topics de Gitea. L'esborrat es gestiona per fora; aquest widget només l'exposa.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRectF, Qt, Signal
|
||||||
|
from PySide6.QtGui import QPainter, QPainterPath, QPalette, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..config import Game
|
||||||
|
from ..metadata import GameMeta, icon_path, load_meta
|
||||||
|
from ..paths import repo_dir
|
||||||
|
from .flow_layout import FlowLayout
|
||||||
|
|
||||||
|
ICON_SIZE = 64
|
||||||
|
ICON_RADIUS = 14
|
||||||
|
|
||||||
|
# Colors d'estat (text + fons translúcid del pill).
|
||||||
|
_STATUS_COLORS = {
|
||||||
|
"none": ("#cc8855", "rgba(204, 136, 85, 0.18)"), # no descarregat
|
||||||
|
"update": ("#e0a030", "rgba(224, 160, 48, 0.20)"), # actualització disponible
|
||||||
|
"ok": ("#6fae6f", "rgba(111, 174, 111, 0.18)"), # descarregat i al dia
|
||||||
|
}
|
||||||
|
_NEUTRAL_BG = "rgba(127, 127, 127, 0.18)" # pills informatius (segueix clar/fosc)
|
||||||
|
|
||||||
|
|
||||||
|
def _rounded_pixmap(src: QPixmap, size: int, radius: int) -> QPixmap:
|
||||||
|
"""Escala omplint el quadrat, retalla al centre i arrodoneix les cantonades."""
|
||||||
|
scaled = src.scaled(
|
||||||
|
size, size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation
|
||||||
|
)
|
||||||
|
x = max(0, (scaled.width() - size) // 2)
|
||||||
|
y = max(0, (scaled.height() - size) // 2)
|
||||||
|
cropped = scaled.copy(x, y, size, size)
|
||||||
|
|
||||||
|
out = QPixmap(size, size)
|
||||||
|
out.fill(Qt.transparent)
|
||||||
|
painter = QPainter(out)
|
||||||
|
painter.setRenderHint(QPainter.Antialiasing)
|
||||||
|
path = QPainterPath()
|
||||||
|
path.addRoundedRect(QRectF(0, 0, size, size), radius, radius)
|
||||||
|
painter.setClipPath(path)
|
||||||
|
painter.drawPixmap(0, 0, cropped)
|
||||||
|
painter.end()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_text_color() -> str:
|
||||||
|
"""Color de text actual de la paleta, com a rgb() concret (no 'palette(text)',
|
||||||
|
que Qt cacheja als stylesheets i no es refresca en canviar de tema)."""
|
||||||
|
c = QApplication.palette().color(QPalette.WindowText)
|
||||||
|
return f"rgb({c.red()}, {c.green()}, {c.blue()})"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pill(text: str, fg: str | None = None, bg: str = _NEUTRAL_BG,
|
||||||
|
bold: bool = False) -> QLabel:
|
||||||
|
"""Una 'pill' arrodonida amb fons translúcid; passiva al ratolí.
|
||||||
|
|
||||||
|
Sense `fg` explícit s'usa el color de text de la paleta vigent (pills neutres).
|
||||||
|
"""
|
||||||
|
pill = QLabel(text)
|
||||||
|
pill.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
weight = "600" if bold else "normal"
|
||||||
|
pill.setStyleSheet(
|
||||||
|
f"QLabel {{ background: {bg}; color: {fg or _palette_text_color()}; "
|
||||||
|
f"border-radius: 9px; padding: 2px 8px; font-size: 11px; "
|
||||||
|
f"font-weight: {weight}; }}"
|
||||||
|
)
|
||||||
|
return pill
|
||||||
|
|
||||||
|
|
||||||
|
class GameRow(QFrame):
|
||||||
|
"""Fila clicable. Emet `activated` en clic i `delete_requested` per a l'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)
|
||||||
|
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 (arrodonida) ---
|
||||||
|
self.icon_label = QLabel()
|
||||||
|
self.icon_label.setFixedSize(ICON_SIZE, ICON_SIZE)
|
||||||
|
self.icon_label.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(self.icon_label, alignment=Qt.AlignTop)
|
||||||
|
|
||||||
|
# --- Text: títol + descripció + pills (centrat verticalment) ---
|
||||||
|
text_box = QVBoxLayout()
|
||||||
|
text_box.setSpacing(4)
|
||||||
|
|
||||||
|
self.name_label = QLabel(game.name)
|
||||||
|
self.name_label.setStyleSheet("font-weight: bold; font-size: 18px;")
|
||||||
|
self.desc_label = QLabel("")
|
||||||
|
self.desc_label.setWordWrap(True)
|
||||||
|
self.desc_label.setForegroundRole(QPalette.PlaceholderText)
|
||||||
|
|
||||||
|
# Contenidor de pills amb FlowLayout (envolten si no caben).
|
||||||
|
self.pills_box = QWidget()
|
||||||
|
self.pills_layout = FlowLayout(self.pills_box, spacing=6)
|
||||||
|
self.pills_box.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
# Sense fons propi: que no pinti cap banda darrere dels pills.
|
||||||
|
self.pills_box.setAttribute(Qt.WA_NoSystemBackground, True)
|
||||||
|
self.pills_box.setAutoFillBackground(False)
|
||||||
|
sp = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||||
|
sp.setHeightForWidth(True)
|
||||||
|
self.pills_box.setSizePolicy(sp)
|
||||||
|
|
||||||
|
text_box.addStretch(1)
|
||||||
|
text_box.addWidget(self.name_label)
|
||||||
|
text_box.addWidget(self.desc_label)
|
||||||
|
text_box.addWidget(self.pills_box)
|
||||||
|
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)
|
||||||
|
|
||||||
|
for child in (self.name_label, self.desc_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ó, pills 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._rebuild_pills(meta)
|
||||||
|
self._set_action(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(_rounded_pixmap(pixmap, ICON_SIZE, ICON_RADIUS))
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- pills
|
||||||
|
|
||||||
|
def _clear_pills(self) -> None:
|
||||||
|
while self.pills_layout.count():
|
||||||
|
item = self.pills_layout.takeAt(0)
|
||||||
|
w = item.widget() if item else None
|
||||||
|
if w is not None:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
def _rebuild_pills(self, meta: GameMeta) -> None:
|
||||||
|
self._clear_pills()
|
||||||
|
installed = self.is_installed()
|
||||||
|
|
||||||
|
# 1) Estat (pill amb color).
|
||||||
|
if not installed:
|
||||||
|
fg, bg = _STATUS_COLORS["none"]
|
||||||
|
self.pills_layout.addWidget(_make_pill("No descarregat", fg, bg, bold=True))
|
||||||
|
elif self._update_available:
|
||||||
|
fg, bg = _STATUS_COLORS["update"]
|
||||||
|
self.pills_layout.addWidget(
|
||||||
|
_make_pill("⬆ Actualització", fg, bg, bold=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fg, bg = _STATUS_COLORS["ok"]
|
||||||
|
self.pills_layout.addWidget(_make_pill("✓ Descarregat", fg, bg, bold=True))
|
||||||
|
|
||||||
|
# 2) Versió (només si està descarregat i la coneixem).
|
||||||
|
if installed and meta.version:
|
||||||
|
self.pills_layout.addWidget(_make_pill(meta.version))
|
||||||
|
|
||||||
|
# 3) Data de llançament (creació del repo a Gitea).
|
||||||
|
released = _year_month_day(meta.created_at)
|
||||||
|
if released:
|
||||||
|
self.pills_layout.addWidget(_make_pill(released))
|
||||||
|
|
||||||
|
# 4) Jugadors i autor (manuals, de games.toml).
|
||||||
|
if self.game.players:
|
||||||
|
self.pills_layout.addWidget(_make_pill(self.game.players))
|
||||||
|
if self.game.author:
|
||||||
|
self.pills_layout.addWidget(_make_pill(self.game.author))
|
||||||
|
|
||||||
|
# 5) Topics de Gitea.
|
||||||
|
for topic in meta.topics:
|
||||||
|
self.pills_layout.addWidget(_make_pill(topic))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- acció
|
||||||
|
|
||||||
|
def _set_action(self, meta: GameMeta) -> None:
|
||||||
|
installed = self.is_installed()
|
||||||
|
if self._delete_mode:
|
||||||
|
if installed:
|
||||||
|
self._set_action_text("Esborra", "#d9534f")
|
||||||
|
else:
|
||||||
|
self.action_label.clear() # no es pot esborrar el que no està
|
||||||
|
elif not installed:
|
||||||
|
self._set_action_text("Descarrega", "#4a90d9")
|
||||||
|
elif self._update_available:
|
||||||
|
self._set_action_text("Actualitza", "#e0a030")
|
||||||
|
else:
|
||||||
|
self._set_action_text("Juga", "#6fae6f")
|
||||||
|
|
||||||
|
def _set_action_text(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._clear_pills()
|
||||||
|
self.pills_layout.addWidget(_make_pill(message, "#d0d060"))
|
||||||
|
|
||||||
|
|
||||||
|
def _year_month_day(iso: str) -> str:
|
||||||
|
"""De un ISO-8601 (2026-04-05T20:26:09+02:00) treu '2026-04-05'; '' si no és vàlid."""
|
||||||
|
if not iso or len(iso) < 10:
|
||||||
|
return ""
|
||||||
|
date = iso[:10]
|
||||||
|
return date if date[4] == "-" and date[7] == "-" else ""
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
"""Ventana principal: lista de juegos con scroll + panel de log."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QThreadPool, Qt, QTimer
|
||||||
|
from PySide6.QtGui import QAction, QActionGroup, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QProgressBar,
|
||||||
|
QScrollArea,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import __version__, gitops
|
||||||
|
from ..config import Config, Game
|
||||||
|
from ..paths import app_icon_path
|
||||||
|
from ..settings import load_settings, save_settings
|
||||||
|
from ..workers import CheckUpdatesWorker, DownloadWorker, RunWorker
|
||||||
|
from . import theme
|
||||||
|
from .game_row import GameRow
|
||||||
|
|
||||||
|
APP_NAME = "Jail Launcher"
|
||||||
|
WINDOW_TITLE = f"© 2026 {APP_NAME} — JailDesigner"
|
||||||
|
|
||||||
|
CONSOLE_HEIGHT = 150 # alçada de la consola desplegada (px)
|
||||||
|
CONSOLE_ANIM_MS = 220 # durada de l'animació de desplegar/replegar
|
||||||
|
CONSOLE_IDLE_MS = 3000 # marge sense activitat abans de replegar en mode auto
|
||||||
|
|
||||||
|
CONSOLE_SHOW = "show"
|
||||||
|
CONSOLE_AUTO = "auto"
|
||||||
|
CONSOLE_HIDE = "hide"
|
||||||
|
|
||||||
|
SORT_DEFAULT = "default" # ordre del games.toml
|
||||||
|
SORT_NAME = "name" # alfabètic pel nom
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Aplica el tema guardado (system/light/dark) i vigila els canvis del SO
|
||||||
|
# només quan estem en mode 'system'.
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
theme.apply_theme(app, self.settings.theme)
|
||||||
|
theme.watch_system_theme(app, lambda: self.settings.theme == theme.THEME_SYSTEM)
|
||||||
|
|
||||||
|
self._build_menu()
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
root_layout = QVBoxLayout(central)
|
||||||
|
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# --- Lista de juegos con scroll ---
|
||||||
|
list_container = QWidget()
|
||||||
|
self.list_layout = QVBoxLayout(list_container)
|
||||||
|
self.list_layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
self.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
|
||||||
|
self._populate_list()
|
||||||
|
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setWidget(list_container)
|
||||||
|
root_layout.addWidget(scroll, stretch=1)
|
||||||
|
|
||||||
|
# --- Panel de log (consola colapsable amb alçada animada) ---
|
||||||
|
self.log_view = QPlainTextEdit()
|
||||||
|
self.log_view.setReadOnly(True)
|
||||||
|
self.log_view.setMaximumBlockCount(5000)
|
||||||
|
self.log_view.setStyleSheet("font-family: monospace; font-size: 11px;")
|
||||||
|
self.log_view.setMinimumHeight(0)
|
||||||
|
root_layout.addWidget(self.log_view)
|
||||||
|
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
self._setup_console()
|
||||||
|
|
||||||
|
# Barra de progrés (cantonada de la status bar) per a la comprovació d'updates;
|
||||||
|
# amagada fins que arrenca una comprovació.
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setMaximumWidth(220)
|
||||||
|
self.progress.setFormat("Comprovant %v/%m")
|
||||||
|
self.progress.hide()
|
||||||
|
self.statusBar().addPermanentWidget(self.progress)
|
||||||
|
|
||||||
|
# Estado persistido: marcas de update + filtro de ocultar no descargados.
|
||||||
|
for game_id in self.settings.updates_pending:
|
||||||
|
if game_id in self.rows:
|
||||||
|
self.rows[game_id].set_update_available(True)
|
||||||
|
self._apply_filter()
|
||||||
|
|
||||||
|
# Comprobación automática de updates al iniciar (si está activada). Se difiere
|
||||||
|
# con un timer 0 para que la ventana se muestre antes de lanzar el worker.
|
||||||
|
if self.settings.check_updates_on_start:
|
||||||
|
QTimer.singleShot(0, self._check_updates)
|
||||||
|
|
||||||
|
def _net_config(self) -> gitops.NetConfig:
|
||||||
|
"""Construye la config de red/timeouts a partir de las preferencias guardadas."""
|
||||||
|
s = self.settings
|
||||||
|
return gitops.NetConfig(
|
||||||
|
fetch_timeout=s.git_fetch_timeout,
|
||||||
|
clone_timeout=s.git_clone_timeout,
|
||||||
|
http_timeout=s.http_timeout,
|
||||||
|
stall_limit=s.git_stall_limit,
|
||||||
|
stall_time=s.git_stall_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- consola
|
||||||
|
|
||||||
|
def _setup_console(self) -> None:
|
||||||
|
"""Prepara la animació d'alçada i l'estat inicial segons console_mode."""
|
||||||
|
self._console_open = False
|
||||||
|
self._console_anim_start = 0 # alçada de consola en arrencar l'animació
|
||||||
|
self._console_win_start = 0 # alçada de finestra en arrencar l'animació
|
||||||
|
self._console_grow_window = True
|
||||||
|
self._console_anim = QPropertyAnimation(self.log_view, b"maximumHeight", self)
|
||||||
|
self._console_anim.setDuration(CONSOLE_ANIM_MS)
|
||||||
|
self._console_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||||
|
self._console_anim.valueChanged.connect(self._on_console_anim_value)
|
||||||
|
self._console_anim.finished.connect(self._on_console_anim_done)
|
||||||
|
|
||||||
|
# Timer que replega la consola en mode auto després d'un marge sense activitat.
|
||||||
|
self._console_idle_timer = QTimer(self)
|
||||||
|
self._console_idle_timer.setSingleShot(True)
|
||||||
|
self._console_idle_timer.setInterval(CONSOLE_IDLE_MS)
|
||||||
|
self._console_idle_timer.timeout.connect(self._on_console_idle)
|
||||||
|
|
||||||
|
# Estat inicial sense animació: oberta només en mode "show".
|
||||||
|
self.log_view.setMaximumHeight(0)
|
||||||
|
self.log_view.hide()
|
||||||
|
if self.settings.console_mode == CONSOLE_SHOW:
|
||||||
|
self._set_console_open(True, animated=False)
|
||||||
|
|
||||||
|
def _set_console_open(self, open_: bool, animated: bool = True) -> None:
|
||||||
|
"""Desplega/replega la consola fent créixer o encongir la finestra (perquè la
|
||||||
|
consola guanyi espai en comptes de menjar-ne a la llista)."""
|
||||||
|
if open_ == self._console_open:
|
||||||
|
return
|
||||||
|
self._console_open = open_
|
||||||
|
start = self.log_view.maximumHeight()
|
||||||
|
target = CONSOLE_HEIGHT if open_ else 0
|
||||||
|
# Si la finestra està maximitzada/pantalla completa no la podem fer créixer:
|
||||||
|
# caiem al comportament d'encongir la llista.
|
||||||
|
grow = not (self.isMaximized() or self.isFullScreen())
|
||||||
|
if open_:
|
||||||
|
self.log_view.show() # visible abans d'animar l'obertura
|
||||||
|
self._console_anim.stop()
|
||||||
|
if not animated:
|
||||||
|
if grow:
|
||||||
|
self.resize(self.width(), self.height() + (target - start))
|
||||||
|
self.log_view.setMinimumHeight(target)
|
||||||
|
self.log_view.setMaximumHeight(target)
|
||||||
|
if not open_:
|
||||||
|
self.log_view.hide()
|
||||||
|
return
|
||||||
|
self._console_grow_window = grow
|
||||||
|
self._console_anim_start = start
|
||||||
|
self._console_win_start = self.height()
|
||||||
|
self._console_anim.setStartValue(start)
|
||||||
|
self._console_anim.setEndValue(target)
|
||||||
|
self._console_anim.start()
|
||||||
|
|
||||||
|
def _on_console_anim_value(self, value: int) -> None:
|
||||||
|
"""A cada pas: fixem l'alçada de la consola a `value` (min=max, perquè agafi
|
||||||
|
exactament aquest espai i no el cedeixi a la llista) i fem créixer/encongir la
|
||||||
|
finestra el mateix, així la llista (finestra − consola) es manté constant."""
|
||||||
|
self.log_view.setMinimumHeight(value)
|
||||||
|
if self._console_grow_window:
|
||||||
|
delta = value - self._console_anim_start
|
||||||
|
self.resize(self.width(), self._console_win_start + delta)
|
||||||
|
|
||||||
|
def _on_console_anim_done(self) -> None:
|
||||||
|
if not self._console_open:
|
||||||
|
self.log_view.hide() # replegada del tot: treure-la del layout
|
||||||
|
|
||||||
|
def _on_console_idle(self) -> None:
|
||||||
|
if self.settings.console_mode == CONSOLE_AUTO and not self._workers:
|
||||||
|
self._set_console_open(False)
|
||||||
|
|
||||||
|
def _console_activity_started(self) -> None:
|
||||||
|
"""Hi ha activitat (worker o log): en mode auto, desplega i atura el timer."""
|
||||||
|
if self.settings.console_mode == CONSOLE_AUTO:
|
||||||
|
self._console_idle_timer.stop()
|
||||||
|
self._set_console_open(True)
|
||||||
|
|
||||||
|
def _console_activity_maybe_ended(self) -> None:
|
||||||
|
"""Si no queden workers actius, en mode auto arrenca el compte enrere per replegar."""
|
||||||
|
if self.settings.console_mode == CONSOLE_AUTO and not self._workers:
|
||||||
|
self._console_idle_timer.start()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- menú
|
||||||
|
|
||||||
|
def _build_menu(self) -> None:
|
||||||
|
menu = self.menuBar().addMenu("Opcions")
|
||||||
|
|
||||||
|
self.action_hide = QAction("Amaga els jocs no descarregats", self, checkable=True)
|
||||||
|
self.action_hide.setChecked(self.settings.hide_not_downloaded)
|
||||||
|
self.action_hide.toggled.connect(self._on_toggle_hide)
|
||||||
|
menu.addAction(self.action_hide)
|
||||||
|
|
||||||
|
self.action_check = QAction("Comprova actualitzacions", self)
|
||||||
|
self.action_check.triggered.connect(self._check_updates)
|
||||||
|
menu.addAction(self.action_check)
|
||||||
|
|
||||||
|
self.action_check_on_start = QAction(
|
||||||
|
"Comprova actualitzacions a l'inici", self, checkable=True
|
||||||
|
)
|
||||||
|
self.action_check_on_start.setChecked(self.settings.check_updates_on_start)
|
||||||
|
self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start)
|
||||||
|
menu.addAction(self.action_check_on_start)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
self._build_sort_menu(menu)
|
||||||
|
self._build_theme_menu(menu)
|
||||||
|
self._build_console_menu(menu)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._build_help_menu()
|
||||||
|
|
||||||
|
def _build_help_menu(self) -> None:
|
||||||
|
"""Menú Ajuda amb el «Quant a…». A macOS, AboutRole el mou al menú de l'app."""
|
||||||
|
help_menu = self.menuBar().addMenu("Ajuda")
|
||||||
|
self.action_about = QAction(f"Quant a {APP_NAME}…", self)
|
||||||
|
self.action_about.setMenuRole(QAction.MenuRole.AboutRole)
|
||||||
|
self.action_about.triggered.connect(self._show_about)
|
||||||
|
help_menu.addAction(self.action_about)
|
||||||
|
|
||||||
|
def _show_about(self) -> None:
|
||||||
|
"""Diàleg «Quant a» personalitzat: icona, nom gran i tot centrat."""
|
||||||
|
dlg = QDialog(self)
|
||||||
|
dlg.setWindowTitle(f"Quant a {APP_NAME}")
|
||||||
|
dlg.setModal(True)
|
||||||
|
|
||||||
|
lay = QVBoxLayout(dlg)
|
||||||
|
lay.setContentsMargins(40, 30, 40, 24)
|
||||||
|
lay.setSpacing(0)
|
||||||
|
|
||||||
|
# Logo (si el trobem); s'escala suau des del PNG gran.
|
||||||
|
icon_path = app_icon_path()
|
||||||
|
if icon_path is not None:
|
||||||
|
pix = QPixmap(str(icon_path))
|
||||||
|
if not pix.isNull():
|
||||||
|
logo = QLabel(alignment=Qt.AlignCenter)
|
||||||
|
logo.setPixmap(
|
||||||
|
pix.scaled(
|
||||||
|
96, 96, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lay.addWidget(logo)
|
||||||
|
lay.addSpacing(16)
|
||||||
|
|
||||||
|
# Nom de l'app: gran, en negreta i amb el morat de la marca.
|
||||||
|
name = QLabel(APP_NAME, alignment=Qt.AlignCenter)
|
||||||
|
nf = name.font()
|
||||||
|
nf.setPointSize(nf.pointSize() + 13)
|
||||||
|
nf.setBold(True)
|
||||||
|
name.setFont(nf)
|
||||||
|
name.setStyleSheet("color: #7c4dff;")
|
||||||
|
lay.addWidget(name)
|
||||||
|
lay.addSpacing(2)
|
||||||
|
|
||||||
|
# Versió: petita i atenuada, just davall del nom (estil macOS).
|
||||||
|
ver = QLabel(f"v{__version__}", alignment=Qt.AlignCenter)
|
||||||
|
vf = ver.font()
|
||||||
|
vf.setPointSize(vf.pointSize() - 1)
|
||||||
|
ver.setFont(vf)
|
||||||
|
ver.setStyleSheet("color: #8a8a8a;")
|
||||||
|
lay.addWidget(ver)
|
||||||
|
lay.addSpacing(18)
|
||||||
|
|
||||||
|
# Lema discret.
|
||||||
|
tag = QLabel("Clona, compila i juga · jailgames", alignment=Qt.AlignCenter)
|
||||||
|
tag.setStyleSheet("color: #8a8a8a;")
|
||||||
|
lay.addWidget(tag)
|
||||||
|
lay.addSpacing(14)
|
||||||
|
|
||||||
|
copy = QLabel("© 2026 JailDesigner", alignment=Qt.AlignCenter)
|
||||||
|
cf = copy.font()
|
||||||
|
cf.setPointSize(cf.pointSize() - 1)
|
||||||
|
copy.setFont(cf)
|
||||||
|
lay.addWidget(copy)
|
||||||
|
lay.addSpacing(24)
|
||||||
|
|
||||||
|
# Botó OK centrat.
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok)
|
||||||
|
buttons.button(QDialogButtonBox.Ok).setText("D'acord")
|
||||||
|
buttons.accepted.connect(dlg.accept)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addStretch(1)
|
||||||
|
row.addWidget(buttons)
|
||||||
|
row.addStretch(1)
|
||||||
|
lay.addLayout(row)
|
||||||
|
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
def _build_sort_menu(self, parent_menu) -> None:
|
||||||
|
"""Submenú Ordena amb dues opcions exclusives: Per defecte / Per nom."""
|
||||||
|
submenu = parent_menu.addMenu("Ordena")
|
||||||
|
group = QActionGroup(self)
|
||||||
|
group.setExclusive(True)
|
||||||
|
options = [
|
||||||
|
("Per defecte", SORT_DEFAULT),
|
||||||
|
("Per nom", SORT_NAME),
|
||||||
|
]
|
||||||
|
for label, mode in options:
|
||||||
|
action = QAction(label, self, checkable=True)
|
||||||
|
action.setChecked(self.settings.sort_order == mode)
|
||||||
|
action.triggered.connect(lambda _checked, m=mode: self._on_sort_selected(m))
|
||||||
|
group.addAction(action)
|
||||||
|
submenu.addAction(action)
|
||||||
|
|
||||||
|
def _on_sort_selected(self, mode: str) -> None:
|
||||||
|
self.settings.sort_order = mode
|
||||||
|
save_settings(self.settings)
|
||||||
|
self._populate_list()
|
||||||
|
|
||||||
|
def _build_theme_menu(self, parent_menu) -> None:
|
||||||
|
"""Submenú Tema amb tres opcions exclusives: Sistema / Clar / Fosc."""
|
||||||
|
submenu = parent_menu.addMenu("Tema")
|
||||||
|
group = QActionGroup(self)
|
||||||
|
group.setExclusive(True)
|
||||||
|
options = [
|
||||||
|
("Sistema", theme.THEME_SYSTEM),
|
||||||
|
("Clar", theme.THEME_LIGHT),
|
||||||
|
("Fosc", theme.THEME_DARK),
|
||||||
|
]
|
||||||
|
for label, mode in options:
|
||||||
|
action = QAction(label, self, checkable=True)
|
||||||
|
action.setChecked(self.settings.theme == mode)
|
||||||
|
action.triggered.connect(lambda _checked, m=mode: self._on_theme_selected(m))
|
||||||
|
group.addAction(action)
|
||||||
|
submenu.addAction(action)
|
||||||
|
|
||||||
|
def _build_console_menu(self, parent_menu) -> None:
|
||||||
|
"""Submenú Consola amb tres estats exclusius: Mostra / Auto-amaga / Amaga."""
|
||||||
|
submenu = parent_menu.addMenu("Consola")
|
||||||
|
group = QActionGroup(self)
|
||||||
|
group.setExclusive(True)
|
||||||
|
options = [
|
||||||
|
("Mostra", CONSOLE_SHOW),
|
||||||
|
("Auto-amaga", CONSOLE_AUTO),
|
||||||
|
("Amaga", CONSOLE_HIDE),
|
||||||
|
]
|
||||||
|
for label, mode in options:
|
||||||
|
action = QAction(label, self, checkable=True)
|
||||||
|
action.setChecked(self.settings.console_mode == mode)
|
||||||
|
action.triggered.connect(
|
||||||
|
lambda _checked, m=mode: self._on_console_mode_selected(m)
|
||||||
|
)
|
||||||
|
group.addAction(action)
|
||||||
|
submenu.addAction(action)
|
||||||
|
|
||||||
|
def _on_console_mode_selected(self, mode: str) -> None:
|
||||||
|
self.settings.console_mode = mode
|
||||||
|
save_settings(self.settings)
|
||||||
|
self._console_idle_timer.stop()
|
||||||
|
if mode == CONSOLE_SHOW:
|
||||||
|
self._set_console_open(True)
|
||||||
|
elif mode == CONSOLE_HIDE:
|
||||||
|
self._set_console_open(False)
|
||||||
|
else: # auto: oberta si hi ha activitat, si no replegada
|
||||||
|
self._set_console_open(bool(self._workers))
|
||||||
|
|
||||||
|
def _on_theme_selected(self, mode: str) -> None:
|
||||||
|
self.settings.theme = mode
|
||||||
|
save_settings(self.settings)
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
theme.apply_theme(app, mode)
|
||||||
|
# Reconstruir las filas: los pills usan `palette(...)` en su stylesheet, que
|
||||||
|
# Qt cachea; recrearlos los re-resuelve contra la paleta ya aplicada.
|
||||||
|
for row in self.rows.values():
|
||||||
|
row.refresh()
|
||||||
|
|
||||||
|
def _configure_token(self) -> None:
|
||||||
|
token, ok = QInputDialog.getText(
|
||||||
|
self,
|
||||||
|
"Token de Gitea",
|
||||||
|
"Token personal d'accés (per a repos privats).\n"
|
||||||
|
"Es guarda local a settings.json (no es versiona).",
|
||||||
|
QLineEdit.Password,
|
||||||
|
self.settings.gitea_token,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
self.settings.gitea_token = token.strip()
|
||||||
|
save_settings(self.settings)
|
||||||
|
estat = "configurat" if self.settings.gitea_token else "esborrat"
|
||||||
|
self._log(f"Token de Gitea {estat}.")
|
||||||
|
|
||||||
|
def _on_toggle_hide(self, checked: bool) -> None:
|
||||||
|
self.settings.hide_not_downloaded = checked
|
||||||
|
save_settings(self.settings)
|
||||||
|
self._apply_filter()
|
||||||
|
|
||||||
|
def _on_toggle_check_on_start(self, checked: bool) -> None:
|
||||||
|
self.settings.check_updates_on_start = checked
|
||||||
|
save_settings(self.settings)
|
||||||
|
|
||||||
|
def _ordered_games(self) -> list[Game]:
|
||||||
|
"""Jocs en l'ordre triat: alfabètic pel nom, o l'ordre original del games.toml."""
|
||||||
|
if self.settings.sort_order == SORT_NAME:
|
||||||
|
return sorted(self.config.games, key=lambda g: g.name.casefold())
|
||||||
|
return list(self.config.games)
|
||||||
|
|
||||||
|
def _populate_list(self) -> None:
|
||||||
|
"""(Re)col·loca les files al layout segons l'ordre triat, sense destruir-les."""
|
||||||
|
while self.list_layout.count():
|
||||||
|
item = self.list_layout.takeAt(0)
|
||||||
|
w = item.widget() if item else None
|
||||||
|
if w is not None:
|
||||||
|
w.setParent(None) # treu del layout però conserva la fila (viu a self.rows)
|
||||||
|
for game in self._ordered_games():
|
||||||
|
self.list_layout.addWidget(self.rows[game.id])
|
||||||
|
self.list_layout.addStretch(1)
|
||||||
|
|
||||||
|
def _apply_filter(self) -> None:
|
||||||
|
hide = self.action_hide.isChecked()
|
||||||
|
for row in self.rows.values():
|
||||||
|
row.setVisible(not (hide and not row.is_installed()))
|
||||||
|
|
||||||
|
# ------------------------------------------------------ comprobar updates
|
||||||
|
|
||||||
|
def _check_updates(self) -> None:
|
||||||
|
self.action_check.setEnabled(False)
|
||||||
|
self._log("=== Comprovant actualitzacions ===")
|
||||||
|
worker = CheckUpdatesWorker(
|
||||||
|
self.root, self.config.games, self.settings.gitea_token, self._net_config()
|
||||||
|
)
|
||||||
|
worker.signals.log.connect(self._log)
|
||||||
|
worker.signals.result.connect(self._mark_update)
|
||||||
|
worker.signals.progress.connect(self._check_progress)
|
||||||
|
worker.signals.finished.connect(self._check_done)
|
||||||
|
worker.signals.error.connect(self._check_error)
|
||||||
|
self._track(worker)
|
||||||
|
self.pool.start(worker)
|
||||||
|
|
||||||
|
def _check_progress(self, done: int, total: int) -> None:
|
||||||
|
if total <= 0:
|
||||||
|
return # res descarregat a comprovar: no mostrem barra
|
||||||
|
self.progress.setMaximum(total)
|
||||||
|
self.progress.setValue(done)
|
||||||
|
self.progress.show()
|
||||||
|
|
||||||
|
def _mark_update(self, game_id: str, has_update: bool) -> None:
|
||||||
|
row = self.rows.get(game_id)
|
||||||
|
if row is not None:
|
||||||
|
row.set_update_available(has_update)
|
||||||
|
pending = set(self.settings.updates_pending)
|
||||||
|
pending.add(game_id) if has_update else pending.discard(game_id)
|
||||||
|
self.settings.updates_pending = sorted(pending)
|
||||||
|
save_settings(self.settings)
|
||||||
|
|
||||||
|
def _check_done(self, _payload) -> None:
|
||||||
|
self.action_check.setEnabled(True)
|
||||||
|
self.progress.hide()
|
||||||
|
self._log("=== Comprovació d'actualitzacions acabada ===")
|
||||||
|
|
||||||
|
def _check_error(self, msg: str) -> None:
|
||||||
|
self.action_check.setEnabled(True)
|
||||||
|
self.progress.hide()
|
||||||
|
self._log(f"!!! Error comprovant actualitzacions: {msg}")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
def _log(self, text: str) -> None:
|
||||||
|
# Autoscroll intel·ligent: només seguim el final si la barra ja hi estava.
|
||||||
|
# Si l'usuari ha pujat a llegir una línia anterior, no l'arrosseguem avall.
|
||||||
|
bar = self.log_view.verticalScrollBar()
|
||||||
|
at_bottom = bar.value() >= bar.maximum() - 4
|
||||||
|
self.log_view.appendPlainText(text)
|
||||||
|
if at_bottom:
|
||||||
|
bar.setValue(bar.maximum())
|
||||||
|
# En mode auto, qualsevol línia desplega la consola; si no hi ha cap worker
|
||||||
|
# actiu (p.ex. un missatge solt), arrenca el compte enrere per replegar-la.
|
||||||
|
if self.settings.console_mode == CONSOLE_AUTO:
|
||||||
|
self._console_activity_started()
|
||||||
|
if not self._workers:
|
||||||
|
self._console_idle_timer.start()
|
||||||
|
|
||||||
|
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)
|
||||||
|
self._console_activity_started()
|
||||||
|
|
||||||
|
def _done(*_):
|
||||||
|
self._workers.discard(worker)
|
||||||
|
self._console_activity_maybe_ended()
|
||||||
|
|
||||||
|
worker.signals.finished.connect(_done)
|
||||||
|
worker.signals.error.connect(_done)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- accions
|
||||||
|
|
||||||
|
def _set_delete_mode(self, on: bool) -> None:
|
||||||
|
self._delete_mode = on
|
||||||
|
for row in self.rows.values():
|
||||||
|
row.set_delete_mode(on)
|
||||||
|
|
||||||
|
def _on_activate(self, game: Game) -> None:
|
||||||
|
"""Clic sobre la fila. En mode esborrar elimina; si no, descarrega/actualitza o juga."""
|
||||||
|
row = self.rows[game.id]
|
||||||
|
if self._delete_mode:
|
||||||
|
if not row.is_installed():
|
||||||
|
return # res a esborrar; segueix en mode esborrar
|
||||||
|
if self._on_delete(game):
|
||||||
|
self.action_delete.setChecked(False) # surt del mode esborrar
|
||||||
|
return
|
||||||
|
if row.primary_action_is_download():
|
||||||
|
self._on_download(game)
|
||||||
|
else:
|
||||||
|
self._on_run(game)
|
||||||
|
|
||||||
|
def _on_download(self, game: Game) -> None:
|
||||||
|
row = self.rows[game.id]
|
||||||
|
row.set_busy(True, "Descarregant…")
|
||||||
|
self._log(f"=== Descàrrega: {game.name} ===")
|
||||||
|
|
||||||
|
worker = DownloadWorker(
|
||||||
|
self.root, game, self.settings.gitea_token, self._net_config()
|
||||||
|
)
|
||||||
|
worker.signals.log.connect(self._log)
|
||||||
|
worker.signals.finished.connect(lambda _meta, g=game: self._download_done(g))
|
||||||
|
worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg))
|
||||||
|
self._track(worker)
|
||||||
|
self.pool.start(worker)
|
||||||
|
|
||||||
|
def _download_done(self, game: Game) -> None:
|
||||||
|
self._log(f"=== {game.name}: descàrrega completada ===")
|
||||||
|
row = self.rows[game.id]
|
||||||
|
row.set_busy(False)
|
||||||
|
row.set_update_available(False) # recién traído del remoto → al día
|
||||||
|
if game.id in self.settings.updates_pending:
|
||||||
|
self.settings.updates_pending = [
|
||||||
|
g for g in self.settings.updates_pending if g != game.id
|
||||||
|
]
|
||||||
|
save_settings(self.settings)
|
||||||
|
self._apply_filter() # un juego antes no instalado puede aparecer ahora
|
||||||
|
|
||||||
|
def _on_run(self, game: Game) -> None:
|
||||||
|
row = self.rows[game.id]
|
||||||
|
row.set_busy(True, "Executant…")
|
||||||
|
self._log(f"=== Juga: {game.name} ===")
|
||||||
|
|
||||||
|
worker = RunWorker(self.root, game)
|
||||||
|
worker.signals.log.connect(self._log)
|
||||||
|
worker.signals.finished.connect(lambda code, g=game: self._run_done(g, code))
|
||||||
|
worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg))
|
||||||
|
self._track(worker)
|
||||||
|
self.pool.start(worker)
|
||||||
|
|
||||||
|
def _run_done(self, game: Game, code: int) -> None:
|
||||||
|
self._log(f"=== {game.name}: ha finalitzat amb codi {code} ===")
|
||||||
|
row = self.rows[game.id]
|
||||||
|
row.set_busy(False)
|
||||||
|
row.refresh()
|
||||||
|
|
||||||
|
def _op_error(self, game: Game, msg: str) -> None:
|
||||||
|
self._log(f"!!! {game.name}: ERROR: {msg}")
|
||||||
|
row = self.rows[game.id]
|
||||||
|
row.set_busy(False)
|
||||||
|
row.refresh()
|
||||||
|
|
||||||
|
def _on_delete(self, game: Game) -> bool:
|
||||||
|
"""Esborra la descàrrega local (amb confirmació). Retorna True si s'ha esborrat."""
|
||||||
|
resp = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Esborrar descàrrega",
|
||||||
|
f"Segur que vols esborrar la descàrrega local de «{game.name}»?\n\n"
|
||||||
|
"S'eliminarà el clon i les dades en local (no es treu de la llista).",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if resp != QMessageBox.Yes:
|
||||||
|
return False
|
||||||
|
self._log(f"=== Esborra: {game.name} ===")
|
||||||
|
gitops.delete_local(self.root, game, log=self._log)
|
||||||
|
if game.id in self.settings.updates_pending:
|
||||||
|
self.settings.updates_pending = [
|
||||||
|
g for g in self.settings.updates_pending if g != game.id
|
||||||
|
]
|
||||||
|
save_settings(self.settings)
|
||||||
|
row = self.rows[game.id]
|
||||||
|
row.set_update_available(False)
|
||||||
|
row.refresh()
|
||||||
|
self._apply_filter() # si està actiu "amaga no descarregats", ara s'amaga
|
||||||
|
return True
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""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 collections.abc import Callable
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QColor, QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
# Modos de tema seleccionables por el usuario.
|
||||||
|
THEME_SYSTEM = "system"
|
||||||
|
THEME_LIGHT = "light"
|
||||||
|
THEME_DARK = "dark"
|
||||||
|
THEME_MODES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
|
||||||
|
|
||||||
|
|
||||||
|
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 resolve_is_dark(app: QApplication, mode: str) -> bool:
|
||||||
|
"""Decide si pintar oscuro según el modo elegido (system/light/dark)."""
|
||||||
|
if mode == THEME_DARK:
|
||||||
|
return True
|
||||||
|
if mode == THEME_LIGHT:
|
||||||
|
return False
|
||||||
|
return system_is_dark(app) # THEME_SYSTEM (o valor desconocido)
|
||||||
|
|
||||||
|
|
||||||
|
def _repolish_all(app: QApplication) -> None:
|
||||||
|
"""Re-poliza todos los widgets para que adopten la nueva paleta en caliente.
|
||||||
|
|
||||||
|
``setPalette`` por sí solo no repinta los widgets ya creados: los que tienen
|
||||||
|
stylesheet (pills) no re-resuelven ``palette(...)`` y algunos fondos base (la
|
||||||
|
consola de log) no se redibujan. unpolish→polish→update fuerza ese refresco.
|
||||||
|
"""
|
||||||
|
for widget in app.allWidgets():
|
||||||
|
style = widget.style()
|
||||||
|
style.unpolish(widget)
|
||||||
|
style.polish(widget)
|
||||||
|
widget.update()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_theme(app: QApplication, mode: str = THEME_SYSTEM) -> None:
|
||||||
|
"""Aplica estilo Fusion + paleta clara/oscura según el modo elegido."""
|
||||||
|
app.setStyle("Fusion")
|
||||||
|
if resolve_is_dark(app, mode):
|
||||||
|
app.setPalette(_dark_palette())
|
||||||
|
else:
|
||||||
|
app.setPalette(app.style().standardPalette())
|
||||||
|
_repolish_all(app)
|
||||||
|
|
||||||
|
|
||||||
|
def watch_system_theme(app: QApplication, should_follow: Callable[[], bool]) -> None:
|
||||||
|
"""Re-aplica el tema al cambiar el esquema del sistema, solo si seguimos al sistema.
|
||||||
|
|
||||||
|
``should_follow`` se consulta en cada cambio: devuelve True cuando el modo activo
|
||||||
|
es 'system' (si el usuario ha forzado claro/oscuro, ignoramos el cambio del SO).
|
||||||
|
"""
|
||||||
|
hints = app.styleHints()
|
||||||
|
if hasattr(hints, "colorSchemeChanged"):
|
||||||
|
hints.colorSchemeChanged.connect(
|
||||||
|
lambda _scheme: apply_theme(app, THEME_SYSTEM) if should_follow() else None
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""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
|
||||||
|
progress = Signal(int, int) # (done, total) — CheckUpdatesWorker
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadWorker(QRunnable):
|
||||||
|
"""Clona o actualiza (forzado) y refresca la metadata de un juego."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root: Path,
|
||||||
|
game: Game,
|
||||||
|
token: str = "",
|
||||||
|
net: gitops.NetConfig = gitops.DEFAULT_NET,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.root = root
|
||||||
|
self.game = game
|
||||||
|
self.token = token
|
||||||
|
self.net = net
|
||||||
|
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,
|
||||||
|
net=self.net,
|
||||||
|
)
|
||||||
|
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 = "",
|
||||||
|
net: gitops.NetConfig = gitops.DEFAULT_NET,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.root = root
|
||||||
|
self.games = games
|
||||||
|
self.token = token
|
||||||
|
self.net = net
|
||||||
|
self.signals = _Signals()
|
||||||
|
|
||||||
|
def run(self) -> None: # noqa: D401 - API de QRunnable
|
||||||
|
try:
|
||||||
|
# Total = juegos descargados (los no instalados no se comprueban). Emitimos
|
||||||
|
# progress por cada juego *intentado*, éxito o error, para que la barra
|
||||||
|
# llegue siempre al final aunque algún repo dé timeout.
|
||||||
|
installed = [g for g in self.games if gitops.is_installed(self.root, g)]
|
||||||
|
total = len(installed)
|
||||||
|
self.signals.progress.emit(0, total)
|
||||||
|
for done, game in enumerate(installed, start=1):
|
||||||
|
try:
|
||||||
|
has_update = gitops.check_update(
|
||||||
|
self.root,
|
||||||
|
game,
|
||||||
|
log=self.signals.log.emit,
|
||||||
|
token=self.token,
|
||||||
|
net=self.net,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - no abortar el resto
|
||||||
|
self.signals.log.emit(f"check {game.id}: {exc}")
|
||||||
|
else:
|
||||||
|
self.signals.result.emit(game.id, has_update)
|
||||||
|
self.signals.progress.emit(done, total)
|
||||||
|
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 = "1.0.4"
|
||||||
|
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