Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions docs/EMBEDDED_EDITOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,20 @@ full-screen overlay containing an `<iframe>` whose `src` is the editor URL

### Service worker

The static editor ships a `preview-sw.js` service worker, but **the embedded
editor never registers it**: `editor/index.php` shims
`navigator.serviceWorker.register` so any request for `preview-sw.js` resolves to
a no-op instead of registering (`editor/index.php:224-241`). This avoids the
console-spamming registration errors seen where the `static.php` router is proxied
or cached (e.g. moodle-playground) and returns a 404 for that path.

Because registration is blocked, `editor/static.php` **does not** emit a
`Service-Worker-Allowed: /` header when serving `preview-sw.js`. That header only
widens the scope a service worker is *allowed to control*; with no registration it
was unused, and broadcasting an unnecessarily broad (`/`) control scope is avoided.
Removing it changes no working flow — the worker is never activated — and narrows
the surface a future or proxied registration could claim.
The static editor renders its preview through a `preview-sw.js` service worker,
registered from `editor/static.php/<cmid>/preview-sw.js` with the scope
`editor/static.php/<cmid>/viewer/`. The scope sits under the script's own path, so
no `Service-Worker-Allowed` header is needed and `editor/static.php` does not send
one.

`editor/index.php` wraps `navigator.serviceWorker.register` only to absorb a
**failed** registration: where the `static.php` router is proxied or cached (e.g.
moodle-playground) the worker script 404s, and the wrapper resolves a stub instead
of letting the error reach the console. The editor then falls back to a `blob:`
preview. That fallback is degraded: the inlined theme CSS keeps relative `url(...)`
references that cannot resolve from a `blob:` URL, so theme icons are missing
(exelearning/exelearning issue 2476). Blocking the registration unconditionally, as
earlier versions did, forced that fallback everywhere.

### Protocol messages

Expand Down
22 changes: 13 additions & 9 deletions editor/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -222,24 +222,28 @@ enumerable: true,
// the Yjs theme bind and leaves the editor unresponsive. WP and Omeka-S
// ship the same workaround: swallow 404s on .css / idevices URLs and
// return an empty stylesheet so the editor keeps booting.
// Disable any new service-worker registration (the static editor's
// preview-sw.js is served from the same static.php router; environments
// that proxy or cache that router — e.g. moodle-playground — return a
// 404 there and the registration error spams the console without
// blocking anything).
// Let the static editor register its preview-sw.js: the preview renders
// through it, and without it the editor falls back to a blob: URL whose
// inlined theme CSS keeps relative url(...) references (icons, sprites)
// that cannot resolve, so the preview loses the theme images
// (exelearning/exelearning issue 2476). Only a failed registration is
// absorbed: environments that proxy or cache the static.php router (e.g.
// moodle-playground) 404 the worker script, and the resolved stub keeps
// that error out of the console while the editor uses its blob fallback.
(function() {
if ("serviceWorker" in navigator) {
try {
var registerOriginal = navigator.serviceWorker.register
? navigator.serviceWorker.register.bind(navigator.serviceWorker)
: null;
navigator.serviceWorker.register = function(scriptURL, options) {
if (typeof scriptURL === "string" && scriptURL.indexOf("preview-sw.js") !== -1) {
if (!registerOriginal) {
return Promise.resolve({ scope: "" });
}
return registerOriginal
? registerOriginal(scriptURL, options)
: Promise.resolve({ scope: "" });
return registerOriginal(scriptURL, options).catch(function(err) {
console.warn("[mod_exelearning] Service worker registration failed:", scriptURL, err);
return { scope: "" };
});
};
} catch (e) {
// Some embeds make navigator.serviceWorker non-writable; ignore.
Expand Down
60 changes: 60 additions & 0 deletions tests/js/editor_service_worker_shim.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

// Service-worker shim inlined by editor/index.php (exelearning/exelearning issue 2476).
//
// The editor's preview needs its preview-sw.js: without it the preview falls back
// to a blob: URL where the theme CSS url(...) images (navigation icons, sprites)
// cannot resolve. The shim must let that registration through and only absorb a
// failed one (a proxied static.php router that 404s the worker script).
const fs = require('fs');
const path = require('path');

/** Extracts the service-worker block of the bootstrap script and runs it. */
function installShim(navigator, console) {
const source = fs.readFileSync(path.join(__dirname, '../../editor/index.php'), 'utf8');
const start = source.indexOf('if ("serviceWorker" in navigator) {');
const end = source.indexOf('var originalFetch = window.fetch;', start);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
new Function('navigator', 'console', source.slice(start, end))(navigator, console);
}

function fakeNavigator(register) {
return { serviceWorker: { register } };
}

describe('editor bootstrap: preview service worker', () => {
it('registers preview-sw.js so the preview can resolve theme images', async () => {
const registration = { scope: '/mod/exelearning/editor/static.php/2/viewer/' };
const register = vi.fn(() => Promise.resolve(registration));
const nav = fakeNavigator(register);
installShim(nav, console);

const result = await nav.serviceWorker.register('/static.php/2/preview-sw.js', { scope: 'viewer/' });

expect(register).toHaveBeenCalledWith('/static.php/2/preview-sw.js', { scope: 'viewer/' });
expect(result).toBe(registration);
});

it('resolves a failed registration quietly so the editor uses its blob fallback', async () => {
const nav = fakeNavigator(() => Promise.reject(new TypeError('404 fetching the script')));
const warn = vi.fn();
installShim(nav, { warn });

await expect(nav.serviceWorker.register('/static.php/2/preview-sw.js')).resolves.toEqual({ scope: '' });
expect(warn).toHaveBeenCalled();
});
});
3 changes: 2 additions & 1 deletion vitest.config.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { defineConfig } from 'vitest/config';

// Vitest config for the plugin's own JavaScript unit tests. Scope is deliberately
// narrow: only the grade-critical SCORM tracker (js/scorm_tracker.js). UI glue
// narrow: the grade-critical SCORM tracker (js/scorm_tracker.js) and the editor's
// service-worker shim (editor/index.php). Other UI glue
// (amd/src/fullscreen.js, resize.js, editor_modal.js, ...) and the vendored pipwerks
// wrappers (assets/scorm/*) are out of scope. The embedded editor (exelearning/) ships
// its own Vitest suite and is not retested here.
Expand Down
Loading