diff --git a/lib/Controller/AssetController.php b/lib/Controller/AssetController.php index 9a34362..ef1330b 100644 --- a/lib/Controller/AssetController.php +++ b/lib/Controller/AssetController.php @@ -27,29 +27,40 @@ * the current user can read the underlying file before serving any bytes. */ class AssetController extends Controller { + /** Mirrors `MIME_BY_EXTENSION` in `src/elpx/asset-map.ts` (parity-tested). */ private const MIME_MAP = [ 'html' => 'text/html; charset=utf-8', 'htm' => 'text/html; charset=utf-8', + 'xhtml' => 'application/xhtml+xml; charset=utf-8', 'css' => 'text/css; charset=utf-8', 'js' => 'text/javascript; charset=utf-8', 'mjs' => 'text/javascript; charset=utf-8', 'json' => 'application/json; charset=utf-8', + 'xml' => 'application/xml; charset=utf-8', + 'txt' => 'text/plain; charset=utf-8', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', + 'ico' => 'image/x-icon', 'mp3' => 'audio/mpeg', 'mp4' => 'video/mp4', + 'm4a' => 'audio/mp4', + 'm4v' => 'video/mp4', 'ogg' => 'audio/ogg', + 'oga' => 'audio/ogg', + 'ogv' => 'video/ogg', 'wav' => 'audio/wav', 'webm' => 'video/webm', 'vtt' => 'text/vtt', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', + 'otf' => 'font/otf', 'eot' => 'application/vnd.ms-fontobject', + 'pdf' => 'application/pdf', ]; public function __construct( diff --git a/src/elpx/service-worker-client.ts b/src/elpx/service-worker-client.ts index 01e1b2c..7e25b7e 100644 --- a/src/elpx/service-worker-client.ts +++ b/src/elpx/service-worker-client.ts @@ -63,7 +63,7 @@ async function registerRuntimeWorker(): Promise { /** * Resolves once the registered Service Worker reaches the `activated` - * state. Resolves immediately when the worker is already controlling the + * state, and rejects if it becomes `redundant`. Resolves immediately when the worker is already controlling the * page; otherwise listens for `statechange` on the * installing/waiting/active worker. * @param registration Result of `navigator.serviceWorker.register`. @@ -72,7 +72,7 @@ function waitForActive(registration: ServiceWorkerRegistration): Promise { if (registration.active && navigator.serviceWorker.controller) { return Promise.resolve() } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const sw = registration.installing || registration.waiting || registration.active if (!sw) { resolve() @@ -85,6 +85,10 @@ function waitForActive(registration: ServiceWorkerRegistration): Promise { sw.addEventListener('statechange', () => { if (sw.state === 'activated') { resolve() + } else if (sw.state === 'redundant') { + // Install failed or was superseded; without this the viewer + // would wait forever instead of using the server fallback. + reject(new Error('The eXeLearning Service Worker failed to activate.')) } }) }) diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue index 49f3aba..15939cb 100644 --- a/src/viewer/ElpxViewer.vue +++ b/src/viewer/ElpxViewer.vue @@ -133,6 +133,12 @@ export default defineComponent({ try { const worker = await ensureRuntimeWorker() await registerSession(worker, session) + if (this.$.isUnmounted) { + // Closed while loading: teardown already ran without + // this session, so release the bytes held by the SW. + void unregisterSession(worker, session.id) + return + } this.session = session this.worker = worker diff --git a/tests/Unit/Controller/AssetControllerTest.php b/tests/Unit/Controller/AssetControllerTest.php index 3c86195..269763c 100644 --- a/tests/Unit/Controller/AssetControllerTest.php +++ b/tests/Unit/Controller/AssetControllerTest.php @@ -137,6 +137,16 @@ public function testUsesOctetStreamForUnknownExtension(): void { self::assertSame('application/octet-stream', $response->getHeaders()['Content-Type']); } + public function testMimeMapMatchesTheServiceWorkerMap(): void { + $source = file_get_contents(__DIR__ . '/../../../src/elpx/asset-map.ts'); + self::assertIsString($source); + preg_match_all("/^\t(\w+): '([^']+)',$/m", $source, $matches); + $tsMap = array_combine($matches[1], $matches[2]); + + self::assertNotEmpty($tsMap); + self::assertSame($tsMap, (new \ReflectionClassConstant(AssetController::class, 'MIME_MAP'))->getValue()); + } + private function authenticate(): IUser { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('alice'); diff --git a/tests/js/service-worker-client.test.ts b/tests/js/service-worker-client.test.ts new file mode 100644 index 0000000..ce43491 --- /dev/null +++ b/tests/js/service-worker-client.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/router', () => ({ generateUrl: (path: string) => path })) + +/** Fake ServiceWorker whose state the test drives. */ +function fakeWorker(state: ServiceWorkerState) { + const worker = new EventTarget() as ServiceWorker & { state: ServiceWorkerState } + worker.state = state + return worker +} + +async function loadClient(installing: ServiceWorker) { + vi.resetModules() + const registration = { installing, waiting: null, active: null } + vi.stubGlobal('navigator', { + serviceWorker: { controller: null, register: vi.fn().mockResolvedValue(registration) }, + }) + return import('../../src/elpx/service-worker-client') +} + +describe('ensureRuntimeWorker', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('resolves once the worker activates', async () => { + const worker = fakeWorker('installing') + const { ensureRuntimeWorker } = await loadClient(worker) + + const pending = ensureRuntimeWorker() + await Promise.resolve() + worker.state = 'activated' + worker.dispatchEvent(new Event('statechange')) + + await expect(pending).resolves.toMatchObject({ scope: '/apps/exelearning/runtime/' }) + }) + + it('rejects when the worker becomes redundant so the viewer can fall back', async () => { + const worker = fakeWorker('installing') + const { ensureRuntimeWorker } = await loadClient(worker) + + const pending = ensureRuntimeWorker() + await Promise.resolve() + worker.state = 'redundant' + worker.dispatchEvent(new Event('statechange')) + + await expect(pending).rejects.toThrow('failed to activate') + }) +})