Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cd9dfa2d0 | |||
| aaf8390bb8 | |||
| fccbdc83d5 | |||
| 878c403654 | |||
| 42e7c620f7 | |||
| adcc230b38 | |||
| e60c3cc6eb | |||
| 1e530a413d | |||
| 9147eb56fb | |||
| ecdf389c7c | |||
| c055b98d15 | |||
| fb65f4f249 | |||
| be8c886ad1 | |||
| fd8eedab76 | |||
| a71a1be88d | |||
| 34811038eb | |||
| 95a76d0d76 | |||
| c879127401 |
+4
-2
@@ -1,6 +1,6 @@
|
||||
# ---> jlauncher
|
||||
# ---> jail-launcher
|
||||
# Datos descargados en tiempo de ejecución (clones git + cache de metadata)
|
||||
jlauncher_data/
|
||||
jail_launcher_data/
|
||||
# Preferencias locales del usuario
|
||||
settings.json
|
||||
*.dist/
|
||||
@@ -14,6 +14,8 @@ settings.json
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
# En FS case-insensitive (macOS) el patrón «Icon» se traga la carpeta icon/; re-inclúyela.
|
||||
!/icon/
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# jlauncher
|
||||
# jail-launcher
|
||||
|
||||
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.
|
||||
@@ -15,14 +15,14 @@ GUI en **Python + PySide6**, pensada para compilarse a binario nativo con **Nuit
|
||||
|
||||
```bash
|
||||
pip install PySide6
|
||||
python -m jlauncher
|
||||
python -m jail_launcher
|
||||
```
|
||||
|
||||
La app crea una carpeta `jlauncher_data/` junto al proyecto (o junto al binario, si está
|
||||
La app crea una carpeta `jail_launcher_data/` junto al proyecto (o junto al binario, si está
|
||||
compilado) con esta estructura **anidada (Versión 1)**:
|
||||
|
||||
```
|
||||
jlauncher_data/
|
||||
jail_launcher_data/
|
||||
<id_juego>/
|
||||
repo/ # git clone del juego
|
||||
metadata/
|
||||
@@ -40,7 +40,7 @@ jlauncher_data/
|
||||
- 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/`),
|
||||
- **Esborra**: elimina la descarga local (carpeta del juego en `jail_launcher_data/`),
|
||||
sin quitar el juego del `games.toml`.
|
||||
|
||||
Menú **Opcions**: *Amaga els jocs no descarregats* (filtro persistente) y
|
||||
@@ -55,7 +55,7 @@ Una entrada `[[game]]` por juego. Campos:
|
||||
|
||||
| Campo | Obligatorio | Descripción |
|
||||
|---------------|-------------|--------------------------------------------------------------------|
|
||||
| `id` | sí | slug → nombre de carpeta en `jlauncher_data/` |
|
||||
| `id` | sí | slug → nombre de carpeta en `jail_launcher_data/` |
|
||||
| `name` | sí | nombre visible |
|
||||
| `clone_url` | sí | URL de git clone / pull |
|
||||
| `run_cmd` | sí | comando que ejecuta el juego (cwd = repo) |
|
||||
@@ -68,16 +68,16 @@ Una entrada `[[game]]` por juego. Campos:
|
||||
|
||||
`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`.
|
||||
`dist/jail-launcher-v<versión>-<os>-<arch>.tar.gz` junto a `games.toml`.
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
# binario: dist/jlauncher (+ dist/games.toml)
|
||||
# binario: dist/jail-launcher (+ dist/games.toml)
|
||||
```
|
||||
|
||||
El binario crea `jlauncher_data/` y `settings.json` **junto a sí mismo** (resuelto vía
|
||||
El binario crea `jail_launcher_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`).
|
||||
fuente se ejecuta con `python -m jail_launcher`).
|
||||
|
||||
### Prerequisitos del sistema (no los instala el script)
|
||||
|
||||
@@ -88,9 +88,26 @@ fuente se ejecuta con `python -m jlauncher`).
|
||||
patchelf (Nuitka usa `install_name_tool`).
|
||||
- `git` en el PATH.
|
||||
|
||||
### macOS
|
||||
### macOS (.app + .dmg)
|
||||
|
||||
Compila en el propio Mac (Nuitka no compila cruzado): `./build.sh` genera
|
||||
`jlauncher-v…-darwin-arm64.tar.gz`. Como el binario no va firmado, la primera vez quizá
|
||||
debas hacer `xattr -dr com.apple.quarantine jlauncher` o abrirlo con clic derecho → Abrir.
|
||||
Lánzalo desde terminal (`./jlauncher`).
|
||||
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/Jail Launcher.app
|
||||
# -> dist/jail-launcher-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/jail-launcher/` (`jail_launcher_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/Jail Launcher.app"`.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Punto de entrada para empaquetar con Nuitka.
|
||||
|
||||
Importa el paquete ``jlauncher`` con imports absolutos para que los imports relativos
|
||||
Importa el paquete ``jail_launcher`` 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``.
|
||||
desde fuente sigue valiendo ``python -m jail_launcher``.
|
||||
"""
|
||||
|
||||
from jlauncher.__main__ import main
|
||||
from jail_launcher.__main__ import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Compila jail-launcher con Nuitka y empaqueta un release para Windows.
|
||||
# - jail-launcher.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 jail_launcher/__init__.py) ---------------------------------
|
||||
$initPath = Join-Path $Here 'jail_launcher\__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 jail_launcher/__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 jail-launcher.exe (PySide6 onefile; puede tardar varios minutos)…'
|
||||
& $VenvPython -m nuitka `
|
||||
--onefile `
|
||||
--assume-yes-for-downloads `
|
||||
--enable-plugin=pyside6 `
|
||||
--include-package=jail_launcher `
|
||||
--windows-icon-from-ico=$IconIco `
|
||||
--windows-console-mode=disable `
|
||||
--company-name=jailgames `
|
||||
--product-name=jail-launcher `
|
||||
--product-version=$Version `
|
||||
--file-version=$Version `
|
||||
--output-dir=dist `
|
||||
--output-filename=jail-launcher `
|
||||
--remove-output `
|
||||
app.py
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error '[build] Nuitka falló.'
|
||||
}
|
||||
|
||||
$Exe = 'dist\jail-launcher.exe'
|
||||
if (-not (Test-Path $Exe)) {
|
||||
Write-Error '[build] Nuitka no produjo dist\jail-launcher.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 = "jail-launcher-v$Version-windows-$Arch"
|
||||
$Zip = "dist\$ReleaseName.zip"
|
||||
Write-Host "[build] empaquetando release $ReleaseName.zip…"
|
||||
Compress-Archive -Path dist\jail-launcher.exe, dist\games.toml, dist\icon `
|
||||
-DestinationPath $Zip -Force
|
||||
|
||||
Write-Host '[build] hecho:'
|
||||
Get-Item dist\jail-launcher.exe, dist\games.toml, $Zip |
|
||||
Format-Table -AutoSize Name, @{Name = 'Size'; Expression = { '{0:N0} B' -f $_.Length } }
|
||||
Write-Host '[build] el binario crea jail_launcher_data\ y settings.json junto a sí mismo.'
|
||||
Write-Host '[build] distribuir: descomprimir el .zip (jail-launcher.exe + games.toml + icon\ juntos).'
|
||||
@@ -1,19 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compila jlauncher a un binario standalone con Nuitka y empaqueta un tar.gz de release.
|
||||
# Requisitos del sistema: python3-dev, gcc, patchelf (ver README).
|
||||
# Compila jail-launcher con Nuitka y empaqueta un release.
|
||||
# - Linux: binario onefile + tar.gz.
|
||||
# - macOS: «Jail Launcher.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)"
|
||||
VERSION="$(sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' jail_launcher/__init__.py | head -n1)"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "[build] no se pudo leer __version__ de jlauncher/__init__.py" >&2
|
||||
echo "[build] no se pudo leer __version__ de jail_launcher/__init__.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
ARCH="$(uname -m)"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
RELEASE_NAME="jlauncher-v${VERSION}-${OS}-${ARCH}"
|
||||
|
||||
if [ ! -d .venv ]; then
|
||||
echo "[build] creando venv…"
|
||||
@@ -39,25 +40,99 @@ echo "[build] versión: v${VERSION}"
|
||||
echo "[build] limpiando artefactos previos…"
|
||||
rm -rf dist build app.build app.dist app.onefile-build
|
||||
|
||||
echo "[build] compilando (PySide6 onefile; puede tardar varios minutos)…"
|
||||
.venv/bin/python -m nuitka \
|
||||
--onefile \
|
||||
--assume-yes-for-downloads \
|
||||
--enable-plugin=pyside6 \
|
||||
--include-package=jlauncher \
|
||||
--output-dir=dist \
|
||||
--output-filename=jlauncher \
|
||||
--remove-output \
|
||||
--lto=yes \
|
||||
app.py
|
||||
ICON_ICNS="icon/icon.icns"
|
||||
ICON_PNG="icon/icon.png"
|
||||
|
||||
echo "[build] copiando games.toml junto al binario…"
|
||||
cp games.toml dist/games.toml
|
||||
if [ "$OS" = "darwin" ]; then
|
||||
# -------------------------------------------------------------------------
|
||||
# macOS: app bundle + DMG
|
||||
# -------------------------------------------------------------------------
|
||||
if [ ! -f "$ICON_ICNS" ]; then
|
||||
echo "[build] falta ${ICON_ICNS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[build] empaquetando release ${RELEASE_NAME}.tar.gz…"
|
||||
tar -czf "dist/${RELEASE_NAME}.tar.gz" -C dist jlauncher games.toml
|
||||
echo "[build] compilando «Jail Launcher.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.jail-launcher \
|
||||
--company-name=jailgames \
|
||||
--product-name=jail-launcher \
|
||||
--product-version="$VERSION" \
|
||||
--assume-yes-for-downloads \
|
||||
--enable-plugin=pyside6 \
|
||||
--include-package=jail_launcher \
|
||||
--output-dir=dist \
|
||||
--output-filename=jail-launcher \
|
||||
--remove-output \
|
||||
app.py
|
||||
|
||||
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)."
|
||||
# Según la versión, Nuitka nombra el bundle a partir del script de entrada
|
||||
# (app.py -> app.app). Lo normalizamos a «Jail Launcher.app» (nombre visible en
|
||||
# Finder/Dock; el ejecutable interno sigue siendo jail-launcher).
|
||||
APP="dist/Jail Launcher.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/jail-launcher-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 "Jail Launcher" -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/jail-launcher/."
|
||||
echo "[build] distribuir: el .dmg (arrastrar «Jail Launcher.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="jail-launcher-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=jail_launcher \
|
||||
--output-dir=dist \
|
||||
--output-filename=jail-launcher \
|
||||
--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 jail-launcher games.toml
|
||||
|
||||
echo "[build] hecho:"
|
||||
ls -lh "dist/jail-launcher" "dist/games.toml" "dist/${RELEASE_NAME}.tar.gz"
|
||||
echo "[build] el binario crea jail_launcher_data/ y settings.json junto a sí mismo."
|
||||
echo "[build] distribuir: descomprimir el tar.gz (jail-launcher + games.toml juntos)."
|
||||
fi
|
||||
|
||||
+13
-13
@@ -1,7 +1,7 @@
|
||||
# Configuración de jlauncher — lista de juegos.
|
||||
# Configuración de jail-launcher — lista de juegos.
|
||||
#
|
||||
# Campos por juego ([[game]]):
|
||||
# id (obligatorio) slug interno → nombre de carpeta en jlauncher_data/
|
||||
# id (obligatorio) slug interno → nombre de carpeta en jail_launcher_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)
|
||||
@@ -15,30 +15,30 @@
|
||||
# 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"
|
||||
data_dir = "jail_launcher_data"
|
||||
|
||||
[[game]]
|
||||
id = "coffee_crisis"
|
||||
id = "coffee-crisis"
|
||||
name = "Coffee Crisis"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis.git"
|
||||
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"
|
||||
id = "coffee-crisis-ae"
|
||||
name = "Coffee Crisis Arcade Edition"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee_crisis_arcade_edition.git"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/coffee-crisis-ae.git"
|
||||
build_cmd = ""
|
||||
run_cmd = "make run"
|
||||
players = "1-2 jugadors"
|
||||
author = "JailDesigner"
|
||||
|
||||
[[game]]
|
||||
id = "jaildoctors_dilemma"
|
||||
id = "jaildoctors-dilemma"
|
||||
name = "JailDoctor's Dilemma"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/jaildoctors_dilemma.git"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/jaildoctors-dilemma.git"
|
||||
build_cmd = ""
|
||||
run_cmd = "make run"
|
||||
players = "1 jugador"
|
||||
@@ -54,18 +54,18 @@ players = "1 jugador"
|
||||
author = "JailDesigner"
|
||||
|
||||
[[game]]
|
||||
id = "orni_attack"
|
||||
id = "orni-attack"
|
||||
name = "Orni Attack"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/orni_attack.git"
|
||||
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"
|
||||
id = "projecte-2026"
|
||||
name = "Projecte 2026"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte_2026.git"
|
||||
clone_url = "https://gitea.sustancia.synology.me/jaildesigner-jailgames/projecte-2026.git"
|
||||
build_cmd = ""
|
||||
run_cmd = "make run"
|
||||
players = "1 jugador"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_dependencies():
|
||||
"""Verifica que ImageMagick esté instalado"""
|
||||
try:
|
||||
subprocess.run(['magick', '--version'], capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: ImageMagick no está instalado o no se encuentra en el PATH")
|
||||
print("Instala ImageMagick desde: https://imagemagick.org/script/download.php")
|
||||
sys.exit(1)
|
||||
|
||||
# Verificar iconutil solo en macOS
|
||||
if sys.platform == 'darwin':
|
||||
try:
|
||||
# iconutil no tiene --version, mejor usar which o probar con -h
|
||||
result = subprocess.run(['which', 'iconutil'], capture_output=True, check=True)
|
||||
if result.returncode == 0:
|
||||
print("✓ iconutil disponible - se crearán archivos .ico e .icns")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: iconutil no está disponible (solo funciona en macOS)")
|
||||
print("Solo se creará el archivo .ico")
|
||||
else:
|
||||
print("ℹ️ Sistema no-macOS detectado - solo se creará archivo .ico")
|
||||
|
||||
|
||||
def create_icons(input_file):
|
||||
"""Crea archivos .icns e .ico a partir de un PNG"""
|
||||
|
||||
# Verificar que el archivo existe
|
||||
if not os.path.isfile(input_file):
|
||||
print(f"Error: El archivo {input_file} no existe.")
|
||||
return False
|
||||
|
||||
# Obtener información del archivo
|
||||
file_path = Path(input_file)
|
||||
file_dir = file_path.parent
|
||||
file_name = file_path.stem # Nombre sin extensión
|
||||
file_extension = file_path.suffix
|
||||
|
||||
if file_extension.lower() not in ['.png', '.jpg', '.jpeg', '.bmp', '.tiff']:
|
||||
print(f"Advertencia: {file_extension} puede no ser compatible. Se recomienda usar PNG.")
|
||||
|
||||
# Crear archivo .ico usando el método simplificado
|
||||
ico_output = file_dir / f"{file_name}.ico"
|
||||
try:
|
||||
print(f"Creando {ico_output}...")
|
||||
subprocess.run([
|
||||
'magick', str(input_file),
|
||||
'-define', 'icon:auto-resize=256,128,64,48,32,16',
|
||||
str(ico_output)
|
||||
], check=True)
|
||||
print(f"✓ Archivo .ico creado: {ico_output}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error creando archivo .ico: {e}")
|
||||
return False
|
||||
|
||||
# Crear archivo .icns (solo en macOS)
|
||||
if sys.platform == 'darwin':
|
||||
try:
|
||||
# Crear carpeta temporal para iconset
|
||||
temp_folder = file_dir / "icon.iconset"
|
||||
|
||||
# Eliminar carpeta temporal si existe
|
||||
if temp_folder.exists():
|
||||
shutil.rmtree(temp_folder)
|
||||
|
||||
# Crear carpeta temporal
|
||||
temp_folder.mkdir(parents=True)
|
||||
|
||||
# Definir los tamaños y nombres de archivo para .icns
|
||||
icon_sizes = [
|
||||
(16, "icon_16x16.png"),
|
||||
(32, "icon_16x16@2x.png"),
|
||||
(32, "icon_32x32.png"),
|
||||
(64, "icon_32x32@2x.png"),
|
||||
(128, "icon_128x128.png"),
|
||||
(256, "icon_128x128@2x.png"),
|
||||
(256, "icon_256x256.png"),
|
||||
(512, "icon_256x256@2x.png"),
|
||||
(512, "icon_512x512.png"),
|
||||
(1024, "icon_512x512@2x.png")
|
||||
]
|
||||
|
||||
print("Generando imágenes para .icns...")
|
||||
# Crear cada tamaño de imagen
|
||||
for size, output_name in icon_sizes:
|
||||
output_path = temp_folder / output_name
|
||||
subprocess.run([
|
||||
'magick', str(input_file),
|
||||
'-resize', f'{size}x{size}',
|
||||
str(output_path)
|
||||
], check=True)
|
||||
|
||||
# Crear archivo .icns usando iconutil
|
||||
icns_output = file_dir / f"{file_name}.icns"
|
||||
print(f"Creando {icns_output}...")
|
||||
subprocess.run([
|
||||
'iconutil', '-c', 'icns',
|
||||
str(temp_folder),
|
||||
'-o', str(icns_output)
|
||||
], check=True)
|
||||
|
||||
# Limpiar carpeta temporal
|
||||
if temp_folder.exists():
|
||||
shutil.rmtree(temp_folder)
|
||||
|
||||
print(f"✓ Archivo .icns creado: {icns_output}")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error creando archivo .icns: {e}")
|
||||
# Limpiar carpeta temporal en caso de error
|
||||
if temp_folder.exists():
|
||||
shutil.rmtree(temp_folder)
|
||||
return False
|
||||
else:
|
||||
print("ℹ️ Archivo .icns no creado (solo disponible en macOS)")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Función principal"""
|
||||
# Verificar argumentos
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Uso: {sys.argv[0]} ARCHIVO")
|
||||
print("Ejemplo: python3 create_icons.py imagen.png")
|
||||
sys.exit(0)
|
||||
|
||||
input_file = sys.argv[1]
|
||||
|
||||
# Verificar dependencias
|
||||
check_dependencies()
|
||||
|
||||
# Crear iconos
|
||||
if create_icons(input_file):
|
||||
print("\n✅ Proceso completado exitosamente")
|
||||
else:
|
||||
print("\n❌ El proceso falló")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
"""jail-launcher — lanzador de juegos jailgames."""
|
||||
|
||||
__version__ = "1.0.5"
|
||||
@@ -4,17 +4,24 @@ 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 config_file, data_root
|
||||
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.setApplicationName("jail-launcher")
|
||||
# No fixem applicationDisplayName: a Windows/X11 Qt l'afegeix al títol de la
|
||||
# finestra ("títol - displayName") i duplicaria «Jail Launcher», que ja surt
|
||||
# a WINDOW_TITLE. El nom de l'app al menú de macOS ve del bundle (Nuitka).
|
||||
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)
|
||||
@@ -31,7 +31,7 @@ class Game:
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
data_dir: str = "jlauncher_data"
|
||||
data_dir: str = "jail_launcher_data"
|
||||
games: list[Game] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -80,4 +80,4 @@ def load_config(path: Path) -> Config:
|
||||
if not games:
|
||||
raise ValueError("games.toml no define ningún [[game]]")
|
||||
|
||||
return Config(data_dir=raw.get("data_dir", "jlauncher_data"), games=games)
|
||||
return Config(data_dir=raw.get("data_dir", "jail_launcher_data"), games=games)
|
||||
@@ -10,7 +10,9 @@ 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
|
||||
@@ -44,11 +46,41 @@ class NetConfig:
|
||||
|
||||
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:
|
||||
@@ -99,6 +131,7 @@ def _run_git(
|
||||
text=True,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
||||
timeout=timeout,
|
||||
creationflags=_NO_WINDOW,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
emit(
|
||||
@@ -166,7 +199,7 @@ def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None:
|
||||
log(f"{game.name}: no hi ha res a esborrar")
|
||||
return
|
||||
log(f"Esborrant la descàrrega local de {game.name}…")
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
_force_rmtree(target, log)
|
||||
|
||||
|
||||
def download(
|
||||
@@ -201,7 +234,7 @@ def download(
|
||||
else:
|
||||
log(f"Clonant {game.name}…")
|
||||
if repo.exists(): # carpeta a medias sin .git: limpiarla
|
||||
shutil.rmtree(repo, ignore_errors=True)
|
||||
_force_rmtree(repo, log)
|
||||
_run_git(
|
||||
["clone", game.clone_url, str(repo)],
|
||||
None,
|
||||
@@ -272,6 +305,7 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
creationflags=_NO_WINDOW,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
log(f"version_cmd ha fallat: {exc}")
|
||||
@@ -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 ``jail_launcher``).
|
||||
"""
|
||||
|
||||
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 = "jail-launcher"
|
||||
|
||||
|
||||
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 jail_launcher_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 jail_launcher/
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def macos_app_bundle() -> Path | None:
|
||||
"""Ruta del .app si corremos como bundle de macOS (``…/jail-launcher.app``); si no, None.
|
||||
|
||||
En un app bundle el ejecutable vive en ``…/jail-launcher.app/Contents/MacOS/jail-launcher``,
|
||||
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 = "jail_launcher_data") -> Path:
|
||||
"""Carpeta raíz de datos; se crea si no existe."""
|
||||
root = writable_base() / data_dir
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def game_dir(root: Path, game_id: str) -> Path:
|
||||
"""Carpeta de un juego: <root>/<id>/."""
|
||||
return root / game_id
|
||||
|
||||
|
||||
def repo_dir(root: Path, game_id: str) -> Path:
|
||||
"""Carpeta del clone git: <root>/<id>/repo/."""
|
||||
return game_dir(root, game_id) / "repo"
|
||||
|
||||
|
||||
def metadata_dir(root: Path, game_id: str) -> Path:
|
||||
"""Carpeta de metadata cacheada: <root>/<id>/metadata/."""
|
||||
return game_dir(root, game_id) / "metadata"
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Compilar y ejecutar un juego vía subprocess."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Game
|
||||
from .paths import repo_dir
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
# Windows: evita que la compilación/ejecución (shell + make/gcc, apps de consola)
|
||||
# abra ventanas negras cuando el lanzador corre como GUI sin consola (el .exe).
|
||||
# En el resto de SO es 0 (sin efecto).
|
||||
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||
|
||||
# Directorios habituales de herramientas de línea de comandos (Homebrew, MacPorts…).
|
||||
# Una .app de macOS lanzada desde Finder/Dock NO hereda el PATH del shell de login,
|
||||
# así que herramientas como cmake, instaladas con Homebrew en /opt/homebrew/bin
|
||||
# (Apple Silicon) o /usr/local/bin (Intel), quedan fuera del PATH y `make` no las
|
||||
# encuentra. Las añadimos explícitamente. En Linux estos paths no existen y se
|
||||
# filtran solos.
|
||||
_EXTRA_PATH_DIRS = (
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
"/opt/local/bin",
|
||||
)
|
||||
|
||||
# Prefijos de Homebrew/MacPorts. Una .app lanzada desde Finder/Dock tampoco
|
||||
# hereda CPATH/LIBRARY_PATH/PKG_CONFIG_PATH del shell, así que el compilador no
|
||||
# encuentra las cabeceras (p.ej. <SDL3/SDL_filesystem.h>) ni las librerías
|
||||
# instaladas con `brew`. Añadimos sus include/lib explícitamente. En Linux estos
|
||||
# paths no existen y se filtran solos.
|
||||
_EXTRA_PREFIXES = (
|
||||
"/opt/homebrew",
|
||||
"/usr/local",
|
||||
"/opt/local",
|
||||
)
|
||||
|
||||
|
||||
def _noop(_: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _launch_env() -> dict[str, str]:
|
||||
"""Entorno para los subprocesos con un PATH que incluye los directorios de
|
||||
herramientas habituales aunque la app se lance desde Finder/Dock."""
|
||||
env = os.environ.copy()
|
||||
parts = env.get("PATH", "").split(os.pathsep)
|
||||
parts = [p for p in parts if p]
|
||||
for d in _EXTRA_PATH_DIRS:
|
||||
if d not in parts and os.path.isdir(d):
|
||||
parts.append(d)
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
|
||||
# Rutas de cabeceras (CPATH), librerías (LIBRARY_PATH) y pkg-config para que
|
||||
# el compilador encuentre dependencias instaladas con brew/MacPorts.
|
||||
includes, libs, pkgconfigs = [], [], []
|
||||
for prefix in _EXTRA_PREFIXES:
|
||||
inc, lib = f"{prefix}/include", f"{prefix}/lib"
|
||||
if os.path.isdir(inc):
|
||||
includes.append(inc)
|
||||
if os.path.isdir(lib):
|
||||
libs.append(lib)
|
||||
pkgconfigs.append(f"{lib}/pkgconfig")
|
||||
_prepend(env, "CPATH", includes)
|
||||
_prepend(env, "LIBRARY_PATH", libs)
|
||||
_prepend(env, "PKG_CONFIG_PATH", pkgconfigs)
|
||||
return env
|
||||
|
||||
|
||||
def _prepend(env: dict[str, str], var: str, dirs: list[str]) -> None:
|
||||
"""Antepone dirs al valor de env[var] (lista separada por os.pathsep), sin
|
||||
duplicar entradas ya presentes."""
|
||||
existing = [p for p in env.get(var, "").split(os.pathsep) if p]
|
||||
new = [d for d in dirs if d not in existing]
|
||||
if new:
|
||||
env[var] = os.pathsep.join(new + existing)
|
||||
|
||||
|
||||
def _stream(cmd: str, cwd: Path, log: LogFn) -> int:
|
||||
"""Ejecuta un comando de shell en cwd, retransmitiendo stdout/err línea a línea."""
|
||||
log(f"$ {cmd} (cwd={cwd})")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=_launch_env(),
|
||||
creationflags=_NO_WINDOW,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
log(line.rstrip())
|
||||
return proc.wait()
|
||||
|
||||
|
||||
def run_game(root: Path, game: Game, log: LogFn = _noop) -> int:
|
||||
"""Compila (si hay build_cmd) y ejecuta el juego. Devuelve el código de salida.
|
||||
|
||||
Si build_cmd falla, aborta sin ejecutar. Lanza FileNotFoundError si el repo
|
||||
no está clonado.
|
||||
"""
|
||||
repo = repo_dir(root, game.id)
|
||||
if not (repo / ".git").exists():
|
||||
raise FileNotFoundError(
|
||||
f"{game.name} no està descarregat. Prem Descarrega primer."
|
||||
)
|
||||
|
||||
if game.build_cmd.strip():
|
||||
log(f"Compilant {game.name}…")
|
||||
code = _stream(game.build_cmd, repo, log)
|
||||
if code != 0:
|
||||
log(f"La compilació ha fallat (codi {code}). No s'executa.")
|
||||
return code
|
||||
|
||||
log(f"Executant {game.name}…")
|
||||
return _stream(game.run_cmd, repo, log)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Preferencias persistentes en settings.json, junto al ejecutable."""
|
||||
"""Preferencias persistentes en settings.json, junto a los datos de la app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import base_dir
|
||||
from .paths import writable_base
|
||||
|
||||
SETTINGS_NAME = "settings.json"
|
||||
|
||||
@@ -22,8 +22,16 @@ _CONSOLE_MODES = ("show", "auto", "hide")
|
||||
|
||||
|
||||
def _valid_console_mode(value) -> str:
|
||||
"""Normaliza el modo de consola; cae a 'show' si es desconocido."""
|
||||
return value if value in _CONSOLE_MODES else "show"
|
||||
"""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
|
||||
@@ -33,7 +41,8 @@ class Settings:
|
||||
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 = "show" # consola de log: "show" | "auto" | "hide"
|
||||
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)
|
||||
@@ -43,7 +52,7 @@ class Settings:
|
||||
|
||||
|
||||
def settings_path() -> Path:
|
||||
return base_dir() / SETTINGS_NAME
|
||||
return writable_base() / SETTINGS_NAME
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
@@ -60,7 +69,8 @@ def load_settings() -> Settings:
|
||||
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", "show")),
|
||||
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)),
|
||||
@@ -0,0 +1 @@
|
||||
"""Componentes de interfaz de jail-launcher."""
|
||||
@@ -5,10 +5,14 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QThreadPool, Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup
|
||||
from PySide6.QtGui import QAction, QActionGroup, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
@@ -21,6 +25,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
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
|
||||
@@ -31,12 +36,15 @@ 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 = 4000 # marge sense activitat abans de replegar en mode auto
|
||||
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:
|
||||
@@ -71,16 +79,15 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# --- Lista de juegos con scroll ---
|
||||
list_container = QWidget()
|
||||
list_layout = QVBoxLayout(list_container)
|
||||
list_layout.setContentsMargins(6, 6, 6, 6)
|
||||
list_layout.setSpacing(6)
|
||||
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
|
||||
list_layout.addWidget(row)
|
||||
list_layout.addStretch(1)
|
||||
self._populate_list()
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
@@ -233,6 +240,7 @@ class MainWindow(QMainWindow):
|
||||
menu.addAction(self.action_check_on_start)
|
||||
|
||||
menu.addSeparator()
|
||||
self._build_sort_menu(menu)
|
||||
self._build_theme_menu(menu)
|
||||
self._build_console_menu(menu)
|
||||
|
||||
@@ -257,13 +265,93 @@ class MainWindow(QMainWindow):
|
||||
help_menu.addAction(self.action_about)
|
||||
|
||||
def _show_about(self) -> None:
|
||||
QMessageBox.about(
|
||||
self,
|
||||
f"Quant a {APP_NAME}",
|
||||
f"<b>{APP_NAME}</b><br>"
|
||||
f"Versió {__version__}<br><br>"
|
||||
"© 2026 JailDesigner",
|
||||
)
|
||||
"""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."""
|
||||
@@ -348,6 +436,23 @@ class MainWindow(QMainWindow):
|
||||
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():
|
||||
@@ -398,7 +503,13 @@ class MainWindow(QMainWindow):
|
||||
# --------------------------------------------------------------- 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:
|
||||
@@ -1,3 +0,0 @@
|
||||
"""jlauncher — lanzador de juegos jailgames."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Resolución de rutas: dónde está games.toml y dónde guardar los datos.
|
||||
|
||||
Compilado con Nuitka, ``__compiled__`` existe. En modo ``--onefile`` la carpeta del
|
||||
binario real la expone ``NUITKA_ONEFILE_DIRECTORY`` (Nuitka 4.x); con versiones que usan
|
||||
``NUITKA_ONEFILE_BINARY`` tomamos su carpeta; si no, ``sys.executable`` (standalone).
|
||||
Ejecutando desde fuente usamos la raíz del proyecto (la carpeta que contiene ``jlauncher``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_NAME = "games.toml"
|
||||
|
||||
|
||||
def is_compiled() -> bool:
|
||||
"""True si corremos como binario Nuitka (define ``__compiled__`` en cada módulo)."""
|
||||
return "__compiled__" in globals()
|
||||
|
||||
|
||||
def base_dir() -> Path:
|
||||
"""Carpeta base junto a la que viven games.toml y jlauncher_data."""
|
||||
if is_compiled():
|
||||
directory = os.environ.get("NUITKA_ONEFILE_DIRECTORY")
|
||||
if directory:
|
||||
return Path(directory).resolve()
|
||||
binary = os.environ.get("NUITKA_ONEFILE_BINARY")
|
||||
if binary:
|
||||
return Path(binary).resolve().parent
|
||||
return Path(sys.executable).resolve().parent
|
||||
# Desde fuente: raíz del proyecto = padre del paquete jlauncher/
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def config_file() -> Path:
|
||||
"""Ruta a games.toml (junto al ejecutable / raíz del proyecto)."""
|
||||
return base_dir() / CONFIG_NAME
|
||||
|
||||
|
||||
def data_root(data_dir: str = "jlauncher_data") -> Path:
|
||||
"""Carpeta raíz de datos; se crea si no existe."""
|
||||
root = base_dir() / data_dir
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def game_dir(root: Path, game_id: str) -> Path:
|
||||
"""Carpeta de un juego: <root>/<id>/."""
|
||||
return root / game_id
|
||||
|
||||
|
||||
def repo_dir(root: Path, game_id: str) -> Path:
|
||||
"""Carpeta del clone git: <root>/<id>/repo/."""
|
||||
return game_dir(root, game_id) / "repo"
|
||||
|
||||
|
||||
def metadata_dir(root: Path, game_id: str) -> Path:
|
||||
"""Carpeta de metadata cacheada: <root>/<id>/metadata/."""
|
||||
return game_dir(root, game_id) / "metadata"
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Compilar y ejecutar un juego vía subprocess."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Game
|
||||
from .paths import repo_dir
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
def _noop(_: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _stream(cmd: str, cwd: Path, log: LogFn) -> int:
|
||||
"""Ejecuta un comando de shell en cwd, retransmitiendo stdout/err línea a línea."""
|
||||
log(f"$ {cmd} (cwd={cwd})")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
log(line.rstrip())
|
||||
return proc.wait()
|
||||
|
||||
|
||||
def run_game(root: Path, game: Game, log: LogFn = _noop) -> int:
|
||||
"""Compila (si hay build_cmd) y ejecuta el juego. Devuelve el código de salida.
|
||||
|
||||
Si build_cmd falla, aborta sin ejecutar. Lanza FileNotFoundError si el repo
|
||||
no está clonado.
|
||||
"""
|
||||
repo = repo_dir(root, game.id)
|
||||
if not (repo / ".git").exists():
|
||||
raise FileNotFoundError(
|
||||
f"{game.name} no està descarregat. Prem Descarrega primer."
|
||||
)
|
||||
|
||||
if game.build_cmd.strip():
|
||||
log(f"Compilant {game.name}…")
|
||||
code = _stream(game.build_cmd, repo, log)
|
||||
if code != 0:
|
||||
log(f"La compilació ha fallat (codi {code}). No s'executa.")
|
||||
return code
|
||||
|
||||
log(f"Executant {game.name}…")
|
||||
return _stream(game.run_cmd, repo, log)
|
||||
@@ -1 +0,0 @@
|
||||
"""Componentes de interfaz de jlauncher."""
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "jlauncher"
|
||||
version = "1.0.0"
|
||||
name = "jail-launcher"
|
||||
version = "1.0.4"
|
||||
description = "Lanzador de juegos jailgames: clona, compila y ejecuta repos Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -8,11 +8,11 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
jlauncher = "jlauncher.__main__:main"
|
||||
jail-launcher = "jail_launcher.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["jlauncher", "jlauncher.ui"]
|
||||
packages = ["jail_launcher", "jail_launcher.ui"]
|
||||
|
||||
Reference in New Issue
Block a user