Skip to content

Commit 3b2c566

Browse files
feat: add progressive web app (PWA) support with service worker and manifest (steam-bell-92#1717)
1 parent 7a9faa4 commit 3b2c566

4 files changed

Lines changed: 165 additions & 0 deletions

File tree

web-app/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
<meta content="Interactive games, math visualizations, and utilities in the browser." name="twitter:description" />
3030
<title>Python Mini Projects — Interactive Web Edition</title>
3131
<link href="assets/favicon.svg" rel="icon" type="image/svg+xml" />
32+
<link rel="manifest" href="manifest.json" />
3233
<link href="css/styles.css" rel="stylesheet" />
3334
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
3435
integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
@@ -1733,6 +1734,15 @@ <h3 id="confirmModalTitle">Confirm</h3>
17331734
</div>
17341735
</div>
17351736

1737+
<script>
1738+
if ('serviceWorker' in navigator) {
1739+
window.addEventListener('load', () => {
1740+
navigator.serviceWorker.register('./service-worker.js')
1741+
.then((reg) => console.log('Service Worker registered:', reg.scope))
1742+
.catch((err) => console.error('Service Worker registration failed:', err));
1743+
});
1744+
}
1745+
</script>
17361746
</body>
17371747

17381748
</html>

web-app/manifest.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "Python Mini Projects",
3+
"short_name": "PythonProjects",
4+
"description": "Play beginner-friendly Python mini projects in your browser offline.",
5+
"start_url": "./index.html",
6+
"display": "standalone",
7+
"background_color": "#0c0f1a",
8+
"theme_color": "#0c0f1a",
9+
"orientation": "any",
10+
"icons": [
11+
{
12+
"src": "assets/favicon.svg",
13+
"sizes": "any",
14+
"type": "image/svg+xml",
15+
"purpose": "any maskable"
16+
}
17+
]
18+
}

web-app/service-worker.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
const CACHE_NAME = 'python-mini-projects-v1';
2+
3+
const PRECACHE_ASSETS = [
4+
'./',
5+
'./index.html',
6+
'./games.html',
7+
'./math.html',
8+
'./utilities.html',
9+
'./faq.html',
10+
'./privacy-policy.html',
11+
'./terms-condition.html',
12+
'./404.html',
13+
'./manifest.json',
14+
'./css/styles.css',
15+
'./assets/favicon.svg',
16+
'./assets/games-bg.webp',
17+
'./assets/math-bg.webp',
18+
'./assets/utilities-bg.webp',
19+
'./js/main.js',
20+
'./js/projects.js',
21+
'./js/playground.js',
22+
'./js/playground-worker.js',
23+
'./js/cm-editor.js',
24+
'./js/audio.js',
25+
'./js/audioManager.js',
26+
'./projects_registry.json'
27+
];
28+
29+
const PYODIDE_CDN_PREFIX = 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/';
30+
const PYODIDE_ASSETS = [
31+
PYODIDE_CDN_PREFIX + 'pyodide.js',
32+
PYODIDE_CDN_PREFIX + 'pyodide.asm.js',
33+
PYODIDE_CDN_PREFIX + 'pyodide.asm.wasm',
34+
PYODIDE_CDN_PREFIX + 'python_stdlib.zip',
35+
PYODIDE_CDN_PREFIX + 'pyodide-lock.json'
36+
];
37+
38+
// Install Event - Pre-cache core app shell & Pyodide assets
39+
self.addEventListener('install', (event) => {
40+
event.waitUntil(
41+
caches.open(CACHE_NAME).then(async (cache) => {
42+
await cache.addAll(PRECACHE_ASSETS);
43+
try {
44+
await cache.addAll(PYODIDE_ASSETS);
45+
} catch (err) {
46+
console.warn('Pyodide caching skipped or partial during SW install:', err);
47+
}
48+
}).then(() => self.skipWaiting())
49+
);
50+
});
51+
52+
// Activate Event - Clean up old caches
53+
self.addEventListener('activate', (event) => {
54+
event.waitUntil(
55+
caches.keys().then((cacheNames) => {
56+
return Promise.all(
57+
cacheNames
58+
.filter((name) => name !== CACHE_NAME)
59+
.map((name) => caches.delete(name))
60+
);
61+
}).then(() => self.clients.claim())
62+
);
63+
});
64+
65+
// Fetch Event - Serve from Cache, fallback to Network (and cache dynamically)
66+
self.addEventListener('fetch', (event) => {
67+
if (event.request.method !== 'GET') return;
68+
69+
const url = new URL(event.request.url);
70+
71+
// Network-first strategy for navigation requests (HTML pages)
72+
if (event.request.mode === 'navigate') {
73+
event.respondWith(
74+
fetch(event.request)
75+
.then((networkResponse) => {
76+
if (networkResponse && networkResponse.status === 200) {
77+
const responseClone = networkResponse.clone();
78+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
79+
}
80+
return networkResponse;
81+
})
82+
.catch(() => {
83+
return caches.match(event.request).then((cachedResponse) => {
84+
return cachedResponse || caches.match('./index.html');
85+
});
86+
})
87+
);
88+
return;
89+
}
90+
91+
// Cache-first strategy for static resources & Pyodide CDN assets
92+
event.respondWith(
93+
caches.match(event.request).then((cachedResponse) => {
94+
if (cachedResponse) {
95+
return cachedResponse;
96+
}
97+
return fetch(event.request).then((networkResponse) => {
98+
if (
99+
networkResponse &&
100+
networkResponse.status === 200 &&
101+
(url.origin === self.location.origin ||
102+
url.hostname === 'cdn.jsdelivr.net' ||
103+
url.hostname === 'cdnjs.cloudflare.com' ||
104+
url.hostname === 'fonts.googleapis.com' ||
105+
url.hostname === 'fonts.gstatic.com')
106+
) {
107+
const responseClone = networkResponse.clone();
108+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
109+
}
110+
return networkResponse;
111+
});
112+
})
113+
);
114+
});

web-app/tests-e2e/pwa.spec.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const { test, expect } = require('@playwright/test');
2+
3+
test.describe('PWA Capability and Offline Support', () => {
4+
test('should reference manifest.json and register service worker', async ({ page }) => {
5+
await page.goto('/');
6+
7+
// Check manifest link exists in head
8+
const manifestLink = page.locator('link[rel="manifest"]');
9+
await expect(manifestLink).toHaveAttribute('href', 'manifest.json');
10+
11+
// Fetch manifest file to verify its structure
12+
const response = await page.goto('/manifest.json');
13+
expect(response.status()).toBe(200);
14+
const manifest = await response.json();
15+
expect(manifest.name).toBe('Python Mini Projects');
16+
expect(manifest.start_url).toBe('./index.html');
17+
expect(manifest.display).toBe('standalone');
18+
19+
// Fetch service-worker.js to verify it exists
20+
const swResponse = await page.goto('/service-worker.js');
21+
expect(swResponse.status()).toBe(200);
22+
});
23+
});

0 commit comments

Comments
 (0)