Use Web Worker to download game assets

This commit is contained in:
Christian Semmler 2025-07-21 10:45:24 -07:00
parent 553a714591
commit 90a75d1d2e
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
3 changed files with 134 additions and 117 deletions

40
app.js
View File

@ -346,6 +346,9 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('touch-section').style.display = 'none'; document.getElementById('touch-section').style.display = 'none';
} }
let downloaderWorker = null;
let missingGameFiles = [];
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
Promise.all([ Promise.all([
configManager.init(), configManager.init(),
@ -370,6 +373,10 @@ document.addEventListener('DOMContentLoaded', function () {
if (navigator.serviceWorker && navigator.serviceWorker.controller) { if (navigator.serviceWorker && navigator.serviceWorker.controller) {
await requestPersistentStorage(); await requestPersistentStorage();
if (downloaderWorker) downloaderWorker.terminate();
downloaderWorker = new Worker('/downloader.js');
downloaderWorker.onmessage = handleWorkerMessage;
const selectedLanguage = languageSelect.value; const selectedLanguage = languageSelect.value;
installBtn.style.display = 'none'; installBtn.style.display = 'none';
uninstallBtn.style.display = 'none'; uninstallBtn.style.display = 'none';
@ -377,8 +384,9 @@ document.addEventListener('DOMContentLoaded', function () {
progressCircular.textContent = '0%'; progressCircular.textContent = '0%';
progressCircular.style.background = 'conic-gradient(#FFD700 0deg, #333 0deg)'; progressCircular.style.background = 'conic-gradient(#FFD700 0deg, #333 0deg)';
navigator.serviceWorker.controller.postMessage({ downloaderWorker.postMessage({
action: 'install_language_pack', action: 'install',
missingFiles: missingGameFiles,
language: selectedLanguage language: selectedLanguage
}); });
} }
@ -427,29 +435,39 @@ document.addEventListener('DOMContentLoaded', function () {
} }
function handleServiceWorkerMessage(event) { function handleServiceWorkerMessage(event) {
const { action, language, progress, exists, success } = event.data; const { action, language, isInstalled, success } = event.data;
if (language !== languageSelect.value) return; if (language && language !== languageSelect.value) return;
switch (action) { switch (action) {
case 'cache_status': case 'cache_status':
updateInstallUI(exists); missingGameFiles = event.data.missingFiles;
updateInstallUI(isInstalled);
break; break;
case 'uninstall_complete':
updateInstallUI(!success);
checkInitialCacheStatus();
break;
}
}
function handleWorkerMessage(event) {
const { action, progress, success, error } = event.data;
switch (action) {
case 'install_progress': case 'install_progress':
updateInstallUI(false, true); updateInstallUI(false, true);
const angle = (progress / 100) * 360; const angle = (progress / 100) * 360;
progressCircular.textContent = `${Math.round(progress)}%`; progressCircular.textContent = `${Math.round(progress)}%`;
progressCircular.style.background = progressCircular.style.background = `radial-gradient(#181818 60%, transparent 61%), conic-gradient(#FFD700 ${angle}deg, #333 ${angle}deg)`;
`radial-gradient(#181818 60%, transparent 61%), conic-gradient(#FFD700 ${angle}deg, #333 ${angle}deg)`;
break; break;
case 'install_complete': case 'install_complete':
updateInstallUI(success); updateInstallUI(success);
break; if (downloaderWorker) downloaderWorker.terminate();
case 'uninstall_complete':
updateInstallUI(!success);
break; break;
case 'install_failed': case 'install_failed':
alert('Download failed. Please check your internet connection and try again.'); alert(`Download failed: ${error}`);
updateInstallUI(false); updateInstallUI(false);
if (downloaderWorker) downloaderWorker.terminate();
break; break;
} }
} }

59
downloader.js Normal file
View File

@ -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 });
}
}
};

152
sw.js
View File

@ -16,7 +16,7 @@ const coreAppFiles = [
'/island.webp', '/isle.js', '/isle.wasm', '/poster.pdf', '/read_me_off.webp', '/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', '/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', '/uninstall_off.webp', '/uninstall_on.webp', '/app.js', '/style.css', '/manifest.json',
'/install.webp', '/install.mp3' '/install.webp', '/install.mp3', '/downloader.js'
]; ];
const gameFiles = [ const gameFiles = [
@ -33,22 +33,6 @@ const gameFiles = [
const STATIC_CACHE_NAME = 'static-assets-v1'; 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 rangeRequestsPlugin = new RangeRequestsPlugin();
const normalizePathPlugin = { const normalizePathPlugin = {
cacheKeyWillBeUsed: async ({ request }) => { 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}`; 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) { async function uninstallLanguagePack(language, client) {
const cacheName = getLanguageCacheName(language); const cacheName = getLanguageCacheName(language);
try { try {
@ -187,10 +84,40 @@ async function uninstallLanguagePack(language, client) {
async function checkCacheStatus(language, client) { async function checkCacheStatus(language, client) {
const cacheName = getLanguageCacheName(language); const cacheName = getLanguageCacheName(language);
const hasCache = await caches.has(cacheName); const cache = await caches.open(cacheName);
client.postMessage({ action: 'cache_status', exists: hasCache, language: language }); 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) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
@ -205,3 +132,16 @@ self.addEventListener('activate', (event) => {
); );
event.waitUntil(self.clients.claim()); 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;
}
}
});