Ajoute "Publier" : exporte un jeu en exécutable Windows autonome
Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).
Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.
- db/constants.py : PROJECTS_DIR devient surchargeable via
FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
joueur autonome pointe ainsi vers son propre dossier "projects/"
embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
embeddable officiel (python.org) et vendore Flask via pip --target ;
fonctions séparées et mockables pour ne jamais déclencher de vrai
téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
dur au moment de la publication, csrf_token() factice puisqu'aucune
session n'existe dans cet export). sys.path doit être complété
manuellement au démarrage : le python311._pth de la distribution
embeddable ne référence que le dossier de python.exe lui-même, jamais
celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
(jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
de suivi serveur réel — le build est rapide) qui déclenche le
téléchargement du zip via un blob, comme la modale des codes de
récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
pjax.js la remplace et rejoue son script à chaque navigation).
Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.
tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5d6747770c
commit
1289079da5
+11
-1
@@ -2,7 +2,17 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
PROJECTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects")
|
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que
|
||||||
|
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur
|
||||||
|
# joueur autonome (publish/player_app_template.py), qui pose cette
|
||||||
|
# variable AVANT d'importer db/ pour pointer vers son propre dossier
|
||||||
|
# "projects/" embarqué plutôt que celui, réel, du poste de développement.
|
||||||
|
# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable
|
||||||
|
# d'environnement doit donc déjà être posée avant le tout premier
|
||||||
|
# `import db`.
|
||||||
|
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
|
||||||
|
)
|
||||||
|
|
||||||
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
|
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
|
||||||
FIELD_TYPES = {
|
FIELD_TYPES = {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
|
||||||
|
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
|
||||||
|
de navigation d'un jeu (templates/base.html)."""
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Assemble le zip exporté d'un jeu (bouton "Publier", voir
|
||||||
|
routes/publish/publish_game.py) : un dossier autonome contenant Python
|
||||||
|
portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie
|
||||||
|
figée du jeu (screens/db/filters/core minimal + templates/play.html +
|
||||||
|
le sous-ensemble utile de static/), sa base SQLite et ses fichiers
|
||||||
|
envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat).
|
||||||
|
|
||||||
|
Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un
|
||||||
|
dossier de travail temporaire, supprimé après envoi de la réponse) ni la
|
||||||
|
vraie base de données du jeu (copiée, jamais déplacée)."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
from . import vendor_runtime
|
||||||
|
|
||||||
|
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
|
||||||
|
|
||||||
|
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
|
||||||
|
# templates/play.html) — jamais les assets propres à l'éditeur seul
|
||||||
|
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
|
||||||
|
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"]
|
||||||
|
|
||||||
|
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_engine_sources(staging_dir):
|
||||||
|
"""Copie la partie du moteur nécessaire au mode jouable SEUL (jamais
|
||||||
|
l'éditeur ni l'authentification — voir le constat d'exploration dans
|
||||||
|
le plan : screens/ et db/ sont déjà totalement découplés de auth//
|
||||||
|
routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS
|
||||||
|
fichiers de core/ dont ils dépendent réellement)."""
|
||||||
|
for pkg in ("screens", "db", "filters"):
|
||||||
|
shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg),
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||||
|
|
||||||
|
core_dir = os.path.join(staging_dir, "core")
|
||||||
|
os.makedirs(core_dir, exist_ok=True)
|
||||||
|
for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"):
|
||||||
|
shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname))
|
||||||
|
|
||||||
|
templates_dir = os.path.join(staging_dir, "templates")
|
||||||
|
os.makedirs(templates_dir, exist_ok=True)
|
||||||
|
shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html"))
|
||||||
|
|
||||||
|
static_dir = os.path.join(staging_dir, "static")
|
||||||
|
for item in _STATIC_ITEMS:
|
||||||
|
src = os.path.join(_BASE_DIR, "static", item)
|
||||||
|
dst = os.path.join(static_dir, item)
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
if os.path.isdir(src):
|
||||||
|
shutil.copytree(src, dst)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_game_data(staging_dir, slug):
|
||||||
|
"""Copie le dossier RÉEL du jeu (game.db + uploads/, voir
|
||||||
|
db.game_dir) tel quel — jamais déplacé, jamais modifié."""
|
||||||
|
src = db.game_dir(slug)
|
||||||
|
dst = os.path.join(staging_dir, "projects", slug)
|
||||||
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js"))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_player_app(staging_dir, slug):
|
||||||
|
template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py")
|
||||||
|
with open(template_path, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
content = content.replace(_SLUG_PLACEHOLDER, slug)
|
||||||
|
with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_run_bat(staging_dir):
|
||||||
|
src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat")
|
||||||
|
shutil.copy2(src, os.path.join(staging_dir, "run.bat"))
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_dir(staging_dir, zip_path):
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, _dirs, files in os.walk(staging_dir):
|
||||||
|
for name in files:
|
||||||
|
full = os.path.join(root, name)
|
||||||
|
rel = os.path.relpath(full, staging_dir)
|
||||||
|
zf.write(full, rel)
|
||||||
|
|
||||||
|
|
||||||
|
def build_game_zip(slug):
|
||||||
|
"""Construit le zip et renvoie son chemin (dans un dossier temporaire
|
||||||
|
— à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir
|
||||||
|
routes/publish/publish_game.py)."""
|
||||||
|
game = db.game_meta(slug)
|
||||||
|
python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready()
|
||||||
|
|
||||||
|
staging_dir = tempfile.mkdtemp(prefix="forge_publish_")
|
||||||
|
try:
|
||||||
|
shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed"))
|
||||||
|
shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs"))
|
||||||
|
_copy_engine_sources(staging_dir)
|
||||||
|
_copy_game_data(staging_dir, slug)
|
||||||
|
_write_player_app(staging_dir, slug)
|
||||||
|
_write_run_bat(staging_dir)
|
||||||
|
|
||||||
|
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip")
|
||||||
|
os.close(zip_fd)
|
||||||
|
_zip_dir(staging_dir, zip_path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
return zip_path, game["name"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL
|
||||||
|
jeu (voir publish/build_package.py) : aucune installation externe requise,
|
||||||
|
lancé par double-clic sur run.bat (Python portable + Flask embarqués à
|
||||||
|
côté de ce fichier). Reprend telles quelles les routes utilisées par le
|
||||||
|
mode jouable (voir templates/play.html) — jamais l'éditeur ni
|
||||||
|
l'authentification, absents de cet export.
|
||||||
|
|
||||||
|
SLUG est remplacé littéralement par build_package.py au moment de la
|
||||||
|
publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté
|
||||||
|
tel quel depuis le dépôt source."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py)
|
||||||
|
# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/)
|
||||||
|
# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus
|
||||||
|
# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/
|
||||||
|
# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à
|
||||||
|
# côté de ce fichier.
|
||||||
|
sys.path.insert(0, _BASE_DIR)
|
||||||
|
|
||||||
|
# Doit être posé AVANT tout import de db/ : db/constants.py lit
|
||||||
|
# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le
|
||||||
|
# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du
|
||||||
|
# poste où le jeu a été publié.
|
||||||
|
os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects")
|
||||||
|
|
||||||
|
from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402
|
||||||
|
|
||||||
|
from core.flask_app import app # noqa: E402
|
||||||
|
from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname)
|
||||||
|
import db # noqa: E402
|
||||||
|
import screens # noqa: E402
|
||||||
|
|
||||||
|
# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) —
|
||||||
|
# play.html appelle quand même csrf_token() en Jinja (voir base.html/
|
||||||
|
# play.html) : un no-op le laisse fonctionner tel quel, sans le forker.
|
||||||
|
app.jinja_env.globals["csrf_token"] = lambda: ""
|
||||||
|
|
||||||
|
SLUG = "_SLUG_PLACEHOLDER"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return redirect(url_for("game_play", slug=SLUG))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/play")
|
||||||
|
def game_play(slug):
|
||||||
|
game = db.game_meta(SLUG)
|
||||||
|
payload = screens.full_game_payload(SLUG)
|
||||||
|
return render_template(
|
||||||
|
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/runtime-payload")
|
||||||
|
def runtime_payload(slug):
|
||||||
|
return jsonify(screens.full_game_payload(SLUG))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
|
||||||
|
def flow_node_run_data(slug, node_id):
|
||||||
|
node = screens.get_flow_node(SLUG, node_id)
|
||||||
|
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
|
||||||
|
return jsonify({"ok": False}), 400
|
||||||
|
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
node = {**node, "target_row_id": body.get("clicked_row_id")}
|
||||||
|
ok = screens.apply_data_action(SLUG, node)
|
||||||
|
return jsonify({"ok": ok})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
|
||||||
|
def flow_node_run_variable(slug, node_id):
|
||||||
|
node = screens.get_flow_node(SLUG, node_id)
|
||||||
|
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
|
||||||
|
return jsonify({"ok": False}), 400
|
||||||
|
ok = screens.apply_variable_action(SLUG, node)
|
||||||
|
return jsonify({"ok": ok})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/uploads/<path:filename>")
|
||||||
|
def uploaded_file(slug, filename):
|
||||||
|
return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _open_browser(port):
|
||||||
|
webbrowser.open(f"http://127.0.0.1:{port}/")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Port libre choisi au hasard par l'OS (jamais le même serveur à
|
||||||
|
# relancer sur un port déjà pris par une partie précédente encore
|
||||||
|
# ouverte) — le navigateur est ouvert automatiquement, l'utilisateur
|
||||||
|
# n'a jamais besoin de connaître ni taper le port.
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
threading.Timer(1.0, _open_browser, args=(port,)).start()
|
||||||
|
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python-embed\python.exe player_app.py
|
||||||
|
pause
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter
|
||||||
|
un jeu en exécutable autonome : Python portable (embeddable, python.org)
|
||||||
|
et les paquets pip nécessaires (Flask + ses dépendances), mis en cache
|
||||||
|
localement sous data/publish_vendor/ (non commité, voir .gitignore —
|
||||||
|
même dossier que data/secret_key/data/users.db, déjà runtime-only).
|
||||||
|
|
||||||
|
La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces
|
||||||
|
~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les
|
||||||
|
publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui
|
||||||
|
reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne
|
||||||
|
concerne que la machine qui PUBLIE.
|
||||||
|
|
||||||
|
Fonctions séparées et mockables (download_python_embeddable/
|
||||||
|
vendor_flask) pour que les tests ne déclenchent jamais un vrai
|
||||||
|
téléchargement."""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor")
|
||||||
|
|
||||||
|
PYTHON_VERSION = "3.11.9"
|
||||||
|
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
|
||||||
|
PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64")
|
||||||
|
PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs")
|
||||||
|
|
||||||
|
# Mêmes versions que requirements.txt — seul Flask est listé explicitement,
|
||||||
|
# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker)
|
||||||
|
# suivent automatiquement via pip.
|
||||||
|
FLASK_SPEC = "Flask==3.0.3"
|
||||||
|
|
||||||
|
|
||||||
|
def download_python_embeddable():
|
||||||
|
"""Télécharge et extrait le ZIP Python embeddable officiel si absent
|
||||||
|
du cache — no-op si déjà présent (idempotent, sûr à rappeler à
|
||||||
|
chaque publication)."""
|
||||||
|
marker = os.path.join(PYTHON_EMBED_DIR, "python.exe")
|
||||||
|
if os.path.isfile(marker):
|
||||||
|
return PYTHON_EMBED_DIR
|
||||||
|
os.makedirs(PYTHON_EMBED_DIR, exist_ok=True)
|
||||||
|
zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip")
|
||||||
|
urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path)
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(PYTHON_EMBED_DIR)
|
||||||
|
os.remove(zip_path)
|
||||||
|
_enable_pylibs_path(PYTHON_EMBED_DIR)
|
||||||
|
return PYTHON_EMBED_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_pylibs_path(python_embed_dir):
|
||||||
|
"""Le ZIP embeddable désactive site-packages par défaut
|
||||||
|
(python311._pth ne contient que "python311.zip" et "."). On ajoute une
|
||||||
|
ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver
|
||||||
|
tout le mécanisme "site" — les paquets vendorés (Flask...) sont de
|
||||||
|
simples dossiers purement Python, aucun besoin d'aller plus loin."""
|
||||||
|
pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")]
|
||||||
|
if not pth_files:
|
||||||
|
return
|
||||||
|
pth_path = os.path.join(python_embed_dir, pth_files[0])
|
||||||
|
with open(pth_path, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
if "pylibs" not in content:
|
||||||
|
with open(pth_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\npylibs\n")
|
||||||
|
|
||||||
|
|
||||||
|
def vendor_flask():
|
||||||
|
"""pip install --target=... : installe Flask (paquets purs Python,
|
||||||
|
indépendants de la version exacte de l'interpréteur embeddable) dans
|
||||||
|
le cache, réutilisé par tous les exports suivants — no-op si déjà
|
||||||
|
fait."""
|
||||||
|
if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR):
|
||||||
|
return PYLIBS_DIR
|
||||||
|
os.makedirs(PYLIBS_DIR, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return PYLIBS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_vendor_ready():
|
||||||
|
"""Point d'entrée unique appelé par build_package.py — télécharge/
|
||||||
|
installe ce qui manque, puis renvoie (dossier_python_embed,
|
||||||
|
dossier_pylibs)."""
|
||||||
|
python_dir = download_python_embeddable()
|
||||||
|
pylibs_dir = vendor_flask()
|
||||||
|
return python_dir, pylibs_dir
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
|
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import publish_game
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import after_this_request, send_file
|
||||||
|
|
||||||
|
import db
|
||||||
|
from publish.build_package import build_game_zip
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/publish", methods=["POST"])
|
||||||
|
def publish_game(slug):
|
||||||
|
"""Construit et renvoie le zip exécutable autonome du jeu (voir
|
||||||
|
publish/build_package.py) — protégée par la même garde d'accès que
|
||||||
|
toutes les autres routes /game/<slug>/... (core/auth_guard.py),
|
||||||
|
aucune vérification supplémentaire nécessaire ici."""
|
||||||
|
zip_path, game_name = build_game_zip(slug)
|
||||||
|
|
||||||
|
@after_this_request
|
||||||
|
def _cleanup(response):
|
||||||
|
try:
|
||||||
|
os.remove(zip_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return response
|
||||||
|
|
||||||
|
download_name = f"{db.slugify(game_name)}.zip"
|
||||||
|
return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip")
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<nav class="gameNavBar" aria-label="Navigation du jeu">
|
<nav class="gameNavBar" aria-label="Navigation du jeu">
|
||||||
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
|
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
|
||||||
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
|
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
|
||||||
|
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +79,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if game is defined and game %}
|
||||||
|
<!-- Même remarque que pour le modal des codes de récupération ci-dessus : à L'INTÉRIEUR
|
||||||
|
de <main> pour que pjax.js le remplace (et rejoue son script JS, voir
|
||||||
|
runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé
|
||||||
|
dans #pageChrome (comme le bouton "📦 Publier" qui l'ouvre), son
|
||||||
|
script ne serait rejoué qu'au tout premier chargement complet de la
|
||||||
|
page. -->
|
||||||
|
<div class="modal" id="publishModal">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="box">
|
||||||
|
<h2 class="title is-4">Publier « {{ game.name }} »</h2>
|
||||||
|
<p class="hint mb-4">Construit un exécutable Windows autonome (Python + serveur embarqués) : dézippé puis lancé en double-cliquant sur <code>run.bat</code>, le jeu se joue hors connexion, sans rien installer.</p>
|
||||||
|
<div id="publishProgress">
|
||||||
|
<p class="hint" id="publishStepText">Préparation du jeu...</p>
|
||||||
|
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="publishProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
|
||||||
|
</div>
|
||||||
|
<div id="publishError" class="notification is-danger" style="display:none;"></div>
|
||||||
|
<div id="publishDone" class="notification is-success" style="display:none;">✅ Téléchargement lancé — dézippe le fichier puis double-clique sur <code>run.bat</code>.</div>
|
||||||
|
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
|
||||||
|
<button type="button" class="button is-small" onclick="window.forgeClosePublishModal()">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var modal = document.getElementById('publishModal');
|
||||||
|
var stepText = document.getElementById('publishStepText');
|
||||||
|
var progressBox = document.getElementById('publishProgress');
|
||||||
|
var errorBox = document.getElementById('publishError');
|
||||||
|
var doneBox = document.getElementById('publishDone');
|
||||||
|
var steps = ["Préparation du jeu...", "Copie des données...", "Compression du dossier..."];
|
||||||
|
var stepTimer = null;
|
||||||
|
|
||||||
|
window.forgeOpenPublishModal = function () {
|
||||||
|
var stepIndex = 0;
|
||||||
|
stepText.textContent = steps[0];
|
||||||
|
progressBox.style.display = '';
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
doneBox.style.display = 'none';
|
||||||
|
modal.classList.add('is-active');
|
||||||
|
|
||||||
|
stepTimer = setInterval(function () {
|
||||||
|
stepIndex = (stepIndex + 1) % steps.length;
|
||||||
|
stepText.textContent = steps[stepIndex];
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
var filename = "{{ game.slug }}.zip";
|
||||||
|
fetch("{{ url_for('publish_game', slug=game.slug) }}", { method: 'POST' })
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
var disposition = resp.headers.get('Content-Disposition') || '';
|
||||||
|
var match = /filename="?([^";]+)"?/.exec(disposition);
|
||||||
|
if (match) filename = match[1];
|
||||||
|
return resp.blob();
|
||||||
|
})
|
||||||
|
.then(function (blob) {
|
||||||
|
clearInterval(stepTimer);
|
||||||
|
progressBox.style.display = 'none';
|
||||||
|
doneBox.style.display = '';
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
clearInterval(stepTimer);
|
||||||
|
progressBox.style.display = 'none';
|
||||||
|
errorBox.style.display = '';
|
||||||
|
errorBox.textContent = "Échec de la publication (" + err.message + "). Réessaie, ou vérifie ta connexion internet (nécessaire uniquement lors de la toute première publication sur ce poste).";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.forgeClosePublishModal = function () {
|
||||||
|
modal.classList.remove('is-active');
|
||||||
|
if (stepTimer) clearInterval(stepTimer);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
|
||||||
|
routes/publish/publish_game.py et publish/build_package.py). Le vrai
|
||||||
|
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
|
||||||
|
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
|
||||||
|
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
|
||||||
|
(fichiers présents, structure), jamais un vrai lancement du serveur
|
||||||
|
embarqué (nécessite un environnement Windows réel, voir le plan)."""
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import publish.build_package as build_package
|
||||||
|
|
||||||
|
from tests.test_auth import anon_client # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_vendor(tmp_path, monkeypatch):
|
||||||
|
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
|
||||||
|
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
|
||||||
|
python_dir = tmp_path / "python-embed-amd64"
|
||||||
|
python_dir.mkdir()
|
||||||
|
(python_dir / "python.exe").write_bytes(b"fake-exe")
|
||||||
|
pylibs_dir = tmp_path / "pylibs"
|
||||||
|
pylibs_dir.mkdir()
|
||||||
|
(pylibs_dir / "flask").mkdir()
|
||||||
|
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
|
||||||
|
|
||||||
|
def _fake_ensure_vendor_ready():
|
||||||
|
return str(python_dir), str(pylibs_dir)
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
|
||||||
|
return python_dir, pylibs_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
|
||||||
|
zip_path, game_name = build_package.build_game_zip(game)
|
||||||
|
try:
|
||||||
|
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
names = set(zf.namelist())
|
||||||
|
for expected in [
|
||||||
|
"run.bat", "player_app.py",
|
||||||
|
f"projects/{game}/game.db",
|
||||||
|
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
|
||||||
|
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
|
||||||
|
"templates/play.html",
|
||||||
|
"static/style.css", "static/csrf_fetch.js",
|
||||||
|
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
|
||||||
|
]:
|
||||||
|
assert expected in names, f"{expected} manquant du zip"
|
||||||
|
finally:
|
||||||
|
if os.path.isfile(zip_path):
|
||||||
|
os.remove(zip_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
|
||||||
|
zip_path, _ = build_package.build_game_zip(game)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
player_app_src = zf.read("player_app.py").decode("utf-8")
|
||||||
|
assert f'SLUG = "{game}"' in player_app_src
|
||||||
|
assert "_SLUG_PLACEHOLDER" not in player_app_src
|
||||||
|
finally:
|
||||||
|
if os.path.isfile(zip_path):
|
||||||
|
os.remove(zip_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
|
||||||
|
resp = client.post(f"/game/{game}/publish")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["Content-Type"] == "application/zip"
|
||||||
|
assert "attachment" in resp.headers["Content-Disposition"]
|
||||||
|
assert resp.data[:2] == b"PK" # signature d'un fichier zip
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
|
||||||
|
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
|
||||||
|
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
|
||||||
|
le projet d'un autre (même vérification que /game/<slug> lui-même,
|
||||||
|
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
|
||||||
|
from tests.test_auth import _register, _confirm_2fa, _cleanup_project
|
||||||
|
|
||||||
|
_register(anon_client, "publishisolation@example.com")
|
||||||
|
_confirm_2fa(anon_client)
|
||||||
|
try:
|
||||||
|
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
finally:
|
||||||
|
_cleanup_project("publishisolation@example.com")
|
||||||
Reference in New Issue
Block a user