Make the frontend an installable PWA
CI / Build and push docker image (push) Successful in 1m54s

Add a web app manifest and an install-only service worker (no offline
caching: live state always comes from the server and hashed asset names
change every build), registered from the WASM bundle. The worker must
live at the site root for its scope to cover the app, and Granian only
serves /static and /assets, so tavolo/static.py answers
/manifest.webmanifest and /sw.js from STATIC_DIR with the content types
browsers require; index.html gains the manifest link, icons, theme-color
and Apple standalone meta tags.

Icons are committed under web/assets/icons/ with vector sources and a
make-icons.sh regeneration script, mirroring the card art workflow.
This commit is contained in:
2026-10-15 06:16:11 +08:00
parent 46f10f145a
commit 517ac6fb46
17 changed files with 271 additions and 19 deletions
+40
View File
@@ -81,6 +81,46 @@ class StaticRouteTest(unittest.TestCase):
self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content)
@async_test
async def test_serves_pwa_manifest_and_worker(self) -> None:
"""The manifest and the service worker are served from the site
root, with the content types browsers require, instead of falling
through to the app shell."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
(Path(dist) / "manifest.webmanifest").write_text('{"name": "Tavolo"}')
(Path(dist) / "sw.js").write_text("self.addEventListener('fetch', () => {});")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
manifest = await client.get("/manifest.webmanifest")
self.assertEqual(200, manifest.status_code)
self.assertEqual("application/manifest+json", manifest.headers["content-type"])
self.assertEqual('{"name": "Tavolo"}', manifest.text)
worker = await client.get("/sw.js")
self.assertEqual(200, worker.status_code)
self.assertEqual("text/javascript; charset=utf-8", worker.headers["content-type"])
# The worker script itself must never be cached stale.
self.assertEqual("no-cache", worker.headers["cache-control"])
self.assertIn(b"addEventListener", worker.content)
@async_test
async def test_missing_pwa_files_return_404(self) -> None:
"""A build without the PWA files must 404 those exact paths rather
than answer them with the shell (the wrong content type)."""
with tempfile.TemporaryDirectory() as dist:
(Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
self.assertEqual(404, (await client.get("/manifest.webmanifest")).status_code)
self.assertEqual(404, (await client.get("/sw.js")).status_code)
@async_test
async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")