feat: deploy autonomous French web edition for Joy
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
.godot
|
||||
dist
|
||||
addons/godot-sqlite/bin
|
||||
*.log
|
||||
.DS_Store
|
||||
+66
@@ -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
@@ -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 n’utilise pas l’API 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 d’application 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.
|
||||
|
||||
L’image construit l’export avec Godot 4.6.3, les modèles d’export 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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
|
||||
Executable
+14
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user