diff --git a/.github/workflows/docs-and-repos.yml b/.github/workflows/docs-and-repos.yml
index d50c61619..f93c046b6 100644
--- a/.github/workflows/docs-and-repos.yml
+++ b/.github/workflows/docs-and-repos.yml
@@ -13,9 +13,7 @@ on:
workflow_dispatch: # Allows manually triggering
permissions:
- contents: read
- pages: write
- id-token: write
+ contents: write
jobs:
build-and-deploy:
@@ -27,14 +25,12 @@ jobs:
# ---- Build MkDocs docs ----
- name: Setup Python
- uses: actions/setup-python@v6
+ uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install MkDocs
- run: |
- python -m pip install --upgrade pip
- pip install mkdocs mkdocs-material
+ run: pip install --upgrade pip mkdocs mkdocs-material
- name: Build docs
run: |
@@ -275,15 +271,14 @@ jobs:
[ -d site/rpm ] && generate_index "site/rpm" "rpm" || true
# ---- Deploy to GitHub Pages ----
- - name: Upload Pages artifact
- uses: actions/upload-pages-artifact@v4
- with:
- path: site
-
- name: Deploy to GitHub Pages
if: github.repository == 'exelearning/exelearning'
- id: deployment
- uses: actions/deploy-pages@v4
+ uses: JamesIves/github-pages-deploy-action@v4
+ with:
+ folder: site
+ branch: gh-pages
+ clean-exclude: pr-preview
+ force: false
publish-chocolatey:
if: github.event_name == 'release' && github.event.action == 'released'
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
new file mode 100644
index 000000000..29565509f
--- /dev/null
+++ b/.github/workflows/pr-preview.yml
@@ -0,0 +1,53 @@
+name: Deploy PR Preview
+
+on:
+ pull_request:
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - closed
+
+concurrency: preview-${{ github.ref }}
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ deploy-preview:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup Bun
+ if: github.event.action != 'closed'
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ if: github.event.action != 'closed'
+ run: bun install
+
+ - name: Build static distribution
+ if: github.event.action != 'closed'
+ run: bun run build:static
+
+ - name: Deploy preview
+ id: deploy
+ uses: rossjrw/pr-preview-action@v1
+ with:
+ source-dir: ./dist/static/
+ preview-branch: gh-pages
+ umbrella-dir: pr-preview
+ action: auto
+ qr-code: true
+
+ - name: Add preview URL to summary
+ if: github.event.action != 'closed'
+ run: |
+ echo "## 🚀 PR Preview Deployed" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Preview URL:** ${{ steps.deploy.outputs.deployment-url }}" >> $GITHUB_STEP_SUMMARY
diff --git a/Makefile b/Makefile
index a14f90f1b..58960fdbc 100644
--- a/Makefile
+++ b/Makefile
@@ -156,12 +156,33 @@ else
bun run dev:local
endif
-# Start full app: Elysia backend + Electron
+# Start full app: Static files + Electron (no server needed)
.PHONY: run-app
run-app: check-bun deps css bundle
- @echo "Launching eXeLearning App (Electron + Elysia)..."
- @bun run build:standalone
- @bun run dev:app
+ @echo "Building static files..."
+ @bun scripts/build-static-bundle.ts
+ @echo "Launching eXeLearning App (Electron)..."
+ @bun run electron
+
+# Build static distribution (PWA mode, no server required)
+# Usage: make build-static
+.PHONY: build-static
+build-static: check-bun deps css bundle
+ @echo "Building static distribution..."
+ @bun run build:static
+ @echo "Static distribution built at dist/static/"
+
+# Build static distribution and serve it
+# Usage: make up-static [PORT=8080]
+.PHONY: up-static
+up-static: build-static
+ @echo ""
+ @echo "============================================================"
+ @echo " Serving static distribution at http://localhost:$${PORT:-8080}"
+ @echo " Press Ctrl+C to stop"
+ @echo "============================================================"
+ @echo ""
+ @bunx serve dist/static -p $${PORT:-8080}
# =============================================================================
@@ -532,6 +553,13 @@ test-e2e-ui: check-env ## Run Playwright E2E tests with UI
test-e2e-firefox: check-env ## Run Playwright E2E tests with Firefox
bunx playwright test --project=firefox
+.PHONY: test-e2e-static
+test-e2e-static: check-env ## Run Playwright E2E tests with static bundle (no server)
+ PLAYWRIGHT_PROJECT=chromium-static bunx playwright test --project=chromium-static
+
+.PHONY: test-e2e-all
+test-e2e-all: test-e2e test-e2e-static ## Run E2E tests for both server and static modes
+
# =============================================================================
# DATABASE-SPECIFIC E2E TESTS
@@ -807,7 +835,10 @@ help:
@echo "Local:"
@echo " make up-local Start locally (web only, dev mode)"
@echo " make up-local APP_ENV=prod Start locally (web only, prod mode)"
- @echo " make run-app Start Electron + backend (desktop app)"
+ @echo " make build-static Build static distribution (PWA mode)"
+ @echo " make up-static Build and serve static distribution (PWA mode)"
+ @echo " make up-static PORT=3000 Same, but on custom port"
+ @echo " make run-app Start Electron app (static mode, no server)"
@echo " make bundle Build all assets (TS + CSS + JS bundle)"
@echo " make deps Install dependencies"
@echo ""
@@ -839,6 +870,8 @@ help:
@echo " make test-e2e Run Playwright E2E tests (Chromium)"
@echo " make test-e2e-chromium Run E2E tests with Chromium"
@echo " make test-e2e-firefox Run E2E tests with Firefox"
+ @echo " make test-e2e-static Run E2E tests with static bundle (no server)"
+ @echo " make test-e2e-all Run E2E tests for both server and static modes"
@echo ""
@echo "Legacy (Core2 Duo / No Bun):"
@echo " make up-legacy Start legacy server with Node.js (Docker)"
diff --git a/assets/styles/main.scss b/assets/styles/main.scss
index 96ecc4774..e5bb85a85 100644
--- a/assets/styles/main.scss
+++ b/assets/styles/main.scss
@@ -39,9 +39,26 @@ body[mode="advanced"] .exe-simplified {
display: none !important;
}
-/* eXe Mode */
-body[installation-type="offline"] .exe-online,
-body[installation-type="online"] .exe-offline {
+/* eXe Mode - installation type visibility */
+/* Online mode: hide offline and electron elements, show online elements */
+body[installation-type="online"] .exe-offline,
+body[installation-type="online"] .exe-electron {
+ display: none !important;
+}
+
+/* Static mode: hide online and electron elements, show offline elements (exe logo) */
+body[installation-type="static"] .exe-online,
+body[installation-type="static"] .exe-electron {
+ display: none !important;
+}
+
+/* Electron mode: hide online elements, show offline and electron elements */
+body[installation-type="electron"] .exe-online {
+ display: none !important;
+}
+
+/* Legacy support: "offline" value maps to electron behavior */
+body[installation-type="offline"] .exe-online {
display: none !important;
}
diff --git a/doc/architecture.md b/doc/architecture.md
index 1a573d437..353c62650 100644
--- a/doc/architecture.md
+++ b/doc/architecture.md
@@ -532,6 +532,99 @@ ws.on('open', async () => {
});
```
+## 13. Theme Architecture
+
+### 13.1 Theme Types
+
+eXeLearning supports three types of themes:
+
+| Type | Source | Storage | Served By |
+|------|--------|---------|-----------|
+| **Base** | Built-in with eXeLearning | Server `/perm/themes/base/` | Server |
+| **Site** | Admin-installed for all users | Server `/perm/themes/site/` | Server |
+| **User** | Imported by user or from .elpx | Client IndexedDB + Yjs | **Never server** |
+
+### 13.2 Server Themes (Base & Site)
+
+**Base themes** are included with eXeLearning and synchronized at startup:
+- Located in `/public/files/perm/themes/base/`
+- Cannot be modified by users
+- Served directly by the server
+
+**Site themes** are installed by administrators for all users:
+- Located in `/perm/themes/site/`
+- Admin can activate/deactivate themes
+- Admin can set a default theme for new projects
+- Served directly by the server
+
+### 13.3 User Themes (Client-Side Only)
+
+> **Important**: User themes are NEVER stored or served by the server.
+
+User themes are stored entirely on the client side:
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ USER THEME STORAGE │
+├─────────────────────────────────────────────────────────────────────┤
+│ │
+│ IndexedDB (per-user isolation) │
+│ └── user-themes store: key = "userId:themeName" │
+│ └── Each user's themes isolated by userId prefix │
+│ └── User "alice" cannot see user "bob"'s themes │
+│ │
+│ Yjs themeFiles (project document) │
+│ └── Currently selected user theme (for collaboration/export) │
+│ │
+│ .elpx export │
+│ └── Embedded theme files (for portability) │
+│ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### 13.4 User Theme Flow
+
+```
+1. IMPORT THEME
+ User uploads ZIP → Stored in IndexedDB (local storage)
+
+2. SELECT THEME
+ User selects theme → Copied to Yjs themeFiles
+ (enables collaboration and export)
+
+3. CHANGE TO ANOTHER THEME
+ User selects different theme → Removed from Yjs
+ (but remains in IndexedDB for future use)
+
+4. EXPORT PROJECT (.elpx)
+ If user theme selected → Embedded in ZIP
+
+5. OPEN PROJECT WITH EMBEDDED THEME
+ Another user opens .elpx → Theme extracted to their IndexedDB
+ (if ONLINE_THEMES_INSTALL is enabled)
+```
+
+### 13.5 Admin Configuration
+
+```bash
+# Allow users to import/install styles
+ONLINE_THEMES_INSTALL=1 # 1 = enabled (default), 0 = disabled
+```
+
+When disabled (`ONLINE_THEMES_INSTALL=0`):
+- Users cannot import external themes via the interface
+- Users cannot open .elpx files with embedded themes
+
+### 13.6 Why User Themes Are Client-Side
+
+This design follows the same pattern as other user-specific data (like favorite iDevices):
+
+1. **Per-user storage**: Each user's themes are private to them
+2. **No server storage**: Themes don't consume server disk space
+3. **Collaboration via Yjs**: Selected theme is shared with collaborators in real-time
+4. **Portability**: Themes embedded in .elpx can be opened anywhere
+5. **Offline capability**: Themes work without server connectivity
+
---
## Further Reading
@@ -539,4 +632,5 @@ ws.on('open', async () => {
- [Real-Time Collaboration](development/real-time.md) - WebSocket and Yjs details
- [REST API](development/rest-api.md) - API endpoints
- [Testing](development/testing.md) - Test patterns and coverage
+- [Creating Styles](development/styles.md) - How to create custom themes
diff --git a/doc/development/styles.md b/doc/development/styles.md
index af785f7a9..b7d3a90a0 100644
--- a/doc/development/styles.md
+++ b/doc/development/styles.md
@@ -169,22 +169,42 @@ Common functionality found in built-in eXe styles:
---
+## Theme Types
+
+eXeLearning has three types of themes:
+
+| Type | Source | Storage | Served By |
+|------|--------|---------|-----------|
+| **Base** | Built-in with eXeLearning | Server `/perm/themes/base/` | Server |
+| **Site** | Admin-installed for all users | Server `/perm/themes/site/` | Server |
+| **User** | Imported by user or from .elpx | Client IndexedDB + Yjs | **Never server** |
+
+---
+
## Deployment Information
+### Base themes (built-in)
+
The styles included by default in eXeLearning are located in:
```
/public/files/perm/themes/base/
```
-If you are managing an online instance of eXeLearning, place the folder containing your new styles there and restart the service.
+These are synchronized at server startup and cannot be modified by users.
-User-installed styles (both in the online version, if allowed by the administrator, and in the desktop version) are stored, for each user, in:
+### Site themes (admin-installed)
+
+Administrators can install themes for all users by placing them in:
```
-/public/files/perm/themes/users/
+/perm/themes/site/
```
+Site themes can be:
+- Activated/deactivated by the administrator
+- Set as the default theme for new projects
+
### Using custom styles with Docker
To bind a custom style directly in `docker-compose.yml`, add the following volume:
@@ -200,14 +220,56 @@ This makes the style available to **all users**.
This is required because eXeLearning recreates the entire `/base/` themes directory when restarting the server. Any style not bound as a volume would be overwritten during this process.
-### User styles
+---
+
+## User Styles (Client-Side)
+
+> **Important**: User themes are NEVER stored or served by the server.
-User styles are those imported through the application interface (**Styles → Imported**).
+User styles are imported through the application interface (**Styles → Imported**) and stored entirely on the client side.
-Their final location on disk is:
+### Storage locations
```
-/public/files/perm/themes/users/user
+IndexedDB (browser, per-user)
+└── user-themes store: key = "userId:themeName"
+ └── Each user's themes are isolated by userId prefix
+ └── Switching users shows only that user's themes
+
+Yjs themeFiles (project document)
+└── Currently selected user theme (for collaboration/export)
+
+.elpx export
+└── Embedded theme files (for portability)
```
-These styles are user-specific and are not affected by the regeneration of the base themes directory.
+**Per-user isolation**: When user "alice" logs in, she only sees her themes. If "bob" logs in on the same browser, he sees his own themes, not Alice's. This is achieved by storing themes with a composite key `userId:themeName` in IndexedDB.
+
+### How user themes work
+
+1. **Import**: User uploads ZIP → Stored in IndexedDB (local browser storage)
+2. **Select**: User selects theme → Copied to Yjs `themeFiles` (for collaboration/export)
+3. **Change**: User selects different theme → Removed from Yjs (but kept in IndexedDB)
+4. **Export**: If user theme is selected → Embedded in .elpx ZIP
+5. **Open**: Another user opens .elpx → Theme extracted to their IndexedDB
+
+### Admin configuration
+
+```bash
+# Allow users to import/install styles
+ONLINE_THEMES_INSTALL=1 # 1 = enabled (default), 0 = disabled
+```
+
+When disabled (`ONLINE_THEMES_INSTALL=0`):
+- Users **cannot** import external themes via the interface
+- Users **cannot** open .elpx files with embedded themes
+
+### Why user themes are client-side
+
+This design follows the same pattern as other user-specific data (like favorite iDevices):
+
+1. **Per-user storage**: Each user's themes are private to them
+2. **No server storage**: Themes don't consume server disk space
+3. **Collaboration via Yjs**: Selected theme is shared with collaborators in real-time
+4. **Portability**: Themes embedded in .elpx can be opened anywhere
+5. **Offline capability**: Themes work without server connectivity
diff --git a/main.js b/main.js
index 81bd70be2..fd1a2084c 100644
--- a/main.js
+++ b/main.js
@@ -1,20 +1,48 @@
-const { app, BrowserWindow, dialog, session, ipcMain, Menu, systemPreferences, shell } = require('electron');
+const { app, BrowserWindow, dialog, session, ipcMain, Menu, systemPreferences, shell, protocol, net } = require('electron');
const { autoUpdater } = require('electron-updater');
const log = require('electron-log');
const path = require('path');
const i18n = require('i18n');
-const { spawn, execFileSync } = require('child_process');
const fs = require('fs');
const fflate = require('fflate');
-const http = require('http'); // Import the http module to check server availability and downloads
const https = require('https');
+const { pathToFileURL } = require('url');
const { initAutoUpdater } = require('./update-manager');
+// Register exe:// protocol as privileged (must be done before app ready)
+// This allows the protocol to:
+// - Be treated as secure origin (like https)
+// - Support fetch, XHR, and other web APIs
+// - Bypass CORS restrictions for local files
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: 'exe',
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ stream: true,
+ },
+ },
+]);
+
// Determine the base path depending on whether the app is packaged when we enable "asar" packaging
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath();
+/**
+ * Get the path to the static files directory.
+ * In packaged mode, static files are in extraResources/static/.
+ * In dev mode, static files are in dist/static/.
+ */
+function getStaticPath() {
+ return app.isPackaged
+ ? path.join(process.resourcesPath, 'static')
+ : path.join(__dirname, 'dist', 'static');
+}
+
// Optional: force a predictable path/name
log.transports.file.resolvePathFn = () => path.join(app.getPath('userData'), 'logs', 'main.log');
@@ -65,14 +93,9 @@ i18n.configure({
i18n.setLocale(defaultLocale);
let appDataPath;
-let databasePath;
-
-let databaseUrl;
let mainWindow;
-let loadingWindow;
let isShuttingDown = false; // Flag to ensure the app only shuts down once
-let serverProcess = null; // Elysia server process handle
let updaterInited = false; // guard
// Environment variables container
@@ -288,44 +311,21 @@ function ensureAllDirectoriesWritable(env) {
function initializePaths() {
appDataPath = app.getPath('userData');
- databasePath = path.join(appDataPath, 'exelearning.db');
-
console.log(`APP data path: ${appDataPath}`);
- console.log('Database path:', databasePath);
}
// Define environment variables after initializing paths
+// Note: In static mode, we only need directory paths for cache/cleanup
function initializeEnv() {
const isDev = determineDevMode();
const appEnv = isDev ? 'dev' : 'prod';
- // For Electron mode, use port 3001 for local development
- const serverPort = '3001';
- // Get the appropriate app data path based on platform
customEnv = {
APP_ENV: process.env.APP_ENV || appEnv,
APP_DEBUG: process.env.APP_DEBUG ?? (isDev ? 1 : 0),
EXELEARNING_DEBUG_MODE: (process.env.EXELEARNING_DEBUG_MODE ?? (isDev ? '1' : '0')).toString(),
- APP_SECRET: process.env.APP_SECRET || 'CHANGE_THIS_FOR_A_SECRET',
- APP_PORT: serverPort,
- APP_ONLINE_MODE: process.env.APP_ONLINE_MODE ?? '0',
- APP_AUTH_METHODS: process.env.APP_AUTH_METHODS || 'none',
- TEST_USER_EMAIL: process.env.TEST_USER_EMAIL || 'user@exelearning.net',
- TEST_USER_USERNAME: process.env.TEST_USER_USERNAME || 'user',
- TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD || '1234',
- TRUSTED_PROXIES: process.env.TRUSTED_PROXIES || '',
- MAILER_DSN: process.env.MAILER_DSN || 'smtp://localhost',
- CAS_URL: process.env.CAS_URL || '',
- DB_DRIVER: process.env.DB_DRIVER || 'pdo_sqlite',
- DB_CHARSET: process.env.DB_CHARSET || 'utf8',
- DB_PATH: process.env.DB_PATH || databasePath,
- DB_SERVER_VERSION: process.env.DB_SERVER_VERSION || '3.32',
FILES_DIR: path.join(appDataPath, 'data'),
CACHE_DIR: path.join(appDataPath, 'cache'),
LOG_DIR: path.join(appDataPath, 'log'),
- API_JWT_SECRET: process.env.API_JWT_SECRET || 'CHANGE_THIS_FOR_A_SECRET',
- ONLINE_THEMES_INSTALL: 1,
- ONLINE_IDEVICES_INSTALL: 0, // To do (see #381)
- BASE_PATH: process.env.BASE_PATH || '/',
};
}
/**
@@ -372,13 +372,6 @@ function applyCombinedEnvToProcess() {
Object.assign(process.env, env || {});
}
-function getServerPort() {
- try {
- return Number(customEnv?.APP_PORT || process.env.APP_PORT || 3001);
- } catch (_e) {
- return 3001;
- }
-}
// Detecta si una URL es externa (debe abrirse en navegador del sistema)
function isExternalUrl(url) {
@@ -483,310 +476,263 @@ function createWindow() {
// Ensure all required directories exist and try to set permissions
ensureAllDirectoriesWritable(env);
- // Create the loading window
- createLoadingWindow();
+ // Register exe:// protocol handler to serve static files
+ // This allows the app to load files with proper origin (exe://static)
+ // which enables fetch, CORS, and blob URL resolution in previews
+ const staticDir = getStaticPath();
+ protocol.handle('exe', (request) => {
+ // Parse the URL: exe://./path/to/file -> staticDir/path/to/file
+ const url = new URL(request.url);
+ // Remove leading ./ or / from pathname
+ let filePath = url.pathname.replace(/^\/+/, '');
+ const fullPath = path.join(staticDir, filePath);
+
+ // Security: ensure the path is within staticDir
+ const normalizedPath = path.normalize(fullPath);
+ if (!normalizedPath.startsWith(staticDir)) {
+ return new Response('Forbidden', { status: 403 });
+ }
+
+ // Use net.fetch to serve the file (handles MIME types automatically)
+ return net.fetch(pathToFileURL(normalizedPath).href);
+ });
+ console.log('Registered exe:// protocol handler for:', staticDir);
- // Start the Elysia server only in production (in dev, assume it's already running)
const isDev = determineDevMode();
- if (!isDev) {
- startElysiaServer();
- } else {
- console.log('Development mode: skipping server startup (assuming external server running)');
- }
- // Wait for the server to be available before loading the main window
- waitForServer(() => {
- // Close the loading window
- if (loadingWindow) {
- loadingWindow.close();
- }
+ // Create the main window (no server needed - load static files directly)
+ mainWindow = new BrowserWindow({
+ width: 1250,
+ height: 800,
+ autoHideMenuBar: !isDev, // Windows / Linux
+ webPreferences: {
+ nodeIntegration: false,
+ contextIsolation: true,
+ preload: path.join(__dirname, 'preload.js'),
+ },
+ tabbingIdentifier: 'mainGroup',
+ show: true,
+ });
- const isDev = determineDevMode();
+ // Show the menu bar in development mode, hide it in production
+ mainWindow.setMenuBarVisibility(isDev);
- // Create the main window
- mainWindow = new BrowserWindow({
- width: 1250,
- height: 800,
- autoHideMenuBar: !isDev, // Windows / Linux
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- preload: path.join(__dirname, 'preload.js'),
- },
- tabbingIdentifier: 'mainGroup',
- show: true,
- // titleBarStyle: 'customButtonsOnHover', // hidden title bar on macOS
- });
+ // Maximize the window and open it
+ mainWindow.maximize();
+ mainWindow.show();
- // Show the menu bar in development mode, hide it in production
- mainWindow.setMenuBarVisibility(isDev);
+ // macOS: Show tab bar after window is visible
+ if (process.platform === 'darwin' && typeof mainWindow.toggleTabBar === 'function') {
+ // Small delay to ensure window is fully rendered
+ setTimeout(() => {
+ try {
+ mainWindow.toggleTabBar();
+ } catch (e) {
+ console.warn('Could not toggle tab bar:', e.message);
+ }
+ }, 100);
+ }
- // Maximize the window and open it
- mainWindow.maximize();
+ if (process.env.CI === '1' || process.env.CI === 'true') {
+ mainWindow.setAlwaysOnTop(true, 'screen-saver');
mainWindow.show();
+ mainWindow.focus();
+ setTimeout(() => mainWindow.setAlwaysOnTop(false), 2500);
+ }
- // macOS: Show tab bar after window is visible
- if (process.platform === 'darwin' && typeof mainWindow.toggleTabBar === 'function') {
- // Small delay to ensure window is fully rendered
- setTimeout(() => {
- try {
- mainWindow.toggleTabBar();
- } catch (e) {
- console.warn('Could not toggle tab bar:', e.message);
- }
- }, 100);
- }
+ // Allow the child windows to be created and ensure proper closing behavior
+ mainWindow.webContents.on('did-create-window', childWindow => {
+ console.log('Child window created');
- if (process.env.CI === '1' || process.env.CI === 'true') {
- mainWindow.setAlwaysOnTop(true, 'screen-saver');
- mainWindow.show();
- mainWindow.focus();
- setTimeout(() => mainWindow.setAlwaysOnTop(false), 2500);
- }
+ // Adjust child window position slightly offset from the main window
+ const [mainWindowX, mainWindowY] = mainWindow.getPosition();
+ const x = mainWindowX + 10;
+ const y = mainWindowY + 10;
+ childWindow.setPosition(x, y);
- // Allow the child windows to be created and ensure proper closing behavior
- mainWindow.webContents.on('did-create-window', childWindow => {
- console.log('Child window created');
-
- // Adjust child window position slightly offset from the main window
- const [mainWindowX, mainWindowY] = mainWindow.getPosition();
- const x = mainWindowX + 10;
- const y = mainWindowY + 10;
- childWindow.setPosition(x, y);
-
- // Remove preventDefault if you want the window to close when clicking the X button
- childWindow.on('close', () => {
- // Optional: Add any cleanup actions here if necessary
- console.log('Child window closed');
- childWindow.destroy();
- });
+ // Remove preventDefault if you want the window to close when clicking the X button
+ childWindow.on('close', () => {
+ // Optional: Add any cleanup actions here if necessary
+ console.log('Child window closed');
+ childWindow.destroy();
});
+ });
- mainWindow.loadURL(`http://localhost:${getServerPort()}`);
-
- // Check for updates and flush pending files
- mainWindow.webContents.on('did-finish-load', () => {
- // Flush pending files (opened via double-click or command line)
- // Delay to allow frontend JS to initialize and register IPC handlers
- if (pendingOpenFiles.length > 0) {
- const filesToOpen = [...pendingOpenFiles];
- pendingOpenFiles = [];
- console.log(`Flushing ${filesToOpen.length} pending file(s) to open:`, filesToOpen);
-
- setTimeout(() => {
- if (mainWindow && !mainWindow.isDestroyed()) {
- // Open first file in main window
- const firstFile = filesToOpen.shift();
- if (firstFile) {
- console.log('[main] Sending file to main window:', firstFile);
- mainWindow.webContents.send('app:open-file', firstFile);
- }
- // Open remaining files in new windows/tabs
- for (const filePath of filesToOpen) {
- console.log('[main] Creating new window for file:', filePath);
- createNewProjectWindow(filePath);
- }
- }
- }, 1500); // Wait for frontend to fully initialize
- }
+ // Load static HTML via exe:// protocol (enables proper origin for fetch/CORS)
+ mainWindow.loadURL('exe://./index.html');
- if (!updaterInited) {
- try {
- const updater = initAutoUpdater({ mainWindow, autoUpdater, logger: log, streamToFile });
- // Init updater once
- updaterInited = true;
- void updater.checkForUpdatesAndNotify().catch(err => log.warn('update check failed', err));
- } catch (e) {
- log.warn?.('Failed to init updater after load', e);
- }
- }
- });
+ // Check for updates and flush pending files
+ mainWindow.webContents.on('did-finish-load', () => {
+ // Flush pending files (opened via double-click or command line)
+ // Delay to allow frontend JS to initialize and register IPC handlers
+ if (pendingOpenFiles.length > 0) {
+ const filesToOpen = [...pendingOpenFiles];
+ pendingOpenFiles = [];
+ console.log(`Flushing ${filesToOpen.length} pending file(s) to open:`, filesToOpen);
- // Intercept downloads: first time ask path, then overwrite same path
- session.defaultSession.on('will-download', async (event, item, webContents) => {
- try {
- // Use the filename from the request or our override
- const wc =
- webContents && !webContents.isDestroyed?.()
- ? webContents
- : mainWindow
- ? mainWindow.webContents
- : null;
- const wcId = wc && !wc.isDestroyed?.() ? wc.id : null;
- // Deduplicate same-URL downloads triggered within a short window
- try {
- const url = typeof item.getURL === 'function' ? item.getURL() : undefined;
- if (wcId && url) {
- const now = Date.now();
- const last = lastDownloadByWC.get(wcId);
- if (last && last.url === url && now - last.time < 1500) {
- // Cancel duplicate download attempt
- event.preventDefault();
- return;
- }
- lastDownloadByWC.set(wcId, { url, time: now });
+ setTimeout(() => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ // Open first file in main window
+ const firstFile = filesToOpen.shift();
+ if (firstFile) {
+ console.log('[main] Sending file to main window:', firstFile);
+ mainWindow.webContents.send('app:open-file', firstFile);
}
- } catch (_e) {}
- const overrideName = wcId ? nextDownloadNameByWC.get(wcId) : null;
- if (wcId && nextDownloadNameByWC.has(wcId)) nextDownloadNameByWC.delete(wcId);
- const suggestedName = overrideName || item.getFilename() || 'document.elpx';
- // Determine a safe target WebContents (can be null in some cases)
- // Allow renderer to define a project key (optional)
- let projectKey = 'default';
- if (wcId && nextDownloadKeyByWC.has(wcId)) {
- projectKey = nextDownloadKeyByWC.get(wcId) || 'default';
- nextDownloadKeyByWC.delete(wcId);
- } else if (wc) {
- try {
- projectKey = await wc.executeJavaScript('window.__currentProjectId || "default"', true);
- } catch (_e) {
- // ignore, fallback to default
+ // Open remaining files in new windows/tabs
+ for (const filePath of filesToOpen) {
+ console.log('[main] Creating new window for file:', filePath);
+ createNewProjectWindow(filePath);
}
}
+ }, 1500); // Wait for frontend to fully initialize
+ }
- let targetPath = getSavedPath(projectKey);
-
- if (!targetPath) {
- const owner = wc ? BrowserWindow.fromWebContents(wc) : mainWindow;
- const { filePath, canceled } = await dialog.showSaveDialog(owner, {
- title: tOrDefault(
- 'save.dialogTitle',
- defaultLocale === 'es' ? 'Guardar proyecto' : 'Save project',
- ),
- defaultPath: suggestedName,
- buttonLabel: tOrDefault('save.button', defaultLocale === 'es' ? 'Guardar' : 'Save'),
- });
- if (canceled || !filePath) {
+ if (!updaterInited) {
+ try {
+ const updater = initAutoUpdater({ mainWindow, autoUpdater, logger: log, streamToFile });
+ // Init updater once
+ updaterInited = true;
+ void updater.checkForUpdatesAndNotify().catch(err => log.warn('update check failed', err));
+ } catch (e) {
+ log.warn?.('Failed to init updater after load', e);
+ }
+ }
+ });
+
+ // Intercept downloads: first time ask path, then overwrite same path
+ session.defaultSession.on('will-download', async (event, item, webContents) => {
+ try {
+ // Use the filename from the request or our override
+ const wc =
+ webContents && !webContents.isDestroyed?.()
+ ? webContents
+ : mainWindow
+ ? mainWindow.webContents
+ : null;
+ const wcId = wc && !wc.isDestroyed?.() ? wc.id : null;
+ // Deduplicate same-URL downloads triggered within a short window
+ try {
+ const url = typeof item.getURL === 'function' ? item.getURL() : undefined;
+ if (wcId && url) {
+ const now = Date.now();
+ const last = lastDownloadByWC.get(wcId);
+ if (last && last.url === url && now - last.time < 1500) {
+ // Cancel duplicate download attempt
event.preventDefault();
return;
}
- targetPath = ensureExt(filePath, suggestedName);
- setSavedPath(projectKey, targetPath);
- } else {
- // If remembered path has no extension, append inferred one
- const fixed = ensureExt(targetPath, suggestedName);
- if (fixed !== targetPath) {
- targetPath = fixed;
- setSavedPath(projectKey, targetPath);
- }
+ lastDownloadByWC.set(wcId, { url, time: now });
}
+ } catch (_e) {}
+ const overrideName = wcId ? nextDownloadNameByWC.get(wcId) : null;
+ if (wcId && nextDownloadNameByWC.has(wcId)) nextDownloadNameByWC.delete(wcId);
+ const suggestedName = overrideName || item.getFilename() || 'document.elpx';
+ // Determine a safe target WebContents (can be null in some cases)
+ // Allow renderer to define a project key (optional)
+ let projectKey = 'default';
+ if (wcId && nextDownloadKeyByWC.has(wcId)) {
+ projectKey = nextDownloadKeyByWC.get(wcId) || 'default';
+ nextDownloadKeyByWC.delete(wcId);
+ } else if (wc) {
+ try {
+ projectKey = await wc.executeJavaScript('window.__currentProjectId || "default"', true);
+ } catch (_e) {
+ // ignore, fallback to default
+ }
+ }
- // Save directly (overwrite without prompting)
- item.setSavePath(targetPath);
-
- // Progress feedback and auto-resume on interruption
- item.on('updated', (_e, state) => {
- if (state === 'progressing') {
- if (wc && !wc.isDestroyed?.())
- wc.send('download-progress', {
- received: item.getReceivedBytes(),
- total: item.getTotalBytes(),
- });
- } else if (state === 'interrupted') {
- try {
- if (item.canResume()) item.resume();
- } catch (_err) {}
- }
+ let targetPath = getSavedPath(projectKey);
+
+ if (!targetPath) {
+ const owner = wc ? BrowserWindow.fromWebContents(wc) : mainWindow;
+ const { filePath, canceled } = await dialog.showSaveDialog(owner, {
+ title: tOrDefault(
+ 'save.dialogTitle',
+ defaultLocale === 'es' ? 'Guardar proyecto' : 'Save project',
+ ),
+ defaultPath: suggestedName,
+ buttonLabel: tOrDefault('save.button', defaultLocale === 'es' ? 'Guardar' : 'Save'),
});
-
- item.once('done', (_e, state) => {
- const send = payload => {
- if (wc && !wc.isDestroyed?.()) wc.send('download-done', payload);
- else if (mainWindow && !mainWindow.isDestroyed())
- mainWindow.webContents.send('download-done', payload);
- };
- if (state === 'completed') {
- send({ ok: true, path: targetPath });
- return;
- }
- if (state === 'interrupted') {
- try {
- const total = item.getTotalBytes() || 0;
- const exists = fs.existsSync(targetPath);
- const size = exists ? fs.statSync(targetPath).size : 0;
- if (exists && (total === 0 || size >= total)) {
- send({ ok: true, path: targetPath });
- return;
- }
- } catch (_err) {}
- }
- send({ ok: false, error: state });
- });
- } catch (err) {
- event.preventDefault();
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.webContents.send('download-done', { ok: false, error: err.message });
+ if (canceled || !filePath) {
+ event.preventDefault();
+ return;
+ }
+ targetPath = ensureExt(filePath, suggestedName);
+ setSavedPath(projectKey, targetPath);
+ } else {
+ // If remembered path has no extension, append inferred one
+ const fixed = ensureExt(targetPath, suggestedName);
+ if (fixed !== targetPath) {
+ targetPath = fixed;
+ setSavedPath(projectKey, targetPath);
}
}
- });
- // If any event blocks window closing, remove it
- mainWindow.on('close', e => {
- // This is to ensure any preventDefault() won't stop the closing
- console.log('Window is being forced to close...');
- e.preventDefault(); // Optional: Prevent default close event
- mainWindow.destroy(); // Force destroy the window
- });
+ // Save directly (overwrite without prompting)
+ item.setSavePath(targetPath);
+
+ // Progress feedback and auto-resume on interruption
+ item.on('updated', (_e, state) => {
+ if (state === 'progressing') {
+ if (wc && !wc.isDestroyed?.())
+ wc.send('download-progress', {
+ received: item.getReceivedBytes(),
+ total: item.getTotalBytes(),
+ });
+ } else if (state === 'interrupted') {
+ try {
+ if (item.canResume()) item.resume();
+ } catch (_err) {}
+ }
+ });
- mainWindow.on('closed', () => {
- mainWindow = null;
- });
+ item.once('done', (_e, state) => {
+ const send = payload => {
+ if (wc && !wc.isDestroyed?.()) wc.send('download-done', payload);
+ else if (mainWindow && !mainWindow.isDestroyed())
+ mainWindow.webContents.send('download-done', payload);
+ };
+ if (state === 'completed') {
+ send({ ok: true, path: targetPath });
+ return;
+ }
+ if (state === 'interrupted') {
+ try {
+ const total = item.getTotalBytes() || 0;
+ const exists = fs.existsSync(targetPath);
+ const size = exists ? fs.statSync(targetPath).size : 0;
+ if (exists && (total === 0 || size >= total)) {
+ send({ ok: true, path: targetPath });
+ return;
+ }
+ } catch (_err) {}
+ }
+ send({ ok: false, error: state });
+ });
+ } catch (err) {
+ event.preventDefault();
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send('download-done', { ok: false, error: err.message });
+ }
+ }
+ });
- // Listen for application exit events
- handleAppExit();
+ // If any event blocks window closing, remove it
+ mainWindow.on('close', e => {
+ // This is to ensure any preventDefault() won't stop the closing
+ console.log('Window is being forced to close...');
+ e.preventDefault(); // Optional: Prevent default close event
+ mainWindow.destroy(); // Force destroy the window
});
-}
-function createLoadingWindow() {
- loadingWindow = new BrowserWindow({
- width: 400,
- height: 300,
- frame: false, // No title bar
- transparent: true, // Make the window transparent
- alwaysOnTop: true, // Always on top
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- },
+ mainWindow.on('closed', () => {
+ mainWindow = null;
});
- // Load the loading.html file
- loadingWindow.loadFile(path.join(basePath, 'public', 'loading.html'));
+ // Listen for application exit events
+ handleAppExit();
}
-function waitForServer(callback) {
- // Use the BASE_PATH to check the correct healthcheck endpoint
- // Handle both '/' and '/web/exelearning' style paths
- const rawBasePath = customEnv?.BASE_PATH || '/';
- const urlBasePath = rawBasePath === '/' ? '' : rawBasePath;
- const options = {
- host: 'localhost',
- port: getServerPort(),
- path: `${urlBasePath}/healthcheck`,
- timeout: 1000, // 1-second timeout
- };
-
- const checkServer = () => {
- const req = http.request(options, res => {
- if (res.statusCode >= 200 && res.statusCode <= 400) {
- console.log('Application server available.');
- callback(); // Call the callback to continue opening the window
- } else {
- console.log(`Server status: ${res.statusCode}. Retrying...`);
- setTimeout(checkServer, 1000); // Try again in 1 second
- }
- });
-
- req.on('error', () => {
- console.log('Server not available, retrying...');
- setTimeout(checkServer, 1000); // Try again in 1 second
- });
-
- req.end();
- };
-
- checkServer();
-}
/**
* Stream a URL to a file path using Node http/https, preserving Electron session cookies.
@@ -802,11 +748,14 @@ function streamToFile(downloadUrl, targetPath, wc, redirects = 0) {
return new Promise(async resolve => {
try {
// Resolve absolute URL (support relative paths from renderer)
- let baseOrigin = `http://localhost:${getServerPort() || 80}/`;
+ // In static mode, we only support absolute URLs (https://)
+ let baseOrigin = 'https://localhost/';
try {
if (wc && !wc.isDestroyed?.()) {
const current = wc.getURL?.();
- if (current) baseOrigin = current;
+ if (current && !current.startsWith('file://')) {
+ baseOrigin = current;
+ }
}
} catch (_e) {}
let urlObj;
@@ -815,6 +764,8 @@ function streamToFile(downloadUrl, targetPath, wc, redirects = 0) {
} catch (_e) {
urlObj = new URL(downloadUrl, baseOrigin);
}
+ // Select HTTP or HTTPS client based on URL protocol
+ const http = require('http');
const client = urlObj.protocol === 'https:' ? https : http;
// Build Cookie header from Electron session
let cookieHeader = '';
@@ -1101,7 +1052,7 @@ app.on('new-window-for-tab', () => {
});
newWindow.setMenuBarVisibility(isDev);
- newWindow.loadURL(`http://localhost:${getServerPort()}`);
+ newWindow.loadURL('exe://./index.html');
attachOpenHandler(newWindow);
@@ -1132,13 +1083,6 @@ function handleAppExit() {
if (isShuttingDown) return;
isShuttingDown = true;
- // Kill the server process if it's running
- if (serverProcess && !serverProcess.killed) {
- console.log('Stopping Elysia server...');
- serverProcess.kill('SIGTERM');
- serverProcess = null;
- }
-
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.destroy();
}
@@ -1332,98 +1276,6 @@ ipcMain.handle('app:saveBufferAs', async (e, { base64Data, projectKey, suggested
}
});
-function checkAndCreateDatabase() {
- if (!fs.existsSync(databasePath)) {
- console.log('The database does not exist. Creating the database...');
- // Add code to create the database if necessary
- fs.openSync(databasePath, 'w'); // Allow read and write for all users
- } else {
- console.log('The database already exists.');
- }
-}
-
-/**
- * Starts the Elysia backend as a standalone executable (built with bun build --compile).
- * The server runs as an external process, not in-process.
- */
-function startElysiaServer() {
- try {
- const isWindows = process.platform === 'win32';
- const isLinux = process.platform === 'linux';
- const arch = process.arch; // 'arm64' or 'x64'
-
- // Determine executable name based on platform and architecture
- let execName;
- if (isWindows) {
- execName = 'exelearning-server.exe';
- } else if (isLinux) {
- execName = 'exelearning-server-linux';
- } else {
- // macOS - use architecture-specific executable for universal app support
- execName = arch === 'arm64' ? 'exelearning-server-arm64' : 'exelearning-server-x64';
- }
-
- const candidates = [
- // ExtraResources path (outside asar) - packaged app
- path.join(process.resourcesPath, 'dist', execName),
- // Dev path
- path.join(__dirname, 'dist', execName),
- ];
-
- const serverBinary = candidates.find(p => fs.existsSync(p));
- if (!serverBinary) {
- showErrorDialog('Server executable not found. Run "bun run build:standalone" before packaging.');
- app.quit();
- return;
- }
-
- const port = getServerPort();
- console.log(`Starting Elysia server from ${serverBinary} on port ${port}`);
-
- // Build environment for the server process
- const serverEnv = {
- ...process.env,
- APP_PORT: String(port),
- DB_PATH: customEnv?.DB_PATH || databasePath,
- FILES_DIR: customEnv?.FILES_DIR || path.join(appDataPath, 'data'),
- APP_ONLINE_MODE: '0',
- APP_SECRET: customEnv?.APP_SECRET || 'CHANGE_THIS_FOR_A_SECRET',
- API_JWT_SECRET: customEnv?.API_JWT_SECRET || 'CHANGE_THIS_FOR_A_SECRET',
- APP_VERSION: `v${app.getVersion()}`,
- };
-
- serverProcess = spawn(serverBinary, [], {
- env: serverEnv,
- stdio: ['ignore', 'pipe', 'pipe'],
- cwd: app.isPackaged ? process.resourcesPath : __dirname,
- });
-
- serverProcess.stdout.on('data', data => {
- console.log(`[Server] ${data.toString().trim()}`);
- });
-
- serverProcess.stderr.on('data', data => {
- console.error(`[Server] ${data.toString().trim()}`);
- });
-
- serverProcess.on('error', err => {
- console.error('Failed to start server process:', err);
- showErrorDialog(`Failed to start server: ${err.message}`);
- app.quit();
- });
-
- serverProcess.on('close', code => {
- if (code !== 0 && code !== null && !isShuttingDown) {
- console.error(`Server process exited unexpectedly with code ${code}`);
- }
- serverProcess = null;
- });
- } catch (err) {
- console.error('Error starting Elysia server:', err);
- showErrorDialog(`Error starting server: ${err.message}`);
- app.quit();
- }
-}
/**
* Create a new window for a project file
@@ -1461,7 +1313,7 @@ function createNewProjectWindow(filePath) {
});
newWindow.setMenuBarVisibility(isDev);
- newWindow.loadURL(`http://localhost:${getServerPort()}`);
+ newWindow.loadURL('exe://./index.html');
// macOS: Show tab bar after window is visible
if (process.platform === 'darwin' && typeof newWindow.toggleTabBar === 'function') {
diff --git a/package.json b/package.json
index b79b85de7..567e4ff40 100644
--- a/package.json
+++ b/package.json
@@ -1,308 +1,287 @@
{
- "name": "exelearning",
- "version": "0.0.0-alpha",
- "license": "AGPL-3.0-or-later",
- "description": "eXeLearning 3 is an AGPL-licensed free/libre tool to create and publish open educational resources.",
- "main": "main.js",
- "homepage": "https://exelearning.net",
- "type": "commonjs",
- "scripts": {
- "start": "bun run dist/index.js",
- "start:dev": "bun --watch src/index.ts",
- "start:local": "cross-env FILES_DIR=data/ DB_PATH=data/exelearning.db PORT=8080 APP_ONLINE_MODE=1 bun run start:dev",
- "dev": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:dev' 'bun run sass:watch'",
- "dev:local": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:local' 'bun run sass:watch'",
- "build": "bun build src/index.ts --outdir dist --target bun && bun build src/cli/index.ts --outfile dist/cli.js --target bun",
- "build:standalone": "bun scripts/build-standalone.js",
- "build:all": "bun run build && bun run css:node && bun run bundle:app && bun run bundle:exporters && bun run bundle:resources",
- "css:node": "bunx sass assets/styles/main.scss public/style/workarea/main.css --style=compressed --no-source-map",
- "sass:watch": "bunx sass --watch assets/styles/main.scss:public/style/workarea/main.css --style=expanded --embed-source-map",
- "bundle:app": "bun build public/app/app.js --outfile public/app/app.bundle.js --minify --target browser --format iife",
- "bundle:exporters": "bun scripts/build-exporters-bundle.js",
- "bundle:resources": "bun scripts/build-resource-bundles.js",
- "upload:bundles": "bun scripts/upload-bundle-analysis.js",
- "predev": "bun scripts/setup-local.js",
- "seed": "bun run src/db/seed.ts",
- "cli": "bun run dist/cli.js",
- "convert-elp": "bun run dist/cli.js elp:convert",
- "export-elpx": "bun run dist/cli.js elp:export",
- "export-html5": "bun run dist/cli.js elp:export --format=html5",
- "export-html5-sp": "bun run dist/cli.js elp:export --format=html5-sp",
- "export-scorm12": "bun run dist/cli.js elp:export --format=scorm12",
- "export-scorm2004": "bun run dist/cli.js elp:export --format=scorm2004",
- "export-ims": "bun run dist/cli.js elp:export --format=ims",
- "export-epub3": "bun run dist/cli.js elp:export --format=epub3",
- "test:unit": "bun test ./src ./test/helpers --coverage",
- "test:unit:ci": "bun test ./src ./test/helpers --coverage --coverage-reporter=lcov --coverage-dir=coverage/bun --reporter=junit --reporter-outfile=coverage/bun/junit.xml",
- "test:integration": "bun test ./test/integration",
- "test:frontend": "vitest run --config vitest.config.mts --coverage --reporter=default --reporter=junit --outputFile=coverage/vitest/junit.xml",
- "test:frontend:ui": "vitest --ui --config vitest.config.mts",
- "test:frontend:legacy": "npx vitest run --config vitest.config.mts --coverage",
- "test:e2e": "playwright test",
- "test:e2e:ui": "playwright test --ui",
- "electron": "electron .",
- "electron:dev": "bun scripts/run-electron-dev.js",
- "start:app-backend": "cross-env APP_ONLINE_MODE=0 APP_PORT=3001 PORT=3001 FILES_DIR=data/ DB_PATH=data/exelearning.db bun run start:dev",
- "dev:app": "concurrently --kill-others --names 'BACKEND,ELECTRON' --prefix-colors 'blue,green' 'bun run start:app-backend' 'wait-on http://localhost:3001/healthcheck && bun run electron:dev'",
- "electron:pack": "electron-builder",
- "electron:pack:dir": "electron-builder --dir",
- "package:prepare": "bun run build:all && bun run build:standalone",
- "package:app": "bun run package:prepare && bun run electron:pack",
- "lint:src": "biome check src/",
- "lint:src:fix": "biome check --write src/",
- "lint:test": "biome check test/",
- "lint:test:fix": "biome check --write test/",
- "lint:public": "biome lint public/app/",
- "lint:public:fix": "biome lint --write public/app/",
- "format": "biome format --write src/ test/",
- "format:check": "biome format src/ test/"
+ "name": "exelearning",
+ "version": "0.0.0-alpha",
+ "license": "AGPL-3.0-or-later",
+ "description": "eXeLearning 3 is an AGPL-licensed free/libre tool to create and publish open educational resources.",
+ "main": "main.js",
+ "homepage": "https://exelearning.net",
+ "type": "commonjs",
+ "scripts": {
+ "start": "bun run dist/index.js",
+ "start:dev": "bun --watch src/index.ts",
+ "start:local": "cross-env FILES_DIR=data/ DB_PATH=data/exelearning.db PORT=8080 APP_ONLINE_MODE=1 bun run start:dev",
+ "dev": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:dev' 'bun run sass:watch'",
+ "dev:local": "concurrently --names 'ELYSIA,SCSS' --prefix-colors 'blue,magenta' 'bun run start:local' 'bun run sass:watch'",
+ "build": "bun build src/index.ts --outdir dist --target bun && bun build src/cli/index.ts --outfile dist/cli.js --target bun",
+ "build:standalone": "bun scripts/build-standalone.js",
+ "build:all": "bun run build && bun run css:node && bun run bundle:app && bun run bundle:exporters && bun run bundle:resources",
+ "build:static": "bun run build:all && bun scripts/build-static-bundle.ts",
+ "css:node": "bunx sass assets/styles/main.scss public/style/workarea/main.css --style=compressed --no-source-map",
+ "sass:watch": "bunx sass --watch assets/styles/main.scss:public/style/workarea/main.css --style=expanded --embed-source-map",
+ "bundle:app": "bun build public/app/app.js --outfile public/app/app.bundle.js --minify --target browser --format iife",
+ "bundle:exporters": "bun scripts/build-exporters-bundle.js",
+ "bundle:resources": "bun scripts/build-resource-bundles.js",
+ "upload:bundles": "bun scripts/upload-bundle-analysis.js",
+ "predev": "bun scripts/setup-local.js",
+ "seed": "bun run src/db/seed.ts",
+ "cli": "bun run dist/cli.js",
+ "convert-elp": "bun run dist/cli.js elp:convert",
+ "export-elpx": "bun run dist/cli.js elp:export",
+ "export-html5": "bun run dist/cli.js elp:export --format=html5",
+ "export-html5-sp": "bun run dist/cli.js elp:export --format=html5-sp",
+ "export-scorm12": "bun run dist/cli.js elp:export --format=scorm12",
+ "export-scorm2004": "bun run dist/cli.js elp:export --format=scorm2004",
+ "export-ims": "bun run dist/cli.js elp:export --format=ims",
+ "export-epub3": "bun run dist/cli.js elp:export --format=epub3",
+ "test:unit": "bun test ./src ./test/helpers --coverage",
+ "test:unit:ci": "bun test ./src ./test/helpers --coverage --coverage-reporter=lcov --coverage-dir=coverage/bun --reporter=junit --reporter-outfile=coverage/bun/junit.xml",
+ "test:integration": "bun test ./test/integration",
+ "test:frontend": "vitest run --config vitest.config.mts --coverage --reporter=default --reporter=junit --outputFile=coverage/vitest/junit.xml",
+ "test:frontend:ui": "vitest --ui --config vitest.config.mts",
+ "test:frontend:legacy": "npx vitest run --config vitest.config.mts --coverage",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui",
+ "electron": "electron .",
+ "electron:dev": "bun scripts/run-electron-dev.js",
+ "start:app-backend": "cross-env APP_ONLINE_MODE=0 APP_PORT=3001 PORT=3001 FILES_DIR=data/ DB_PATH=data/exelearning.db bun run start:dev",
+ "dev:app": "concurrently --kill-others --names 'BACKEND,ELECTRON' --prefix-colors 'blue,green' 'bun run start:app-backend' 'wait-on http://localhost:3001/healthcheck && bun run electron:dev'",
+ "electron:pack": "electron-builder",
+ "electron:pack:dir": "electron-builder --dir",
+ "package:prepare": "bun run build:static",
+ "package:app": "bun run package:prepare && bun run electron:pack",
+ "lint:src": "biome check src/",
+ "lint:src:fix": "biome check --write src/",
+ "lint:test": "biome check test/",
+ "lint:test:fix": "biome check --write test/",
+ "lint:public": "biome lint public/app/",
+ "lint:public:fix": "biome lint --write public/app/",
+ "format": "biome format --write src/ test/",
+ "format:check": "biome format src/ test/"
+ },
+ "keywords": [],
+ "author": {
+ "name": "INTEF",
+ "email": "cedec@educacion.gob.es",
+ "url": "https://exelearning.net"
+ },
+ "dependencies": {
+ "@elysiajs/cookie": "^0.8.0",
+ "@elysiajs/cors": "^1.4.0",
+ "@elysiajs/jwt": "^1.4.0",
+ "@elysiajs/static": "^1.4.7",
+ "@sinclair/typebox": "^0.34.45",
+ "bcryptjs": "^3.0.3",
+ "chmodr": "^2.0.2",
+ "concurrently": "^9.2.1",
+ "dotenv": "^17.2.3",
+ "electron-log": "^5.4.3",
+ "electron-updater": "^6.6.2",
+ "elysia": "^1.4.19",
+ "fast-xml-parser": "^5.3.3",
+ "fflate": "^0.8.2",
+ "fs-extra": "^11.3.3",
+ "i18n": "^0.15.3",
+ "ioredis": "^5.8.2",
+ "jose": "^6.1.3",
+ "kysely": "^0.28.9",
+ "kysely-bun-worker": "^1.2.1",
+ "lib0": "^0.2.116",
+ "mime-types": "^3.0.2",
+ "mysql2": "^3.16.0",
+ "nunjucks": "^3.2.4",
+ "sass": "^1.97.1",
+ "uuid": "^13.0.0",
+ "ws": "^8.18.3",
+ "y-websocket": "^3.0.0",
+ "yjs": "^13.6.28"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.28.5",
+ "@babel/preset-env": "^7.28.5",
+ "@biomejs/biome": "^2.3.10",
+ "@codecov/bundle-analyzer": "^1.9.1",
+ "@electron/notarize": "^3.1.1",
+ "@playwright/test": "^1.57.0",
+ "@types/bcryptjs": "^3.0.0",
+ "@types/fs-extra": "^11.0.4",
+ "@types/mime-types": "^3.0.1",
+ "@types/node": "^25.0.3",
+ "@types/nunjucks": "^3.2.6",
+ "@types/uuid": "^11.0.0",
+ "@types/ws": "^8.18.1",
+ "@vitest/coverage-v8": "^4.0.16",
+ "@vitest/ui": "^4.0.16",
+ "cross-env": "^10.1.0",
+ "electron": "^39.2.7",
+ "electron-builder": "^26.0.12",
+ "esbuild": "^0.27.2",
+ "happy-dom": "^20.0.11",
+ "http-proxy-middleware": "^3.0.5",
+ "kill-port": "^2.0.1",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.0",
+ "vitest": "^4.0.16",
+ "wait-on": "^9.0.3"
+ },
+ "build": {
+ "npmRebuild": false,
+ "appId": "es.intef.exelearning",
+ "productName": "eXeLearning",
+ "directories": {
+ "output": "release",
+ "app": "."
},
- "keywords": [],
- "author": {
- "name": "INTEF",
- "email": "cedec@educacion.gob.es",
- "url": "https://exelearning.net"
+ "compression": "normal",
+ "publish": [
+ {
+ "provider": "github",
+ "owner": "exelearning",
+ "repo": "exelearning",
+ "releaseType": "prerelease",
+ "channel": "latest"
+ }
+ ],
+ "afterPack": "packaging/afterPack.js",
+ "afterSign": "packaging/notarize.js",
+ "asar": true,
+ "disableDefaultIgnoredFiles": true,
+ "files": [
+ "main.js",
+ "preload.js",
+ "update-manager.js",
+ "node_modules/**/*",
+ ".env.dist",
+ "translations/en.json",
+ "translations/es.json",
+ "package.json"
+ ],
+ "fileAssociations": [
+ {
+ "ext": "elpx",
+ "name": "eXeLearning Project",
+ "description": "eXeLearning project file",
+ "role": "Editor",
+ "mimeType": "application/x-exelearning-elpx"
+ }
+ ],
+ "extraResources": [
+ {
+ "from": "dist/static/",
+ "to": "static/"
+ },
+ {
+ "from": "translations/",
+ "to": "translations/"
+ },
+ {
+ "from": "packaging/keys/",
+ "to": "keys/"
+ }
+ ],
+ "win": {
+ "target": [
+ {
+ "target": "nsis",
+ "arch": [
+ "x64"
+ ]
+ },
+ {
+ "target": "msi",
+ "arch": [
+ "x64"
+ ]
+ }
+ ],
+ "icon": "public/exelearning.ico",
+ "legalTrademarks": "INTEF",
+ "signAndEditExecutable": true,
+ "verifyUpdateCodeSignature": false,
+ "signtoolOptions": {
+ "rfc3161TimeStampServer": "http://time.certum.pl",
+ "timeStampServer": "http://time.certum.pl",
+ "signingHashAlgorithms": [
+ "sha1",
+ "sha256"
+ ]
+ }
},
- "dependencies": {
- "@elysiajs/cookie": "^0.8.0",
- "@elysiajs/cors": "^1.4.0",
- "@elysiajs/jwt": "^1.4.0",
- "@elysiajs/static": "^1.4.7",
- "@sinclair/typebox": "^0.34.45",
- "bcryptjs": "^3.0.3",
- "chmodr": "^2.0.2",
- "concurrently": "^9.2.1",
- "dotenv": "^17.2.3",
- "electron-log": "^5.4.3",
- "electron-updater": "^6.6.2",
- "elysia": "^1.4.19",
- "fast-xml-parser": "^5.3.3",
- "fflate": "^0.8.2",
- "fs-extra": "^11.3.3",
- "i18n": "^0.15.3",
- "ioredis": "^5.8.2",
- "jose": "^6.1.3",
- "kysely": "^0.28.9",
- "kysely-bun-worker": "^1.2.1",
- "lib0": "^0.2.116",
- "mime-types": "^3.0.2",
- "mysql2": "^3.16.0",
- "nunjucks": "^3.2.4",
- "sass": "^1.97.1",
- "uuid": "^13.0.0",
- "ws": "^8.18.3",
- "y-websocket": "^3.0.0",
- "yjs": "^13.6.28"
+ "nsis": {
+ "oneClick": true,
+ "runAfterFinish": true,
+ "perMachine": false,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true,
+ "shortcutName": "eXeLearning",
+ "preCompressedFileExtensions": [
+ ".zip",
+ ".7z",
+ ".gz",
+ ".bz2",
+ ".xz"
+ ]
},
- "devDependencies": {
- "@babel/core": "^7.28.5",
- "@babel/preset-env": "^7.28.5",
- "@biomejs/biome": "^2.3.10",
- "@codecov/bundle-analyzer": "^1.9.1",
- "@electron/notarize": "^3.1.1",
- "@playwright/test": "^1.57.0",
- "@types/bcryptjs": "^3.0.0",
- "@types/fs-extra": "^11.0.4",
- "@types/mime-types": "^3.0.1",
- "@types/node": "^25.0.3",
- "@types/nunjucks": "^3.2.6",
- "@types/uuid": "^11.0.0",
- "@types/ws": "^8.18.1",
- "@vitest/coverage-v8": "^4.0.16",
- "@vitest/ui": "^4.0.16",
- "cross-env": "^10.1.0",
- "electron": "^39.2.7",
- "electron-builder": "^26.0.12",
- "esbuild": "^0.27.2",
- "happy-dom": "^20.0.11",
- "http-proxy-middleware": "^3.0.5",
- "kill-port": "^2.0.1",
- "typescript": "^5.9.3",
- "vite": "^7.3.0",
- "vitest": "^4.0.16",
- "wait-on": "^9.0.3"
+ "msi": {
+ "oneClick": false,
+ "perMachine": true,
+ "runAfterFinish": false,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true
},
- "build": {
- "npmRebuild": false,
- "appId": "es.intef.exelearning",
- "productName": "eXeLearning",
- "directories": {
- "output": "release",
- "app": "."
- },
- "compression": "normal",
- "publish": [
- {
- "provider": "github",
- "owner": "exelearning",
- "repo": "exelearning",
- "releaseType": "prerelease",
- "channel": "latest"
- }
- ],
- "afterPack": "packaging/afterPack.js",
- "afterSign": "packaging/notarize.js",
- "asar": true,
- "disableDefaultIgnoredFiles": true,
- "files": [
- "main.js",
- "preload.js",
- "update-manager.js",
- "node_modules/**/*",
- ".env.dist",
- "translations/en.json",
- "translations/es.json",
- "dist/**/*",
- "package.json"
- ],
- "fileAssociations": [
- {
- "ext": "elpx",
- "name": "eXeLearning Project",
- "description": "eXeLearning project file",
- "role": "Editor",
- "mimeType": "application/x-exelearning-elpx"
- }
- ],
- "extraResources": [
- {
- "from": "public/",
- "to": "public/",
- "filter": [
- "**/*",
- "!**/__tests__/**",
- "!**/jest.setup.js",
- "!**/*.test.js",
- "!**/*.spec.js",
- "!**/*.jest.test.js"
- ]
- },
- {
- "from": "translations/",
- "to": "translations/"
- },
- {
- "from": ".env.dist",
- "to": ".env.dist"
- },
- {
- "from": "packaging/keys/",
- "to": "keys/"
- },
- {
- "from": "dist/",
- "to": "dist/"
- },
- {
- "from": "views/",
- "to": "views/"
- }
- ],
- "win": {
- "target": [
- {
- "target": "nsis",
- "arch": [
- "x64"
- ]
- },
- {
- "target": "msi",
- "arch": [
- "x64"
- ]
- }
- ],
- "icon": "public/exelearning.ico",
- "legalTrademarks": "INTEF",
- "signAndEditExecutable": true,
- "verifyUpdateCodeSignature": false,
- "signtoolOptions": {
- "rfc3161TimeStampServer": "http://time.certum.pl",
- "timeStampServer": "http://time.certum.pl",
- "signingHashAlgorithms": [
- "sha1",
- "sha256"
- ]
- }
- },
- "nsis": {
- "oneClick": true,
- "runAfterFinish": true,
- "perMachine": false,
- "createDesktopShortcut": true,
- "createStartMenuShortcut": true,
- "shortcutName": "eXeLearning",
- "preCompressedFileExtensions": [
- ".zip",
- ".7z",
- ".gz",
- ".bz2",
- ".xz"
- ]
- },
- "msi": {
- "oneClick": false,
- "perMachine": true,
- "runAfterFinish": false,
- "createDesktopShortcut": true,
- "createStartMenuShortcut": true
- },
- "linux": {
- "executableName": "exelearning",
- "executableArgs": [
- "--no-sandbox"
- ],
- "target": [
- {
- "target": "deb",
- "arch": [
- "x64"
- ]
- },
- {
- "target": "rpm",
- "arch": [
- "x64"
- ]
- }
- ],
- "category": "Education",
- "icon": "public/icons"
- },
- "deb": {
- "afterInstall": "packaging/deb/after-install.sh",
- "afterRemove": "packaging/deb/after-remove.sh"
+ "linux": {
+ "executableName": "exelearning",
+ "executableArgs": [
+ "--no-sandbox"
+ ],
+ "target": [
+ {
+ "target": "deb",
+ "arch": [
+ "x64"
+ ]
},
- "rpm": {
- "afterInstall": "packaging/rpm/after-install.sh",
- "afterRemove": "packaging/rpm/after-remove.sh"
- },
- "mac": {
- "category": "public.app-category.education",
- "hardenedRuntime": true,
- "gatekeeperAssess": false,
- "target": [
- {
- "target": "dmg",
- "arch": [
- "universal"
- ]
- },
- {
- "target": "zip",
- "arch": [
- "universal"
- ]
- }
- ],
- "icon": "public/exe_elp.icns",
- "entitlements": "packaging/entitlements.mac.plist",
- "entitlementsInherit": "packaging/entitlements.mac.inherit.plist",
- "x64ArchFiles": "**/exelearning-server-*"
+ {
+ "target": "rpm",
+ "arch": [
+ "x64"
+ ]
+ }
+ ],
+ "category": "Education",
+ "icon": "public/icons"
+ },
+ "deb": {
+ "afterInstall": "packaging/deb/after-install.sh",
+ "afterRemove": "packaging/deb/after-remove.sh"
+ },
+ "rpm": {
+ "afterInstall": "packaging/rpm/after-install.sh",
+ "afterRemove": "packaging/rpm/after-remove.sh"
+ },
+ "mac": {
+ "category": "public.app-category.education",
+ "hardenedRuntime": true,
+ "gatekeeperAssess": false,
+ "target": [
+ {
+ "target": "dmg",
+ "arch": [
+ "universal"
+ ]
},
- "dmg": {
- "format": "ULFO"
+ {
+ "target": "zip",
+ "arch": [
+ "universal"
+ ]
}
+ ],
+ "icon": "public/exe_elp.icns",
+ "entitlements": "packaging/entitlements.mac.plist",
+ "entitlementsInherit": "packaging/entitlements.mac.inherit.plist"
+ },
+ "dmg": {
+ "format": "ULFO"
}
+ }
}
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
index ac4af8446..730919091 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -3,7 +3,65 @@ import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E Test Configuration for eXeLearning
* @see https://playwright.dev/docs/test-configuration
+ *
+ * Supports two modes:
+ * - Server mode (default): Tests against the full Elysia server
+ * - Static mode: Tests against the static bundle (no server)
+ *
+ * Run static mode tests with: make test-e2e-static
*/
+
+// Detect if running static mode tests
+const isStaticProject = process.env.PLAYWRIGHT_PROJECT?.includes('static');
+
+/**
+ * Get the appropriate webServer configuration based on project type
+ */
+function getWebServerConfig() {
+ const project = process.env.PLAYWRIGHT_PROJECT || '';
+
+ if (process.env.E2E_BASE_URL) {
+ return undefined; // External server provided
+ }
+
+ if (project.includes('static')) {
+ // Static mode: build and serve static bundle
+ return {
+ command: 'bun scripts/serve-static-for-e2e.ts',
+ url: 'http://localhost:8080',
+ reuseExistingServer: !process.env.CI,
+ timeout: 180000, // 3 minutes (includes build time)
+ stdout: 'pipe' as const,
+ stderr: 'pipe' as const,
+ env: {
+ ...process.env,
+ PORT: '8080',
+ },
+ };
+ }
+
+ // Server mode (default)
+ return {
+ command:
+ 'DB_PATH=:memory: FILES_DIR=/tmp/exelearning-e2e/ PORT=3001 APP_PORT=3001 APP_AUTH_METHODS=password,guest ONLINE_THEMES_INSTALL=1 APP_LOCALE=en bun src/index.ts',
+ url: 'http://localhost:3001/login',
+ reuseExistingServer: false, // Always start fresh to ensure correct env vars
+ timeout: 120 * 1000, // 2 minutes to start
+ stdout: 'pipe' as const,
+ stderr: 'pipe' as const,
+ env: {
+ ...process.env,
+ DB_PATH: ':memory:',
+ FILES_DIR: '/tmp/exelearning-e2e/',
+ PORT: '3001',
+ APP_PORT: '3001',
+ APP_AUTH_METHODS: 'password,guest',
+ ONLINE_THEMES_INSTALL: '1', // Enable theme import for E2E tests
+ APP_LOCALE: 'en', // Force English locale for E2E tests
+ },
+ };
+}
+
export default defineConfig({
testDir: './test/e2e/playwright/specs',
@@ -30,7 +88,10 @@ export default defineConfig({
/* Shared settings for all the projects below */
use: {
/* Base URL to use in actions like `await page.goto('/')` */
- baseURL: process.env.E2E_BASE_URL || 'http://localhost:3001',
+ baseURL: process.env.E2E_BASE_URL || (isStaticProject ? 'http://localhost:8080' : 'http://localhost:3001'),
+
+ /* Force English locale for consistent test behavior */
+ locale: 'en-US',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
@@ -50,40 +111,33 @@ export default defineConfig({
/* Configure projects for major browsers */
projects: [
+ // Server mode projects (exclude static-mode tests)
{
name: 'chromium',
+ testIgnore: /static-mode-.*\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
+ testIgnore: /static-mode-.*\.spec\.ts/,
use: { ...devices['Desktop Firefox'] },
},
+ // Static mode project (Chromium only) - runs all tests that don't skip via serverOnly()
+ {
+ name: 'chromium-static',
+ use: {
+ ...devices['Desktop Chrome'],
+ baseURL: 'http://localhost:8080',
+ },
+ },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
- /* Run local dev server before starting the tests (only if E2E_BASE_URL is not set) */
- webServer: process.env.E2E_BASE_URL
- ? undefined
- : {
- command:
- 'DB_PATH=:memory: FILES_DIR=/tmp/exelearning-e2e/ PORT=3001 APP_PORT=3001 APP_AUTH_METHODS=password,guest bun src/index.ts',
- url: 'http://localhost:3001/login',
- reuseExistingServer: !process.env.CI,
- timeout: 120 * 1000, // 2 minutes to start
- stdout: 'pipe',
- stderr: 'pipe',
- env: {
- ...process.env,
- DB_PATH: ':memory:',
- FILES_DIR: '/tmp/exelearning-e2e/',
- PORT: '3001',
- APP_PORT: '3001',
- APP_AUTH_METHODS: 'password,guest',
- },
- },
+ /* Run local dev server before starting the tests (conditional based on mode) */
+ webServer: getWebServerConfig(),
/* Global timeout for each test */
timeout: 60000,
diff --git a/public/app/app.js b/public/app/app.js
index b356e89b8..ea073a14b 100644
--- a/public/app/app.js
+++ b/public/app/app.js
@@ -18,11 +18,20 @@ import UserManager from './workarea/user/userManager.js';
import Actions from './common/app_actions.js';
import Shortcuts from './common/shortcuts.js';
import SessionMonitor from './common/sessionMonitor.js';
+import DataProvider from './core/DataProvider.js';
+// Core infrastructure - ports/adapters pattern
+import { RuntimeConfig } from './core/RuntimeConfig.js';
+import { Capabilities } from './core/Capabilities.js';
+import { ProviderFactory } from './core/ProviderFactory.js';
export default class App {
constructor(eXeLearning) {
this.eXeLearning = eXeLearning;
this.parseExelearningConfig();
+
+ // Detect and initialize static/offline mode
+ this.initializeDataProvider();
+
this.api = new ApiCallManager(this);
this.locale = new Locale(this);
this.common = new Common(this);
@@ -48,11 +57,18 @@ export default class App {
*
*/
async init() {
+ // Initialize DataProvider (load static data if in static mode)
+ await this.dataProvider.init();
+
+ // Create ProviderFactory and inject adapters (Ports & Adapters pattern)
+ // This is the ONLY place where mode detection happens for adapters
+ await this.initializeAdapters();
+
// Compose and initialized toasts
this.initializedToasts();
// Compose and initialized modals
this.initializedModals();
- // Load api routes
+ // Load api routes (uses DataProvider in static mode)
await this.loadApiParameters();
// Load locale strings
await this.loadLocale();
@@ -186,6 +202,101 @@ export default class App {
};
}
+ /**
+ * Initialize DataProvider based on detected mode (static vs server)
+ * Called during constructor, before other managers are created
+ */
+ initializeDataProvider() {
+ // Use RuntimeConfig for mode detection (single source of truth)
+ this.runtimeConfig = RuntimeConfig.fromEnvironment();
+ this.capabilities = new Capabilities(this.runtimeConfig);
+
+ // Backward compatibility: store mode flags in config
+ const isStaticMode = this.runtimeConfig.isStaticMode();
+ this.eXeLearning.config.isStaticMode = isStaticMode;
+
+ // Create DataProvider with detected mode
+ const mode = isStaticMode ? 'static' : 'server';
+ const basePath = this.eXeLearning.config.basePath || '';
+
+ this.dataProvider = new DataProvider(mode, { basePath });
+
+ if (isStaticMode) {
+ console.log('[App] Running in STATIC/OFFLINE mode');
+ // Ensure offline-related flags are set
+ this.eXeLearning.config.isOfflineInstallation = true;
+ }
+
+ // Log capabilities for debugging
+ console.log('[App] Capabilities:', {
+ collaboration: this.capabilities.collaboration.enabled,
+ remoteStorage: this.capabilities.storage.remote,
+ auth: this.capabilities.auth.required,
+ });
+ }
+
+ /**
+ * Initialize adapters using ProviderFactory (Ports & Adapters pattern).
+ * This is the ONLY place where adapters are created and injected.
+ * After this, all API calls go through the appropriate adapter based on mode.
+ */
+ async initializeAdapters() {
+ try {
+ // Create factory (mode detection happens inside)
+ const factory = await ProviderFactory.create();
+
+ // Create all adapters
+ const adapters = factory.createAllAdapters();
+
+ // Inject into ApiCallManager
+ this.api.setAdapters(adapters);
+
+ // Store factory and capabilities for other components
+ this.providerFactory = factory;
+ // Update capabilities from factory (in case they differ)
+ this.capabilities = factory.getCapabilities();
+
+ console.log('[App] Adapters injected successfully:', {
+ mode: factory.getConfig().mode,
+ adaptersInjected: Object.keys(adapters).length,
+ });
+ } catch (error) {
+ console.error('[App] Failed to initialize adapters:', error);
+ // Continue without adapters - legacy fallback code will handle it
+ }
+ }
+
+ /**
+ * Detect if the app should run in static (offline) mode
+ * @deprecated Use this.runtimeConfig.isStaticMode() or this.capabilities.storage.remote instead
+ * @returns {boolean}
+ */
+ detectStaticMode() {
+ // Use RuntimeConfig if available (new pattern)
+ if (this.runtimeConfig) {
+ return this.runtimeConfig.isStaticMode();
+ }
+
+ // Fallback for early initialization before RuntimeConfig is set
+ // Priority 1: Explicit static mode flag (set in static/index.html)
+ if (window.__EXE_STATIC_MODE__ === true) {
+ return true;
+ }
+
+ // Priority 2: File protocol (opened as local file)
+ if (window.location.protocol === 'file:') {
+ return true;
+ }
+
+ // Priority 3: No server URL configured
+ if (!this.eXeLearning.config.fullURL) {
+ return true;
+ }
+
+ // Default: server mode
+ return false;
+ }
+
setupSessionMonitor() {
const baseInterval = Number(
this.eXeLearning.config.sessionCheckIntervalMs ||
@@ -309,6 +420,20 @@ export default class App {
console.info('Session expired, redirecting to login.', reason);
}
+ /**
+ * Check if the app is running in static/offline mode.
+ * @deprecated Prefer using this.capabilities for feature checks
+ * @returns {boolean}
+ */
+ isStaticMode() {
+ // Use RuntimeConfig as primary source
+ if (this.runtimeConfig) {
+ return this.runtimeConfig.isStaticMode();
+ }
+ // Fallback to DataProvider for backward compatibility
+ return this.dataProvider?.isStaticMode() ?? false;
+ }
+
/**
*
*/
@@ -393,25 +518,41 @@ export default class App {
*
*/
async check() {
+ // No server-side checks needed when remote storage is unavailable
+ if (!this.capabilities?.storage?.remote) {
+ return;
+ }
+
// Check FILES_DIR
- if (!this.eXeLearning.config.filesDirPermission.checked) {
+ if (!this.eXeLearning.config?.filesDirPermission?.checked) {
let htmlBody = '';
- this.eXeLearning.config.filesDirPermission.info.forEach((text) => {
+ const info = this.eXeLearning.config?.filesDirPermission?.info || [];
+ info.forEach((text) => {
htmlBody += `
${text}
`;
});
- this.modals.alert.show({
- title: _('Permissions error'),
- body: htmlBody,
- contentId: 'error',
- });
+ if (htmlBody) {
+ this.modals.alert.show({
+ title: _('Permissions error'),
+ body: htmlBody,
+ contentId: 'error',
+ });
+ }
}
}
/**
* Show LOPDGDD modal if necessary
+ * Skip LOPD modal when auth is not required (guest access)
*
*/
async showModalLopd() {
+ // Skip LOPD modal when auth is not required (static/offline mode)
+ if (!this.capabilities?.auth?.required) {
+ await this.loadProject();
+ this.check();
+ return;
+ }
+
if (!eXeLearning.user.acceptedLopd) {
// Load modals content
await this.project.loadModalsContent();
@@ -789,10 +930,25 @@ function __exeInstallBeforeUnloadOnce() {
/**
* Run eXe client on load
+ * In static mode, waits for project selection before initializing
*
*/
window.onload = function () {
var eXeLearning = window.eXeLearning;
eXeLearning.app = new App(eXeLearning);
+
+ // Static mode: wait for project selection (projectId will be set by welcome screen)
+ // Use RuntimeConfig for early detection (before app.capabilities is available)
+ const runtimeConfig = RuntimeConfig.fromEnvironment();
+ if (runtimeConfig.isStaticMode() && !eXeLearning.projectId) {
+ console.log('[App] Static mode: waiting for project selection...');
+ // Expose a function to start the app after project is selected
+ window.__startExeApp = function () {
+ console.log('[App] Starting app with project:', eXeLearning.projectId);
+ eXeLearning.app.init();
+ };
+ return;
+ }
+
eXeLearning.app.init();
};
diff --git a/public/app/app.test.js b/public/app/app.test.js
index 7c72d32e2..0a15e954c 100644
--- a/public/app/app.test.js
+++ b/public/app/app.test.js
@@ -663,6 +663,9 @@ describe('App utility methods', () => {
info: ['Error 1', 'Error 2'],
};
+ // Mock isStaticMode to return false so we test the normal check flow
+ vi.spyOn(appInstance, 'isStaticMode').mockReturnValue(false);
+
await appInstance.check();
expect(showSpy).toHaveBeenCalledWith(expect.objectContaining({
@@ -1070,6 +1073,9 @@ describe('App utility methods', () => {
const hideSpy = vi.fn();
const loadModalsContentSpy = vi.fn();
+ // Mock isStaticMode to return false so we test the normal LOPD flow
+ vi.spyOn(appInstance, 'isStaticMode').mockReturnValue(false);
+
appInstance.project = { loadModalsContent: loadModalsContentSpy };
appInstance.interface = { loadingScreen: { hide: hideSpy } };
appInstance.modals = {
@@ -1091,6 +1097,9 @@ describe('App utility methods', () => {
const loadSpy = vi.fn();
const checkSpy = vi.spyOn(appInstance, 'check').mockImplementation(() => {});
+ // Mock isStaticMode to return false so we test the normal LOPD flow
+ vi.spyOn(appInstance, 'isStaticMode').mockReturnValue(false);
+
appInstance.project = { load: loadSpy };
await appInstance.showModalLopd();
@@ -1174,7 +1183,7 @@ describe('App utility methods', () => {
it('sets up session monitor for online installation', () => {
window.eXeLearning = {
user: '{"id":1}',
- config: '{"isOfflineInstallation":false,"basePath":""}',
+ config: '{"isOfflineInstallation":false,"basePath":"","fullURL":"http://localhost:8080"}',
};
const app = new App(window.eXeLearning);
diff --git a/public/app/common/common.js b/public/app/common/common.js
index 1dd489f46..e33bf628f 100644
--- a/public/app/common/common.js
+++ b/public/app/common/common.js
@@ -88,7 +88,11 @@ window.MathJax = window.MathJax || (function() {
load: externalExtensions.map(function(ext) { return '[tex]/' + ext; })
},
options: {
- // MathJax Configuration Options
+ // Exclude navbar dropdown menus from MathJax processing (File, Edit, etc.)
+ // Note: nav-element is NOT excluded - page titles with LaTeX must be processed
+ ignoreHtmlClass: 'tex2jax_ignore|dropdown-menu|dropdown-item|modal',
+ // Skip processing inside these HTML tags
+ skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
}
};
})();
@@ -1498,7 +1502,11 @@ var $exeDevices = {
}
if (!window.MathJax.loader) window.MathJax.loader = {};
if (!window.MathJax.loader.paths) window.MathJax.loader.paths = {};
- window.MathJax.loader.paths.mathjax = basePath;
+ // In static mode, keep the pre-configured relative path
+ var capabilities = window.eXeLearning?.app?.capabilities;
+ if (capabilities?.storage?.remote) {
+ window.MathJax.loader.paths.mathjax = basePath;
+ }
var script = document.createElement('script');
script.src = self.engine;
script.async = true;
diff --git a/public/app/core/Capabilities.js b/public/app/core/Capabilities.js
new file mode 100644
index 000000000..676431c35
--- /dev/null
+++ b/public/app/core/Capabilities.js
@@ -0,0 +1,112 @@
+/**
+ * Capabilities - Feature flags that UI and business logic should query.
+ * Instead of checking mode, code should check capabilities.
+ *
+ * Example:
+ * // BAD: if (this.app.isStaticMode()) { ... }
+ * // GOOD: if (!this.app.capabilities.collaboration.enabled) { ... }
+ */
+export class Capabilities {
+ /**
+ * @param {import('./RuntimeConfig').RuntimeConfig} config
+ */
+ constructor(config) {
+ const isServer = config.mode === 'server';
+ const isStatic = config.mode === 'static';
+ const isElectron = config.mode === 'electron';
+
+ /**
+ * Collaboration features (presence, real-time sync)
+ */
+ this.collaboration = Object.freeze({
+ /** Whether collaboration is available */
+ enabled: isServer,
+ /** Whether real-time sync via WebSocket is available */
+ realtime: isServer,
+ /** Whether presence/cursors are available */
+ presence: isServer,
+ /** Whether concurrent editing is supported */
+ concurrent: isServer,
+ });
+
+ /**
+ * Storage capabilities
+ */
+ this.storage = Object.freeze({
+ /** Whether remote server storage is available */
+ remote: isServer,
+ /** Whether local storage (IndexedDB) is available */
+ local: true, // Always available
+ /** Whether sync between local and remote is available */
+ sync: isServer,
+ /** Whether projects are persisted to server */
+ serverPersistence: isServer,
+ });
+
+ /**
+ * Export capabilities
+ */
+ this.export = Object.freeze({
+ /** Whether server-side export is available */
+ serverSide: isServer,
+ /** Whether client-side export (JSZip) is available */
+ clientSide: true, // Always available
+ });
+
+ /**
+ * Authentication capabilities
+ */
+ this.auth = Object.freeze({
+ /** Whether authentication is required */
+ required: isServer,
+ /** Whether guest/anonymous access is allowed */
+ guest: isStatic || isElectron,
+ /** Whether login/logout is available */
+ loginAvailable: isServer,
+ });
+
+ /**
+ * Project management capabilities
+ */
+ this.projects = Object.freeze({
+ /** Whether project list is fetched from server */
+ remoteList: isServer,
+ /** Whether projects are stored in IndexedDB */
+ localList: isStatic || isElectron,
+ /** Whether "Recent Projects" uses server API */
+ recentFromServer: isServer,
+ /** Whether "Open from server" is available */
+ openFromServer: isServer,
+ /** Whether "Save to server" is available */
+ saveToServer: isServer,
+ });
+
+ /**
+ * Sharing capabilities
+ */
+ this.sharing = Object.freeze({
+ /** Whether sharing is available */
+ enabled: isServer,
+ /** Whether visibility settings are available */
+ visibility: isServer,
+ /** Whether link sharing is available */
+ links: isServer,
+ });
+
+ /**
+ * File management capabilities
+ */
+ this.fileManager = Object.freeze({
+ /** Whether file manager dialog is available */
+ enabled: true, // Available in all modes
+ /** Whether file manager uses server API */
+ serverBacked: isServer,
+ /** Whether files are stored locally */
+ localBacked: isStatic || isElectron,
+ });
+
+ Object.freeze(this);
+ }
+}
+
+export default Capabilities;
diff --git a/public/app/core/Capabilities.test.js b/public/app/core/Capabilities.test.js
new file mode 100644
index 000000000..1c15d9a0b
--- /dev/null
+++ b/public/app/core/Capabilities.test.js
@@ -0,0 +1,150 @@
+import { describe, it, expect } from 'vitest';
+import { Capabilities } from './Capabilities.js';
+import { RuntimeConfig } from './RuntimeConfig.js';
+
+describe('Capabilities', () => {
+ describe('server mode', () => {
+ const config = new RuntimeConfig({
+ mode: 'server',
+ baseUrl: 'http://localhost:8080',
+ wsUrl: 'ws://localhost:8080',
+ staticDataPath: null,
+ });
+ const capabilities = new Capabilities(config);
+
+ it('should be immutable', () => {
+ expect(Object.isFrozen(capabilities)).toBe(true);
+ expect(Object.isFrozen(capabilities.collaboration)).toBe(true);
+ expect(Object.isFrozen(capabilities.storage)).toBe(true);
+ });
+
+ it('should enable collaboration features', () => {
+ expect(capabilities.collaboration.enabled).toBe(true);
+ expect(capabilities.collaboration.realtime).toBe(true);
+ expect(capabilities.collaboration.presence).toBe(true);
+ expect(capabilities.collaboration.concurrent).toBe(true);
+ });
+
+ it('should enable remote storage', () => {
+ expect(capabilities.storage.remote).toBe(true);
+ expect(capabilities.storage.local).toBe(true);
+ expect(capabilities.storage.sync).toBe(true);
+ expect(capabilities.storage.serverPersistence).toBe(true);
+ });
+
+ it('should enable both export methods', () => {
+ expect(capabilities.export.serverSide).toBe(true);
+ expect(capabilities.export.clientSide).toBe(true);
+ });
+
+ it('should require authentication', () => {
+ expect(capabilities.auth.required).toBe(true);
+ expect(capabilities.auth.guest).toBe(false);
+ expect(capabilities.auth.loginAvailable).toBe(true);
+ });
+
+ it('should enable remote project features', () => {
+ expect(capabilities.projects.remoteList).toBe(true);
+ expect(capabilities.projects.localList).toBe(false);
+ expect(capabilities.projects.recentFromServer).toBe(true);
+ expect(capabilities.projects.openFromServer).toBe(true);
+ expect(capabilities.projects.saveToServer).toBe(true);
+ });
+
+ it('should enable sharing', () => {
+ expect(capabilities.sharing.enabled).toBe(true);
+ expect(capabilities.sharing.visibility).toBe(true);
+ expect(capabilities.sharing.links).toBe(true);
+ });
+
+ it('should enable server-backed file manager', () => {
+ expect(capabilities.fileManager.enabled).toBe(true);
+ expect(capabilities.fileManager.serverBacked).toBe(true);
+ expect(capabilities.fileManager.localBacked).toBe(false);
+ });
+ });
+
+ describe('static mode', () => {
+ const config = new RuntimeConfig({
+ mode: 'static',
+ baseUrl: '.',
+ wsUrl: null,
+ staticDataPath: './data/bundle.json',
+ });
+ const capabilities = new Capabilities(config);
+
+ it('should disable collaboration features', () => {
+ expect(capabilities.collaboration.enabled).toBe(false);
+ expect(capabilities.collaboration.realtime).toBe(false);
+ expect(capabilities.collaboration.presence).toBe(false);
+ expect(capabilities.collaboration.concurrent).toBe(false);
+ });
+
+ it('should use local storage only', () => {
+ expect(capabilities.storage.remote).toBe(false);
+ expect(capabilities.storage.local).toBe(true);
+ expect(capabilities.storage.sync).toBe(false);
+ expect(capabilities.storage.serverPersistence).toBe(false);
+ });
+
+ it('should only support client-side export', () => {
+ expect(capabilities.export.serverSide).toBe(false);
+ expect(capabilities.export.clientSide).toBe(true);
+ });
+
+ it('should allow guest access', () => {
+ expect(capabilities.auth.required).toBe(false);
+ expect(capabilities.auth.guest).toBe(true);
+ expect(capabilities.auth.loginAvailable).toBe(false);
+ });
+
+ it('should use local project storage', () => {
+ expect(capabilities.projects.remoteList).toBe(false);
+ expect(capabilities.projects.localList).toBe(true);
+ expect(capabilities.projects.recentFromServer).toBe(false);
+ expect(capabilities.projects.openFromServer).toBe(false);
+ expect(capabilities.projects.saveToServer).toBe(false);
+ });
+
+ it('should disable sharing', () => {
+ expect(capabilities.sharing.enabled).toBe(false);
+ expect(capabilities.sharing.visibility).toBe(false);
+ expect(capabilities.sharing.links).toBe(false);
+ });
+
+ it('should use local-backed file manager', () => {
+ expect(capabilities.fileManager.enabled).toBe(true);
+ expect(capabilities.fileManager.serverBacked).toBe(false);
+ expect(capabilities.fileManager.localBacked).toBe(true);
+ });
+ });
+
+ describe('electron mode', () => {
+ const config = new RuntimeConfig({
+ mode: 'electron',
+ baseUrl: 'http://localhost',
+ wsUrl: null,
+ staticDataPath: null,
+ });
+ const capabilities = new Capabilities(config);
+
+ it('should disable collaboration (no WebSocket)', () => {
+ expect(capabilities.collaboration.enabled).toBe(false);
+ });
+
+ it('should allow guest access', () => {
+ expect(capabilities.auth.required).toBe(false);
+ expect(capabilities.auth.guest).toBe(true);
+ });
+
+ it('should use local project storage', () => {
+ expect(capabilities.projects.localList).toBe(true);
+ expect(capabilities.projects.remoteList).toBe(false);
+ });
+
+ it('should use local-backed file manager', () => {
+ expect(capabilities.fileManager.localBacked).toBe(true);
+ expect(capabilities.fileManager.serverBacked).toBe(false);
+ });
+ });
+});
diff --git a/public/app/core/DataProvider.js b/public/app/core/DataProvider.js
new file mode 100644
index 000000000..fef584ce0
--- /dev/null
+++ b/public/app/core/DataProvider.js
@@ -0,0 +1,345 @@
+/**
+ * DataProvider
+ * Unified data access abstraction for eXeLearning.
+ * Switches between server API calls and pre-bundled static data.
+ *
+ * Usage:
+ * const provider = new DataProvider('static'); // or 'server'
+ * await provider.init();
+ * const translations = await provider.getTranslations('en');
+ */
+
+// Use global AppLogger for debug-controlled logging
+const getLogger = () => window.AppLogger || console;
+
+export default class DataProvider {
+ /**
+ * @param {'server' | 'static'} mode - Data access mode
+ * @param {Object} options - Configuration options
+ * @param {string} [options.basePath=''] - Base path for API URLs
+ * @param {Object} [options.staticData=null] - Pre-bundled static data (if not in window.__EXE_STATIC_DATA__)
+ */
+ constructor(mode = 'server', options = {}) {
+ this.mode = mode;
+ this.basePath = options.basePath || '';
+ this.staticData = options.staticData || null;
+ this.initialized = false;
+
+ // Cache for loaded data (both modes)
+ this.cache = {
+ parameters: null,
+ translations: {},
+ idevices: null,
+ themes: null,
+ bundleManifest: null,
+ };
+
+ getLogger().log(`[DataProvider] Created in ${mode} mode`);
+ }
+
+ /**
+ * Initialize the data provider
+ * In static mode, loads data from window.__EXE_STATIC_DATA__ or fetches bundle.json
+ */
+ async init() {
+ if (this.initialized) {
+ return;
+ }
+
+ if (this.mode === 'static') {
+ await this._initStaticData();
+ }
+
+ this.initialized = true;
+ getLogger().log('[DataProvider] Initialized');
+ }
+
+ /**
+ * Load static data from embedded or external source
+ * @private
+ */
+ async _initStaticData() {
+ // Priority 1: Constructor-provided data
+ if (this.staticData) {
+ getLogger().log('[DataProvider] Using constructor-provided static data');
+ return;
+ }
+
+ // Priority 2: Embedded in window
+ if (window.__EXE_STATIC_DATA__) {
+ this.staticData = window.__EXE_STATIC_DATA__;
+ getLogger().log('[DataProvider] Using window.__EXE_STATIC_DATA__');
+ return;
+ }
+
+ // Priority 3: Fetch from bundle.json
+ try {
+ const bundleUrl = `${this.basePath}/data/bundle.json`;
+ getLogger().log(`[DataProvider] Fetching static data from ${bundleUrl}`);
+ const response = await fetch(bundleUrl);
+ if (response.ok) {
+ this.staticData = await response.json();
+ getLogger().log('[DataProvider] Loaded static data from bundle.json');
+ return;
+ }
+ } catch (e) {
+ getLogger().warn('[DataProvider] Failed to fetch bundle.json:', e.message);
+ }
+
+ // Fallback: Create empty structure
+ getLogger().warn('[DataProvider] No static data source found, using empty defaults');
+ this.staticData = {
+ parameters: { routes: {} },
+ translations: { en: { translations: {} } },
+ idevices: { idevices: [] },
+ themes: { themes: [] },
+ bundleManifest: null,
+ };
+ }
+
+ /**
+ * Check if running in static (offline) mode
+ * @returns {boolean}
+ */
+ isStaticMode() {
+ return this.mode === 'static';
+ }
+
+ /**
+ * Check if running in server (online) mode
+ * @returns {boolean}
+ */
+ isServerMode() {
+ return this.mode === 'server';
+ }
+
+ /**
+ * Get API parameters (route definitions)
+ * @returns {Promise<{routes: Object}>}
+ */
+ async getApiParameters() {
+ if (this.cache.parameters) {
+ return this.cache.parameters;
+ }
+
+ if (this.mode === 'static') {
+ this.cache.parameters = this.staticData?.parameters || { routes: {} };
+ return this.cache.parameters;
+ }
+
+ // Server mode: fetch from API
+ const url = `${this.basePath}/api/parameter-management/parameters/data/list`;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ this.cache.parameters = await response.json();
+ return this.cache.parameters;
+ } catch (e) {
+ getLogger().error('[DataProvider] Failed to fetch API parameters:', e);
+ throw e;
+ }
+ }
+
+ /**
+ * Get translations for a locale
+ * @param {string} locale - Language code (e.g., 'en', 'es')
+ * @returns {Promise<{translations: Object}>}
+ */
+ async getTranslations(locale) {
+ // Default to 'en' if locale is null/undefined
+ const safeLocale = locale || 'en';
+
+ if (this.cache.translations[safeLocale]) {
+ return this.cache.translations[safeLocale];
+ }
+
+ if (this.mode === 'static') {
+ // Try exact locale, then fall back to base language, then 'en'
+ const baseLocale = safeLocale.split('-')[0];
+ const translations =
+ this.staticData?.translations?.[safeLocale] ||
+ this.staticData?.translations?.[baseLocale] ||
+ this.staticData?.translations?.en ||
+ { translations: {} };
+
+ this.cache.translations[safeLocale] = translations;
+ return translations;
+ }
+
+ // Server mode: fetch from API
+ const url = `${this.basePath}/api/translations/${safeLocale}`;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ this.cache.translations[safeLocale] = await response.json();
+ return this.cache.translations[safeLocale];
+ } catch (e) {
+ getLogger().error(`[DataProvider] Failed to fetch translations for ${safeLocale}:`, e);
+ // Return empty translations to avoid breaking the app
+ return { translations: {} };
+ }
+ }
+
+ /**
+ * Get installed iDevices list
+ * @returns {Promise<{idevices: Array}>}
+ */
+ async getInstalledIdevices() {
+ if (this.cache.idevices) {
+ return this.cache.idevices;
+ }
+
+ if (this.mode === 'static') {
+ this.cache.idevices = this.staticData?.idevices || { idevices: [] };
+ return this.cache.idevices;
+ }
+
+ // Server mode: will be fetched via apiCallManager
+ // This method provides a fallback
+ return { idevices: [] };
+ }
+
+ /**
+ * Get installed themes list
+ * @returns {Promise<{themes: Array}>}
+ */
+ async getInstalledThemes() {
+ if (this.cache.themes) {
+ return this.cache.themes;
+ }
+
+ if (this.mode === 'static') {
+ this.cache.themes = this.staticData?.themes || { themes: [] };
+ return this.cache.themes;
+ }
+
+ // Server mode: will be fetched via apiCallManager
+ // This method provides a fallback
+ return { themes: [] };
+ }
+
+ /**
+ * Get bundle manifest (for resource fetching)
+ * @returns {Promise