diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9ec26da2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.github +.godot +dist +addons/godot-sqlite/bin +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..224b8e16 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.fr.md b/README.fr.md index 02b300d4..ffa779cb 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..3d7cda1d --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/export_presets.cfg b/export_presets.cfg index 93145521..314bdd0e 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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="" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..52b67fda --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/project.godot b/project.godot index 8fee4e35..2c0353ee 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/scripts/verify-live.sh b/scripts/verify-live.sh new file mode 100755 index 00000000..cd3ced6a --- /dev/null +++ b/scripts/verify-live.sh @@ -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" diff --git a/sources/menus/language_selection/language_check.gd b/sources/menus/language_selection/language_check.gd index a7b599f4..94fe9c41 100644 --- a/sources/menus/language_selection/language_check.gd +++ b/sources/menus/language_selection/language_check.gd @@ -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) diff --git a/sources/menus/language_selection/package_downloader.gd b/sources/menus/language_selection/package_downloader.gd index c2c5dabf..81f42213 100644 --- a/sources/menus/language_selection/package_downloader.gd +++ b/sources/menus/language_selection/package_downloader.gd @@ -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", @@ -63,6 +66,12 @@ func _ready() -> void: Log.trace("PackageDownloader: Using language from teacherSettings: %s" % language) 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(): @@ -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) @@ -255,6 +268,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: diff --git a/sources/menus/main/main_menu.gd b/sources/menus/main/main_menu.gd index b340118e..0b50e657 100644 --- a/sources/menus/main/main_menu.gd +++ b/sources/menus/main/main_menu.gd @@ -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() diff --git a/sources/utils/autoloads/database.gd b/sources/utils/autoloads/database.gd index 224f5ad7..983672f3 100644 --- a/sources/utils/autoloads/database.gd +++ b/sources/utils/autoloads/database.gd @@ -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: - db.close_db() + 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() diff --git a/sources/utils/autoloads/server_manager.gd b/sources/utils/autoloads/server_manager.gd index b98b0f99..ae52b21b 100644 --- a/sources/utils/autoloads/server_manager.gd +++ b/sources/utils/autoloads/server_manager.gd @@ -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: diff --git a/sources/utils/autoloads/user_data_manager.gd b/sources/utils/autoloads/user_data_manager.gd index ce9b9838..7e36584d 100644 --- a/sources/utils/autoloads/user_data_manager.gd +++ b/sources/utils/autoloads/user_data_manager.gd @@ -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