32 Commits

Author SHA1 Message Date
Sergio Valor Martinez 42e7c620f7 Puja a la versió 1.0.5 2026-06-01 13:10:24 +02:00
Sergio Valor Martinez adcc230b38 Afig menú per ordenar els jocs (per defecte/per nom) amb persistència 2026-06-01 13:09:54 +02:00
Sergio Valor Martinez e60c3cc6eb Evita finestres de consola a Windows als subprocessos (CREATE_NO_WINDOW) 2026-06-01 12:58:59 +02:00
Sergio Valor Martinez 1e530a413d Arregla l'esborrat a Windows: lleva el bit de només-lectura del .git 2026-06-01 12:55:50 +02:00
JailDesigner 9147eb56fb Afig build.ps1 per compilar a Windows amb Nuitka 2026-06-01 12:14:45 +02:00
JailDesigner ecdf389c7c feat: «Jail Launcher» al menú/dock, autoscroll consola i puja a 1.0.4 2026-05-30 23:53:22 +02:00
JailDesigner c055b98d15 fix: afig CPATH/LIBRARY_PATH de Homebrew i puja a 1.0.3 2026-05-30 23:30:04 +02:00
JailDesigner fb65f4f249 Afig directoris de Homebrew al PATH i puja a 1.0.2 2026-05-30 22:01:46 +02:00
JailDesigner be8c886ad1 Versiona la carpeta icon/ (el patró «Icon» del gitignore l'amagava) 2026-05-30 17:18:03 +02:00
JailDesigner fd8eedab76 Usa l'icona real de icon/ al bundle macOS i al «Quant a» 2026-05-30 17:18:03 +02:00
JailDesigner a71a1be88d Puja la versió a 1.0.1 2026-05-30 17:18:03 +02:00
JailDesigner 34811038eb Diàleg «Quant a» centrat amb icona, nom gran i versió en cursiva 2026-05-30 17:18:03 +02:00
JailDesigner 95a76d0d76 Empaqueta jlauncher com a .app + .dmg per a macOS 2026-05-30 17:18:03 +02:00
JailDesigner c879127401 Consola en mode auto-amaga per defecte
En una instal·lació nova (sense settings.json) la consola sortia visible
perquè el defecte era "show". Es passa a "auto" (i també el fallback de
valors desconeguts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:58:26 +02:00
JailDesigner be3cb44ae2 Reordena els jocs al toml: CC, CCAE, JDD, AEE, Orni, Projecte 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:51:56 +02:00
JailDesigner 0085c63ace Diàleg «Quant a» al menú Ajuda i bump a 1.0.0
- Nou menú Ajuda amb «Quant a Jail Launcher…» que mostra nom, versió i
  copyright. AboutRole perquè a macOS Qt el mogui al menú de l'aplicació.
- Versió bumpejada a 1.0.0 (jlauncher.__version__ + pyproject), llegida pel
  diàleg per no duplicar-la.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:49:29 +02:00
JailDesigner cebc76b6e3 Consola més baixa (150px) i sense log en entrar al mode esborrar
- CONSOLE_HEIGHT 220 -> 150: la consola desplegada ocupa menys.
- Es treu la línia de log en marcar «Esborra un joc»: cada fila ja mostra
  «Esborra», així que era redundant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:46:39 +02:00
JailDesigner 38c4f50965 La consola fa créixer la finestra en comptes de menjar espai a la llista
En desplegar/replegar la consola, la finestra creix/encongeix la mateixa alçada
en lockstep amb el panell (i es fixa l'alçada de la consola min=max perquè agafi
exactament aquest espai), de manera que la llista de jocs es manté constant i no
es mou. Si la finestra està maximitzada cau al comportament d'encongir la llista.
També s'allarga el marge de gràcia abans de replegar en mode auto (1.8s -> 4s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:42:28 +02:00
JailDesigner 962e5b054f Consola amb 3 estats (mostra/auto-amaga/amaga), animada i més alta
- Submenú Opcions > Consola: Mostra / Auto-amaga / Amaga, persistit a
  settings.json (console_mode). Es reemplaça el QSplitter per un panell
  col·lapsable amb alçada animada (QPropertyAnimation, easing InOutCubic) i
  més alçada (220px).
- Mode auto: la consola es desplega amb activitat (worker o nova línia de log)
  i es replega sola tras un marge sense activitat.
- Pills robustos al canvi de tema: color de text concret des de la paleta en
  comptes de palette(...) (que Qt cacheja), i pills_box sense fons propi perquè
  no pinti cap banda darrere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:35:37 +02:00
JailDesigner 93efbb06c4 Refresca bé el canvi de tema en calent
setPalette no repolia els widgets ja creats: els stylesheets amb palette(...)
(pills) no es reresolien i la consola de log no repintava el fons. apply_theme
ara fa unpolish→polish→update a tots els widgets, i en canviar de tema es
reconstrueixen les files perquè els pills agafin la paleta nova.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:20:47 +02:00
JailDesigner e0a93a9c28 Estil targeta tipus web a les files + tema seleccionable (system/clar/fosc)
UI:
- Files amb estil de targeta: icona arrodonida, títol gran, subtítol atenuat i
  'pills' amb estat, versió, data de llançament, jugadors, autor i topics. Els
  pills envolten amb un FlowLayout nou quan no caben.
- Submenú Opcions > Tema amb Sistema/Clar/Fosc; persisteix a settings.json
  (theme) i s'aplica a l'instant. El watcher del SO només actua en mode Sistema.

Dades:
- GameMeta guarda topics i created_at, llegits de la resposta de Gitea que ja
  demanàvem (gratis, auto-sincronitzats).
- games.toml: camps opcionals players i author per joc (la resta surt de Gitea).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:13:32 +02:00
JailDesigner e9f0098df8 Barra de progrés per a la comprovació d'actualitzacions
- CheckUpdatesWorker emet progress(done, total) per cada joc intentat (èxit o
  error), amb total = jocs descarregats; així la barra arriba al final encara
  que algun repo doni timeout.
- QProgressBar a la status bar (amagada per defecte) que es mostra durant la
  comprovació i s'amaga en acabar o en error. Reutilitzada pel check manual i
  l'automàtic a l'inici.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:03:58 +02:00
JailDesigner c51b7b74ed Comprovació d'updates a l'inici opcional + timeouts de xarxa configurables
- Opció marcable «Comprova actualitzacions a l'inici» al menú; persisteix a
  settings.json i llança la comprovació diferida en obrir la finestra.
- Tolerància a repos offline/inalcanzables: low-speed abort + techo dur de
  temps a les operacions git de xarxa (fetch/clone), evitant cuelgues.
- Timeouts exposats a settings.json (git_fetch_timeout, git_clone_timeout,
  http_timeout, git_stall_limit, git_stall_time) via NetConfig, propagats
  UI -> workers -> gitops.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:52:19 +02:00
JailDesigner 90b7bb5fb1 Ajusta noms de jocs al toml 2026-05-29 22:55:43 +02:00
JailDesigner 021e865179 Compilat onefile amb Nuitka (app.py + zstandard) i rutes en mode onefile 2026-05-29 22:55:43 +02:00
JailDesigner 667eade660 Suport de repos privats: token de Gitea global configurable des del menú 2026-05-29 22:30:11 +02:00
JailDesigner bfa01f31e3 Mode esborrar des del menú: tria un joc descarregat i s'elimina 2026-05-29 22:13:58 +02:00
JailDesigner 0334e79480 La fila és el botó: clic descarrega/actualitza o juga, amb text d'acció 2026-05-29 22:03:41 +02:00
JailDesigner 694d67f11e Interfície en català, botó Esborra i botons d'icona segons l'estat 2026-05-29 21:55:34 +02:00
JailDesigner 235a3966d2 Menú d'opcions: ocultar no descarregats i comprovar actualitzacions, amb persistència 2026-05-29 21:29:12 +02:00
JailDesigner 9d13c2434b Tema clar/fosc segons el sistema i títol de finestra 2026-05-29 21:07:00 +02:00
JailDesigner b71df66e22 Llançador inicial amb GUI PySide6: descàrrega i execució de jocs 2026-05-29 20:50:50 +02:00
28 changed files with 2803 additions and 2 deletions
+11 -1
View File
@@ -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
@@ -5,7 +13,9 @@
.LSOverride .LSOverride
# 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
._* ._*
+111 -1
View File
@@ -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`.
+11
View File
@@ -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())
+138
View File
@@ -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).'
Executable
+137
View File
@@ -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
View File
@@ -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"
+150
View File
@@ -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()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
"""jlauncher — lanzador de juegos jailgames."""
__version__ = "1.0.5"
+44
View File
@@ -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())
+83
View File
@@ -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)
+352
View File
@@ -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
+62
View File
@@ -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()
+128
View File
@@ -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"
+127
View File
@@ -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)
+89
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Componentes de interfaz de jlauncher."""
+71
View File
@@ -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()
+288
View File
@@ -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 ""
+628
View File
@@ -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
+157
View File
@@ -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
)
+121
View File
@@ -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)
+18
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
PySide6>=6.6