Empaqueta jlauncher com a .app + .dmg per a macOS
This commit is contained in:
@@ -86,6 +86,9 @@ __pycache__/
|
|||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
|
# Artefactos del icono macOS (regenerables desde assets/icon.png con build.sh)
|
||||||
|
assets/icon.icns
|
||||||
|
assets/icon.iconset/
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -88,9 +88,26 @@ fuente se ejecuta con `python -m jlauncher`).
|
|||||||
patchelf (Nuitka usa `install_name_tool`).
|
patchelf (Nuitka usa `install_name_tool`).
|
||||||
- `git` en el PATH.
|
- `git` en el PATH.
|
||||||
|
|
||||||
### macOS
|
### macOS (.app + .dmg)
|
||||||
|
|
||||||
Compila en el propio Mac (Nuitka no compila cruzado): `./build.sh` genera
|
Compila en el propio Mac (Nuitka no compila cruzado). En macOS, `./build.sh` no genera un
|
||||||
`jlauncher-v…-darwin-arm64.tar.gz`. Como el binario no va firmado, la primera vez quizá
|
binario suelto sino una **app nativa**:
|
||||||
debas hacer `xattr -dr com.apple.quarantine jlauncher` o abrirlo con clic derecho → Abrir.
|
|
||||||
Lánzalo desde terminal (`./jlauncher`).
|
```bash
|
||||||
|
./build.sh
|
||||||
|
# -> dist/jlauncher.app
|
||||||
|
# -> dist/jlauncher-v<versión>-macos-<arch>.dmg (arrastrar la app a Aplicaciones)
|
||||||
|
```
|
||||||
|
|
||||||
|
El icono es provisional y se construye a `assets/icon.icns` desde `assets/icon.png`
|
||||||
|
(regenerable con `QT_QPA_PLATFORM=offscreen .venv/bin/python assets/make_icon.py`). Para
|
||||||
|
cambiarlo, sustituye `assets/icon.png` por un PNG cuadrado 1024×1024 y recompila.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,99 @@
|
|||||||
|
"""Genera un icono provisional para jlauncher (PNG 1024x1024).
|
||||||
|
|
||||||
|
Dibuja un «squircle» con degradado y un triángulo de «play» (es un lanzador de juegos).
|
||||||
|
Se ejecuta sin pantalla con la plataforma offscreen de Qt:
|
||||||
|
|
||||||
|
QT_QPA_PLATFORM=offscreen .venv/bin/python assets/make_icon.py
|
||||||
|
|
||||||
|
Salida: assets/icon.png. El .icns lo construye build.sh con iconutil.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt # noqa: E402
|
||||||
|
from PySide6.QtGui import ( # noqa: E402
|
||||||
|
QBrush,
|
||||||
|
QColor,
|
||||||
|
QGuiApplication,
|
||||||
|
QImage,
|
||||||
|
QLinearGradient,
|
||||||
|
QPainter,
|
||||||
|
QPainterPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIZE = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> QImage:
|
||||||
|
img = QImage(SIZE, SIZE, QImage.Format_ARGB32)
|
||||||
|
img.fill(Qt.transparent)
|
||||||
|
|
||||||
|
p = QPainter(img)
|
||||||
|
p.setRenderHint(QPainter.Antialiasing, True)
|
||||||
|
|
||||||
|
# «Squircle»: rectángulo con esquinas redondeadas estilo macOS (~22% del lado).
|
||||||
|
margin = SIZE * 0.06
|
||||||
|
rect = QRectF(margin, margin, SIZE - 2 * margin, SIZE - 2 * margin)
|
||||||
|
radius = rect.width() * 0.2237
|
||||||
|
body = QPainterPath()
|
||||||
|
body.addRoundedRect(rect, radius, radius)
|
||||||
|
|
||||||
|
grad = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
||||||
|
grad.setColorAt(0.0, QColor("#4b3bd6"))
|
||||||
|
grad.setColorAt(1.0, QColor("#7b2ff7"))
|
||||||
|
p.fillPath(body, QBrush(grad))
|
||||||
|
|
||||||
|
# Barras verticales tenues: guiño a «jail».
|
||||||
|
p.save()
|
||||||
|
p.setClipPath(body)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(QColor(255, 255, 255, 26))
|
||||||
|
bars = 5
|
||||||
|
bar_w = rect.width() * 0.052
|
||||||
|
gap = (rect.width() - bars * bar_w) / (bars + 1)
|
||||||
|
x = rect.left() + gap
|
||||||
|
for _ in range(bars):
|
||||||
|
p.drawRoundedRect(QRectF(x, rect.top(), bar_w, rect.height()), bar_w / 2, bar_w / 2)
|
||||||
|
x += bar_w + gap
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
# Triángulo de «play» centrado, blanco con esquinas redondeadas.
|
||||||
|
cx, cy = rect.center().x(), rect.center().y()
|
||||||
|
r = rect.width() * 0.26
|
||||||
|
tri = QPainterPath()
|
||||||
|
tri.moveTo(QPointF(cx - r * 0.55, cy - r * 0.95))
|
||||||
|
tri.lineTo(QPointF(cx - r * 0.55, cy + r * 0.95))
|
||||||
|
tri.lineTo(QPointF(cx + r * 1.0, cy))
|
||||||
|
tri.closeSubpath()
|
||||||
|
|
||||||
|
pen_brush = QColor("#ffffff")
|
||||||
|
stroker_pen = p.pen()
|
||||||
|
stroker_pen.setColor(pen_brush)
|
||||||
|
stroker_pen.setWidthF(r * 0.28)
|
||||||
|
stroker_pen.setJoinStyle(Qt.RoundJoin)
|
||||||
|
stroker_pen.setCapStyle(Qt.RoundCap)
|
||||||
|
p.strokePath(tri, stroker_pen)
|
||||||
|
p.fillPath(tri, pen_brush)
|
||||||
|
|
||||||
|
p.end()
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
QGuiApplication([])
|
||||||
|
out = Path(__file__).resolve().parent / "icon.png"
|
||||||
|
img = render()
|
||||||
|
if not img.save(str(out), "PNG"):
|
||||||
|
print(f"[icon] no se pudo guardar {out}")
|
||||||
|
return 1
|
||||||
|
print(f"[icon] generado {out} ({SIZE}x{SIZE})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Compila jlauncher a un binario standalone con Nuitka y empaqueta un tar.gz de release.
|
# Compila jlauncher con Nuitka y empaqueta un release.
|
||||||
# Requisitos del sistema: python3-dev, gcc, patchelf (ver README).
|
# - 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
|
set -euo pipefail
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -13,7 +15,6 @@ if [ -z "$VERSION" ]; then
|
|||||||
fi
|
fi
|
||||||
ARCH="$(uname -m)"
|
ARCH="$(uname -m)"
|
||||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
RELEASE_NAME="jlauncher-v${VERSION}-${OS}-${ARCH}"
|
|
||||||
|
|
||||||
if [ ! -d .venv ]; then
|
if [ ! -d .venv ]; then
|
||||||
echo "[build] creando venv…"
|
echo "[build] creando venv…"
|
||||||
@@ -39,25 +40,113 @@ echo "[build] versión: v${VERSION}"
|
|||||||
echo "[build] limpiando artefactos previos…"
|
echo "[build] limpiando artefactos previos…"
|
||||||
rm -rf dist build app.build app.dist app.onefile-build
|
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 \
|
# Construye assets/icon.icns desde assets/icon.png (regenerable con make_icon.py).
|
||||||
--onefile \
|
# ---------------------------------------------------------------------------
|
||||||
--assume-yes-for-downloads \
|
build_icns() {
|
||||||
--enable-plugin=pyside6 \
|
local png="assets/icon.png"
|
||||||
--include-package=jlauncher \
|
local icns="assets/icon.icns"
|
||||||
--output-dir=dist \
|
if [ ! -f "$png" ]; then
|
||||||
--output-filename=jlauncher \
|
echo "[build] generando icono provisional (assets/icon.png)…"
|
||||||
--remove-output \
|
QT_QPA_PLATFORM=offscreen .venv/bin/python assets/make_icon.py
|
||||||
--lto=yes \
|
fi
|
||||||
app.py
|
echo "[build] construyendo ${icns}…"
|
||||||
|
local iconset="assets/icon.iconset"
|
||||||
|
rm -rf "$iconset"; mkdir -p "$iconset"
|
||||||
|
local s
|
||||||
|
for s in 16 32 128 256 512; do
|
||||||
|
sips -z "$s" "$s" "$png" --out "$iconset/icon_${s}x${s}.png" >/dev/null
|
||||||
|
sips -z $((s*2)) $((s*2)) "$png" --out "$iconset/icon_${s}x${s}@2x.png" >/dev/null
|
||||||
|
done
|
||||||
|
iconutil -c icns "$iconset" -o "$icns"
|
||||||
|
rm -rf "$iconset"
|
||||||
|
}
|
||||||
|
|
||||||
echo "[build] copiando games.toml junto al binario…"
|
if [ "$OS" = "darwin" ]; then
|
||||||
cp games.toml dist/games.toml
|
# -------------------------------------------------------------------------
|
||||||
|
# macOS: app bundle + DMG
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
build_icns
|
||||||
|
|
||||||
echo "[build] empaquetando release ${RELEASE_NAME}.tar.gz…"
|
echo "[build] compilando jlauncher.app (PySide6; puede tardar varios minutos)…"
|
||||||
tar -czf "dist/${RELEASE_NAME}.tar.gz" -C dist jlauncher games.toml
|
.venv/bin/python -m nuitka \
|
||||||
|
--standalone \
|
||||||
|
--macos-create-app-bundle \
|
||||||
|
--macos-app-icon=assets/icon.icns \
|
||||||
|
--macos-app-name=jlauncher \
|
||||||
|
--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:"
|
# Según la versión, Nuitka nombra el bundle a partir del script de entrada
|
||||||
ls -lh "dist/jlauncher" "dist/games.toml" "dist/${RELEASE_NAME}.tar.gz"
|
# (app.py -> app.app). Lo normalizamos a jlauncher.app.
|
||||||
echo "[build] el binario crea jlauncher_data/ y settings.json junto a sí mismo."
|
APP="dist/jlauncher.app"
|
||||||
echo "[build] distribuir: descomprimir el tar.gz (jlauncher + games.toml juntos)."
|
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 en Contents/Resources…"
|
||||||
|
cp games.toml "$APP/Contents/Resources/games.toml"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
+55
-2
@@ -9,11 +9,16 @@ Ejecutando desde fuente usamos la raíz del proyecto (la carpeta que contiene ``
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
CONFIG_NAME = "games.toml"
|
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:
|
def is_compiled() -> bool:
|
||||||
"""True si corremos como binario Nuitka (define ``__compiled__`` en cada módulo)."""
|
"""True si corremos como binario Nuitka (define ``__compiled__`` en cada módulo)."""
|
||||||
@@ -34,14 +39,62 @@ def base_dir() -> Path:
|
|||||||
return Path(__file__).resolve().parent.parent
|
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 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:
|
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
|
return base_dir() / CONFIG_NAME
|
||||||
|
|
||||||
|
|
||||||
def data_root(data_dir: str = "jlauncher_data") -> Path:
|
def data_root(data_dir: str = "jlauncher_data") -> Path:
|
||||||
"""Carpeta raíz de datos; se crea si no existe."""
|
"""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)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import json
|
|||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .paths import base_dir
|
from .paths import writable_base
|
||||||
|
|
||||||
SETTINGS_NAME = "settings.json"
|
SETTINGS_NAME = "settings.json"
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
def settings_path() -> Path:
|
def settings_path() -> Path:
|
||||||
return base_dir() / SETTINGS_NAME
|
return writable_base() / SETTINGS_NAME
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> Settings:
|
def load_settings() -> Settings:
|
||||||
|
|||||||
Reference in New Issue
Block a user