feat: deploy autonomous French web edition for Joy

This commit is contained in:
2026-08-21 12:30:00 +02:00
parent 643ad6a2b5
commit 262983a8b0
14 changed files with 289 additions and 8 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.github
.godot
dist
addons/godot-sqlite/bin
*.log
.DS_Store
+66
View File
@@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1.7
ARG GODOT_VERSION=4.6.3
FROM ubuntu:24.04 AS web-builder
ARG GODOT_VERSION
ARG SQLITE_VERSION=4.8
ARG TARGETARCH
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl libfontconfig1 unzip \
&& rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
amd64) GODOT_ARCH="x86_64" ;; \
arm64) GODOT_ARCH="arm64" ;; \
*) echo "Unsupported build architecture: $TARGETARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.${GODOT_ARCH}.zip" -o /tmp/godot.zip \
&& unzip -q /tmp/godot.zip -d /usr/local/bin \
&& mv "/usr/local/bin/Godot_v${GODOT_VERSION}-stable_linux.${GODOT_ARCH}" /usr/local/bin/godot \
&& chmod +x /usr/local/bin/godot \
&& mkdir -p "/root/.local/share/godot/export_templates/${GODOT_VERSION}.stable" \
&& curl -fsSL "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_export_templates.tpz" -o /tmp/templates.tpz \
&& unzip -q /tmp/templates.tpz -d /tmp/godot-templates \
&& mv /tmp/godot-templates/templates/* "/root/.local/share/godot/export_templates/${GODOT_VERSION}.stable/" \
&& rm -rf /tmp/godot.zip /tmp/templates.tpz /tmp/godot-templates
WORKDIR /src
COPY . .
RUN mkdir -p addons/godot-sqlite/bin \
&& curl -fsSL "https://github.com/2shady4u/godot-sqlite/releases/download/v${SQLITE_VERSION}/addons.zip" -o /tmp/godot-sqlite.zip \
&& unzip -j /tmp/godot-sqlite.zip \
'addons/godot-sqlite/bin/libgdsqlite.web.template_release.wasm32.wasm' \
-d addons/godot-sqlite/bin \
&& rm /tmp/godot-sqlite.zip \
&& find assets -type f -name '*.import' -exec sed -i 's|^compress/mode=2$|compress/mode=0|' {} +
RUN mkdir -p dist \
&& godot --headless --path /src --import \
&& find .godot/imported -name 'title.png-*.ctex' ! -name '*.etc2.ctex' ! -name '*.s3tc.ctex' -type f -size +0c | grep -q . \
&& find .godot/imported -name 'kalulu_sprite_sheet.png-*.ctex' ! -name '*.etc2.ctex' ! -name '*.s3tc.ctex' -type f -size +0c | grep -q .
RUN godot --headless --path /src --export-release "Web Test" dist/index.html \
&& mv dist/index.pck dist/index-3.0.1-fr-joy.pck \
&& sed -i 's|"index.pck":|"index-3.0.1-fr-joy.pck":|; s|"focusCanvas":true,|"focusCanvas":true,"mainPack":"index-3.0.1-fr-joy.pck",|' dist/index.html \
&& test -s dist/index.html \
&& test -s dist/index.wasm \
&& test -s dist/index-3.0.1-fr-joy.pck
FROM alpine:3.22 AS french-content
ARG FRENCH_PACK_VERSION=v20260615-9e5d0d4
RUN apk add --no-cache ca-certificates curl \
&& mkdir -p /content \
&& curl -fsSL "https://github.com/Excello-Recherche-Education/Kalulu-Languages/releases/download/${FRENCH_PACK_VERSION}/fr_FR.zip" -o /content/fr_FR.zip \
&& test -s /content/fr_FR.zip
FROM nginx:1.29.1-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-builder /src/dist/ /usr/share/nginx/html/
COPY --from=french-content /content/fr_FR.zip /usr/share/nginx/html/content/fr_FR.zip
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
+18 -1
View File
@@ -2,6 +2,23 @@
# 📖 Kalulu — Apprends à lire intelligemment
## Version Web Docker autonome — Joy
Cette variante déploie Kalulu en français sur `https://kalulu.agendx.ch` avec un profil local **Joy**, 5 ans, niveau débutant (Grande Section). Elle nutilise pas lAPI officielle de Kalulu : le pack `fr_FR` est servi par la même image et la progression reste dans le stockage IndexedDB du navigateur.
Prérequis : Docker avec BuildKit, un réseau Docker externe `traefik_proxy` et un Traefik fournissant HTTPS.
```bash
docker network inspect traefik_proxy >/dev/null 2>&1 || docker network create traefik_proxy
docker compose build kalulu
docker compose up -d kalulu
./scripts/verify-live.sh
```
Le premier lancement charge environ 34 Mo dapplication puis télécharge 66 Mo de contenu pédagogique depuis la même origine. Le pack est ensuite extrait et conservé localement ; cette première installation peut prendre quelques minutes. Effacer les données du site dans le navigateur réinitialise le profil et la progression.
Limage construit lexport avec Godot 4.6.3, les modèles dexport officiels et Godot-SQLite 4.8 (la version compilée pour Godot 4.6.3). Nginx sert WebAssembly avec les en-têtes COOP/COEP requis pour les threads.
## 🌍 Langues disponibles
- 🇬🇧 [English](README.md)
@@ -126,4 +143,4 @@ Oui ! Des versions pour Windows, Mac et Linux sont disponibles dans la section [
### 🌐 Kalulu est-il disponible directement sur le web ?
Nous y travaillons, restez à l’écoute !
Oui, cette variante autonome est disponible sur [kalulu.agendx.ch](https://kalulu.agendx.ch).
+25
View File
@@ -0,0 +1,25 @@
services:
kalulu:
image: kalulu-web:3.0.1-fr-joy
build:
context: .
dockerfile: Dockerfile
container_name: kalulu-web
restart: unless-stopped
networks:
- traefik_proxy
labels:
- traefik.enable=true
- traefik.docker.network=traefik_proxy
- traefik.http.routers.kalulu.rule=Host(`kalulu.agendx.ch`)
- traefik.http.routers.kalulu.entrypoints=websecure
- traefik.http.routers.kalulu.tls=true
- traefik.http.routers.kalulu.tls.certresolver=myresolver
- traefik.http.routers.kalulu.middlewares=crowdsec-bouncer@docker
- traefik.http.services.kalulu.loadbalancer.server.port=8080
security_opt:
- no-new-privileges:true
networks:
traefik_proxy:
external: true
+3 -3
View File
@@ -1360,7 +1360,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../Export/web/Kalulu.html"
export_path="dist/index.html"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -1378,9 +1378,9 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
variant/extensions_support=false
variant/extensions_support=true
variant/thread_support=true
vram_texture_compression/for_desktop=true
vram_texture_compression/for_desktop=false
vram_texture_compression/for_mobile=false
html/export_icon=true
html/custom_html_shell=""
+58
View File
@@ -0,0 +1,58 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
default_type application/octet-stream;
types {
text/html html;
text/css css;
application/javascript js;
application/wasm wasm;
application/json json;
image/png png;
image/svg+xml svg;
image/x-icon ico;
application/zip zip;
}
location = /healthz {
access_log off;
return 204;
}
location = /index.html {
add_header Cache-Control "no-cache" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
try_files $uri =404;
}
location /content/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
try_files $uri =404;
}
location ~* \.(wasm|pck|js)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
try_files $uri =404;
}
location / {
try_files $uri $uri/ =404;
}
}
+1
View File
@@ -23,6 +23,7 @@ boot_splash/bg_color=Color(0.141176, 0.141176, 0.141176, 1)
config/icon="res://assets/kalulu_icon.png"
custom/unlock_everything=false
custom/unlock_everything.debug=false
custom/local_web_mode=true
[audio]
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
BASE_URL="${1:-https://kalulu.agendx.ch}"
HEADERS="$(mktemp)"
trap 'rm -f "$HEADERS"' EXIT
curl -fsS -D "$HEADERS" -o /dev/null "$BASE_URL/"
grep -qi '^cross-origin-opener-policy: same-origin' "$HEADERS"
grep -qi '^cross-origin-embedder-policy: require-corp' "$HEADERS"
curl -fsS -o /dev/null "$BASE_URL/index.wasm"
curl -fsS -r 0-1023 -o /dev/null "$BASE_URL/content/fr_FR.zip"
printf 'Kalulu Web OK: %s\n' "$BASE_URL"
@@ -15,6 +15,12 @@ var teacher_settings: TeacherSettings
func _ready() -> void:
await get_tree().process_frame
if (ServerManager as ServerManagerClass).is_local_web_mode():
UserDataManager.ensure_joy_local_profile()
UserDataManager.set_language("fr_FR", true)
UserDataManager.save_all()
go_to_package_download()
return
device_language = UserDataManager.get_device_settings().language
Log.trace("LanguageCheck: Starting with device language %s" % device_language)
# Check the teacher settings, if we are logged in (this scene should not be accessible otherwise)
@@ -15,6 +15,9 @@ const MAIN_MENU_SCENE_PATH: String = "res://sources/menus/main/main_menu.tscn"
const DEVICE_SELECTION_SCENE_PATH: String = "res://sources/menus/device_selection/device_selection.tscn"
const LOGIN_SCENE_PATH: String = "res://sources/menus/login/login.tscn"
const USER_LANGUAGE_RESOURCES_PATH: String = "user://language_resources"
const LOCAL_WEB_PACK_PATH: String = "/content/fr_FR.zip"
const LOCAL_WEB_PACK_VERSION: Dictionary = {"year": 2026, "month": 6, "day": 15}
const GARDENS_SCENE_PATH: String = "res://sources/gardens/gardens.tscn"
# Translation key shown in the error popup for each DownloadError value
const ERROR_MESSAGES: Array[String] = [
"DISCONNECTED_ERROR",
@@ -64,6 +67,12 @@ func _ready() -> void:
current_language_path = USER_LANGUAGE_RESOURCES_PATH.path_join(language)
current_language_version = UserDataManager.get_device_settings().language_versions.get(language, {})
if (ServerManager as ServerManagerClass).is_local_web_mode():
server_language_version = LOCAL_WEB_PACK_VERSION
var origin: String = str(JavaScriptBridge.eval("window.location.origin"))
_start_pack_if_needed(origin + LOCAL_WEB_PACK_PATH)
return
Log.trace("PackageDownloader: Checking internet access")
if not await ServerManager.check_internet_access():
# Offline mode, if a pack is already downloaded, go to next scene
@@ -97,6 +106,10 @@ func _ready() -> void:
_show_error(DownloadError.DOWNLOAD_FAILED)
return
_start_pack_if_needed(res.body.url as String)
func _start_pack_if_needed(pack_url: String) -> void:
# If the language pack is not already downloaded or an update is needed
if not DirAccess.dir_exists_absolute(current_language_path) or current_language_version != server_language_version:
Log.trace("PackageDownloader: A new version of the language pack has been detected.\n Current version = " + str(current_language_version) + "\n Server version = " + str(server_language_version))
@@ -116,8 +129,8 @@ func _ready() -> void:
# still run offline if the download fails; it is only removed during
# extraction, once the new pack has been fully downloaded.
http_request.set_download_file(USER_LANGUAGE_RESOURCES_PATH.path_join(language + ".zip"))
Log.trace("PackageDownloader: Downloading pack from %s" % res.body.url)
var request_error: Error = http_request.request(res.body.url as String)
Log.trace("PackageDownloader: Downloading pack from %s" % pack_url)
var request_error: Error = http_request.request(pack_url)
if request_error != OK:
Log.error("PackageDownloader: Cannot start language pack download: %s" % error_string(request_error))
_show_error(DownloadError.DOWNLOAD_FAILED)
@@ -256,6 +269,14 @@ func _go_to_next_scene() -> void:
if not server_language_version.is_empty():
UserDataManager.set_language_version(language, server_language_version)
if (ServerManager as ServerManagerClass).is_local_web_mode():
if not UserDataManager.login_student("123"):
Log.error("PackageDownloader: Could not open Joy local profile")
_show_error(DownloadError.DISCONNECTED)
return
SceneLoader.change_scene(GARDENS_SCENE_PATH)
return
# Check if we have a valid device id
if not UserDataManager.get_device_settings().device_id:
Log.trace("PackageDownloader: No device id found, going to device selection")
+4
View File
@@ -22,6 +22,7 @@ var dev_last_click_time: float = 0.0
func _ready() -> void:
UserDataManager.ensure_joy_local_profile()
Log.info("MainMenu: Loaded successfuly")
version_label.text = Utils.get_application_version_with_code()
teacher_label.text = UserDataManager.get_device_settings().teacher
@@ -33,6 +34,9 @@ func _ready() -> void:
func _on_main_button_pressed() -> void:
Log.info("MainMenu: Main button pressed")
play_button.set_disabled(true)
if (ServerManager as ServerManagerClass).is_local_web_mode():
_on_login_in()
return
if UserDataManager.get_device_settings().teacher:
Log.info("MainMenu: Device settings has teacher info, so start auto login-in")
_on_login_in()
+7 -1
View File
@@ -38,14 +38,20 @@ var words_path: String = get_language_folder() + "/words/"
var additional_word_list: Dictionary = {}
var is_open: bool = false
@onready var db: SQLite = SQLite.new()
@onready var db: Object = (
ClassDB.instantiate(&"SQLite") if ClassDB.class_exists(&"SQLite") else null
)
func _exit_tree() -> void:
if db:
db.close_db()
func connect_to_db() -> void:
if not db:
Log.error("Database: SQLite extension is not available")
return
if is_open:
Log.trace("Database: Opening database while a connection is already opened. Closing the previous one.")
db.close_db()
@@ -25,6 +25,10 @@ var environment_setting: int = 1
@onready var loading_rect: TextureRect = $TextureRect
func is_local_web_mode() -> bool:
return OS.has_feature("web") and bool(ProjectSettings.get_setting("application/custom/local_web_mode", false))
func _ready() -> void:
var config: ConfigFile = ConfigFile.new()
var load_error: Error = config.load(CONFIG_PATH)
@@ -84,6 +88,8 @@ func _save_environment_config() -> void:
func first_login_student() -> void:
if is_local_web_mode():
return
await _post_json_request("submit_student_session", {"student_id": UserDataManager.student})
@@ -185,6 +191,8 @@ func reset_language(language: String) -> Dictionary:
#region Sender functions
func check_internet_access() -> bool:
if is_local_web_mode():
return false
Log.trace("ServerManager: Sending simple request to " + INTERNET_CHECK_URL + " to check if internet is available")
var res: Error = internet_check.request(INTERNET_CHECK_URL)
if res == OK:
@@ -107,6 +107,8 @@ func _process(_delta: float) -> void:
#region synchronization
func start_synchronization_timer() -> void:
if (ServerManager as ServerManagerClass).is_local_web_mode():
return
if not synchronization_timer_running:
synchronization_timer = 0
synchronization_timer_running = true
@@ -117,6 +119,52 @@ func stop_synchronization_timer() -> void:
synchronization_timer_running = false
Log.trace("UserDataManager: Synchronization timer stopped")
func ensure_joy_local_profile() -> void:
if not (ServerManager as ServerManagerClass).is_local_web_mode():
return
var settings: DeviceSettings = get_device_settings()
settings.language = "fr_FR"
settings.teacher = "profil-local"
settings.device_id = 1
_save_device_settings()
var teacher_path: String = get_teacher_settings_path()
if FileAccess.file_exists(teacher_path):
_load_teacher_settings()
if not teacher_settings:
teacher_settings = TeacherSettings.new()
teacher_settings.account_type = TeacherSettings.AccountType.PARENT
teacher_settings.education_method = TeacherSettings.EducationMethod.APP_ONLY
teacher_settings.email = "profil-local"
teacher_settings.token = "local-only"
teacher_settings.language = "fr_FR"
teacher_settings.server_language_validated = true
var joy: StudentData = StudentData.new()
joy.code = 123
joy.name = "Joy"
joy.age = 5
joy.level = StudentData.Level.BEGINNER
var joy_students: Array[StudentData] = [joy]
teacher_settings.students[1] = joy_students
DirAccess.make_dir_recursive_absolute(get_teacher_folder())
save_teacher_settings()
else:
teacher_settings.language = "fr_FR"
teacher_settings.server_language_validated = true
if not teacher_settings.students.has(1) or (teacher_settings.students[1] as Array).is_empty():
var joy: StudentData = StudentData.new()
joy.code = 123
joy.name = "Joy"
joy.age = 5
joy.level = StudentData.Level.BEGINNER
var joy_students: Array[StudentData] = [joy]
teacher_settings.students[1] = joy_students
save_teacher_settings()
TranslationServer.set_locale("fr_FR")
#endregion
#region Registering and logging in