14 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
18 changed files with 733 additions and 55 deletions
+2
View File
@@ -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
._*
+22 -5
View File
@@ -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/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`.
+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).'
+96 -22
View File
@@ -1,6 +1,8 @@
#!/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 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)"
@@ -13,7 +15,6 @@ if [ -z "$VERSION" ]; then
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,98 @@ 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 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
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 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
+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.
+1 -1
View File
@@ -1,3 +1,3 @@
"""jlauncher — lanzador de juegos jailgames."""
__version__ = "1.0.0"
__version__ = "1.0.5"
+6 -1
View File
@@ -4,10 +4,11 @@ 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
@@ -15,6 +16,10 @@ 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)
+36 -2
View File
@@ -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}")
+69 -2
View File
@@ -9,11 +9,16 @@ Ejecutando desde fuente usamos la raíz del proyecto (la carpeta que contiene ``
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)."""
@@ -34,14 +39,76 @@ def base_dir() -> Path:
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 (junto al ejecutable / raíz del proyecto)."""
"""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 = base_dir() / data_dir
root = writable_base() / data_dir
root.mkdir(parents=True, exist_ok=True)
return root
+70
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import os
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
@@ -11,11 +13,77 @@ 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})")
@@ -27,6 +95,8 @@ def _stream(cmd: str, cwd: Path, log: LogFn) -> int:
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=_launch_env(),
creationflags=_NO_WINDOW,
)
assert proc.stdout is not None
for line in proc.stdout:
+17 -7
View File
@@ -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)),
+125 -14
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "jlauncher"
version = "1.0.0"
version = "1.0.4"
description = "Lanzador de juegos jailgames: clona, compila y ejecuta repos Gitea"
requires-python = ">=3.11"
dependencies = [