diff --git a/.gitignore b/.gitignore index b47393f..f16df8f 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,9 @@ __pycache__/ # Distribution / packaging .Python +# Artefactos del icono macOS (regenerables desde assets/icon.png con build.sh) +assets/icon.icns +assets/icon.iconset/ build/ develop-eggs/ dist/ diff --git a/README.md b/README.md index 49009cd..d239941 100644 --- a/README.md +++ b/README.md @@ -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-macos-.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`. diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..2d6f380 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/make_icon.py b/assets/make_icon.py new file mode 100644 index 0000000..3aa00fc --- /dev/null +++ b/assets/make_icon.py @@ -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()) diff --git a/build.sh b/build.sh index 031f813..4100244 100755 --- a/build.sh +++ b/build.sh @@ -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,113 @@ 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 +# --------------------------------------------------------------------------- +# Construye assets/icon.icns desde assets/icon.png (regenerable con make_icon.py). +# --------------------------------------------------------------------------- +build_icns() { + local png="assets/icon.png" + local icns="assets/icon.icns" + if [ ! -f "$png" ]; then + echo "[build] generando icono provisional (assets/icon.png)…" + QT_QPA_PLATFORM=offscreen .venv/bin/python assets/make_icon.py + fi + 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…" -cp games.toml dist/games.toml +if [ "$OS" = "darwin" ]; then + # ------------------------------------------------------------------------- + # macOS: app bundle + DMG + # ------------------------------------------------------------------------- + build_icns -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=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:" -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 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 diff --git a/jlauncher/paths.py b/jlauncher/paths.py index 0ae48bb..d50f58e 100644 --- a/jlauncher/paths.py +++ b/jlauncher/paths.py @@ -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,62 @@ 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 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 diff --git a/jlauncher/settings.py b/jlauncher/settings.py index dee18b3..c931de9 100644 --- a/jlauncher/settings.py +++ b/jlauncher/settings.py @@ -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" @@ -43,7 +43,7 @@ class Settings: def settings_path() -> Path: - return base_dir() / SETTINGS_NAME + return writable_base() / SETTINGS_NAME def load_settings() -> Settings: