From 90a75d1d2ecb35d65e403f7548b5b17100737873 Mon Sep 17 00:00:00 2001 From: Christian Semmler Date: Mon, 21 Jul 2025 10:45:24 -0700 Subject: [PATCH] Use Web Worker to download game assets --- app.js | 40 +++++++++---- downloader.js | 59 ++++++++++++++++++++ sw.js | 152 +++++++++++++++----------------------------------- 3 files changed, 134 insertions(+), 117 deletions(-) create mode 100644 downloader.js diff --git a/app.js b/app.js index 5d8630d..3354c15 100644 --- a/app.js +++ b/app.js @@ -346,6 +346,9 @@ document.addEventListener('DOMContentLoaded', function () { document.getElementById('touch-section').style.display = 'none'; } + let downloaderWorker = null; + let missingGameFiles = []; + if ('serviceWorker' in navigator) { Promise.all([ configManager.init(), @@ -370,6 +373,10 @@ document.addEventListener('DOMContentLoaded', function () { if (navigator.serviceWorker && navigator.serviceWorker.controller) { await requestPersistentStorage(); + if (downloaderWorker) downloaderWorker.terminate(); + downloaderWorker = new Worker('/downloader.js'); + downloaderWorker.onmessage = handleWorkerMessage; + const selectedLanguage = languageSelect.value; installBtn.style.display = 'none'; uninstallBtn.style.display = 'none'; @@ -377,8 +384,9 @@ document.addEventListener('DOMContentLoaded', function () { progressCircular.textContent = '0%'; progressCircular.style.background = 'conic-gradient(#FFD700 0deg, #333 0deg)'; - navigator.serviceWorker.controller.postMessage({ - action: 'install_language_pack', + downloaderWorker.postMessage({ + action: 'install', + missingFiles: missingGameFiles, language: selectedLanguage }); } @@ -427,29 +435,39 @@ document.addEventListener('DOMContentLoaded', function () { } function handleServiceWorkerMessage(event) { - const { action, language, progress, exists, success } = event.data; - if (language !== languageSelect.value) return; + const { action, language, isInstalled, success } = event.data; + if (language && language !== languageSelect.value) return; switch (action) { case 'cache_status': - updateInstallUI(exists); + missingGameFiles = event.data.missingFiles; + updateInstallUI(isInstalled); break; + case 'uninstall_complete': + updateInstallUI(!success); + checkInitialCacheStatus(); + break; + } + } + + function handleWorkerMessage(event) { + const { action, progress, success, error } = event.data; + + switch (action) { case 'install_progress': updateInstallUI(false, true); const angle = (progress / 100) * 360; progressCircular.textContent = `${Math.round(progress)}%`; - progressCircular.style.background = - `radial-gradient(#181818 60%, transparent 61%), conic-gradient(#FFD700 ${angle}deg, #333 ${angle}deg)`; + progressCircular.style.background = `radial-gradient(#181818 60%, transparent 61%), conic-gradient(#FFD700 ${angle}deg, #333 ${angle}deg)`; break; case 'install_complete': updateInstallUI(success); - break; - case 'uninstall_complete': - updateInstallUI(!success); + if (downloaderWorker) downloaderWorker.terminate(); break; case 'install_failed': - alert('Download failed. Please check your internet connection and try again.'); + alert(`Download failed: ${error}`); updateInstallUI(false); + if (downloaderWorker) downloaderWorker.terminate(); break; } } diff --git a/downloader.js b/downloader.js new file mode 100644 index 0000000..c8c662d --- /dev/null +++ b/downloader.js @@ -0,0 +1,59 @@ +self.onmessage = async (event) => { + const { action, missingFiles, language } = event.data; + + if (action === 'install') { + const cacheName = `game-assets-${language}`; + const THROTTLE_MS = 100; + + try { + const fileMetadataPromises = missingFiles.map(fileUrl => + fetch(fileUrl, { method: 'HEAD', headers: { 'Accept-Language': language } }) + .then(response => { + if (!response.ok) throw new Error(`Failed to HEAD ${fileUrl}`); + return { url: fileUrl, size: Number(response.headers.get('content-length')) || 0 }; + }) + ); + const fileMetadata = await Promise.all(fileMetadataPromises); + const totalBytesToDownload = fileMetadata.reduce((sum, file) => sum + file.size, 0); + let bytesDownloaded = 0; + let lastProgressUpdate = 0; + + const cache = await caches.open(cacheName); + + for (const file of fileMetadata) { + const request = new Request(file.url, { headers: { 'Accept-Language': language } }); + const response = await fetch(request); + + if (!response.ok || !response.body) { + throw new Error(`Failed to fetch ${file.url}`); + } + + const [streamForCaching, streamForProgress] = response.body.tee(); + const cachePromise = cache.put(request, new Response(streamForCaching, response)); + + const reader = streamForProgress.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + bytesDownloaded += value.length; + const now = Date.now(); + + if (now - lastProgressUpdate > THROTTLE_MS) { + lastProgressUpdate = now; + self.postMessage({ + action: 'install_progress', + progress: (bytesDownloaded / totalBytesToDownload) * 100, + }); + } + } + await cachePromise; + } + + self.postMessage({ action: 'install_complete', success: true }); + } catch (error) { + console.error("Download worker error:", error); + self.postMessage({ action: 'install_failed', error: error.message }); + } + } +}; diff --git a/sw.js b/sw.js index 3d2608f..e75f384 100644 --- a/sw.js +++ b/sw.js @@ -16,7 +16,7 @@ const coreAppFiles = [ '/island.webp', '/isle.js', '/isle.wasm', '/poster.pdf', '/read_me_off.webp', '/read_me_on.webp', '/run_game_off.webp', '/run_game_on.webp', '/shark.webp', '/uninstall_off.webp', '/uninstall_on.webp', '/app.js', '/style.css', '/manifest.json', - '/install.webp', '/install.mp3' + '/install.webp', '/install.mp3', '/downloader.js' ]; const gameFiles = [ @@ -33,22 +33,6 @@ const gameFiles = [ const STATIC_CACHE_NAME = 'static-assets-v1'; -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(STATIC_CACHE_NAME).then((cache) => { - return cache.addAll(coreAppFiles); - }) - ); - self.skipWaiting(); -}); - -registerRoute( - ({ url }) => coreAppFiles.includes(url.pathname), - new StaleWhileRevalidate({ - cacheName: STATIC_CACHE_NAME, - }) -); - const rangeRequestsPlugin = new RangeRequestsPlugin(); const normalizePathPlugin = { cacheKeyWillBeUsed: async ({ request }) => { @@ -82,95 +66,8 @@ class LegoCacheStrategy extends Strategy { } } -registerRoute( - ({ url }) => url.pathname.startsWith('/LEGO/'), - new LegoCacheStrategy() -); - -self.addEventListener('message', (event) => { - if (event.data && event.data.action) { - switch (event.data.action) { - case 'install_language_pack': - installLanguagePack(event.data.language, event.source); - break; - case 'uninstall_language_pack': - uninstallLanguagePack(event.data.language, event.source); - break; - case 'check_cache_status': - checkCacheStatus(event.data.language, event.source); - break; - } - } -}); - const getLanguageCacheName = (language) => `game-assets-${language}`; -async function installLanguagePack(language, client) { - const THROTTLE_MS = 100; - const cacheName = getLanguageCacheName(language); - - try { - const fileMetadataPromises = gameFiles.map(fileUrl => - fetch(fileUrl, { method: 'HEAD', headers: { 'Accept-Language': language } }) - .then(response => { - if (!response.ok) throw new Error(`Failed to HEAD ${fileUrl}`); - return { url: fileUrl, size: Number(response.headers.get('content-length')) || 0 }; - }) - ); - const fileMetadata = await Promise.all(fileMetadataPromises); - const totalBytesToDownload = fileMetadata.reduce((sum, file) => sum + file.size, 0); - let bytesDownloaded = 0; - let lastProgressUpdate = 0; - - const cache = await caches.open(cacheName); - - for (const file of fileMetadata) { - const request = new Request(file.url, { headers: { 'Accept-Language': language } }); - const response = await fetch(request); - - if (!response.ok || !response.body) { - throw new Error(`Failed to fetch ${file.url}`); - } - - const [streamForCaching, streamForProgress] = response.body.tee(); - - const responseToCache = new Response(streamForCaching, response); - const cachePromise = cache.put(request, responseToCache); - - const reader = streamForProgress.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - bytesDownloaded += value.length; - const now = Date.now(); - - if (now - lastProgressUpdate > THROTTLE_MS) { - lastProgressUpdate = now; - client.postMessage({ - action: 'install_progress', - progress: (bytesDownloaded / totalBytesToDownload) * 100, - language: language - }); - } - } - - await cachePromise; - } - - client.postMessage({ - action: 'install_progress', - progress: 100, - language: language - }); - client.postMessage({ action: 'install_complete', success: true, language: language }); - } catch (error) { - console.error("Aborting installation due to an error:", error); - await caches.delete(cacheName); - client.postMessage({ action: 'install_failed', language: language }); - } -} - async function uninstallLanguagePack(language, client) { const cacheName = getLanguageCacheName(language); try { @@ -187,10 +84,40 @@ async function uninstallLanguagePack(language, client) { async function checkCacheStatus(language, client) { const cacheName = getLanguageCacheName(language); - const hasCache = await caches.has(cacheName); - client.postMessage({ action: 'cache_status', exists: hasCache, language: language }); + const cache = await caches.open(cacheName); + const requests = await cache.keys(); + const cachedUrls = requests.map(req => new URL(req.url).pathname); + const missingFiles = gameFiles.filter(file => !cachedUrls.includes(file)); + + client.postMessage({ + action: 'cache_status', + isInstalled: missingFiles.length === 0, + missingFiles: missingFiles, + language: language + }); } +registerRoute( + ({ url }) => coreAppFiles.includes(url.pathname), + new StaleWhileRevalidate({ + cacheName: STATIC_CACHE_NAME, + }) +); + +registerRoute( + ({ url }) => url.pathname.startsWith('/LEGO/'), + new LegoCacheStrategy() +); + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(STATIC_CACHE_NAME).then((cache) => { + return cache.addAll(coreAppFiles); + }) + ); + self.skipWaiting(); +}); + self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((cacheNames) => { @@ -205,3 +132,16 @@ self.addEventListener('activate', (event) => { ); event.waitUntil(self.clients.claim()); }); + +self.addEventListener('message', (event) => { + if (event.data && event.data.action) { + switch (event.data.action) { + case 'uninstall_language_pack': + uninstallLanguagePack(event.data.language, event.source); + break; + case 'check_cache_status': + checkCacheStatus(event.data.language, event.source); + break; + } + } +});