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:
@@ -20,8 +20,8 @@ and a secret partner:
|
||||
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||||
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
||||
`web/vendor/` so builds work offline) plus the custom felt theme in
|
||||
`web/theme.css`. Card images are the
|
||||
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||
`web/theme.css`. Also an installable PWA — see [PWA](#pwa). Card images
|
||||
are the CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||
|
||||
## Run the whole stack
|
||||
@@ -77,6 +77,23 @@ the called card is played, so the secret partner stays secret. The
|
||||
the URL to share. A spectator who opens the URL before the match starts
|
||||
sees a "game not started yet" waiting room.
|
||||
|
||||
## PWA
|
||||
|
||||
The frontend is an installable PWA: `web/manifest.webmanifest` describes
|
||||
the app (standalone display, felt theme colors, PNG icons), and `web/sw.js`
|
||||
is an install-only service worker — it deliberately caches nothing, since
|
||||
live state always comes from the server and hashed asset names change every
|
||||
build. Browsers offer to install Tavolo over HTTPS (or on localhost in
|
||||
development).
|
||||
|
||||
Both files must be served from the site root, or the worker's scope and the
|
||||
manifest's `start_url` could not cover the app: Granian only serves
|
||||
`/static` and `/assets`, so `tavolo/static.py` answers
|
||||
`/manifest.webmanifest` and `/sw.js` (revalidated on every load) from the
|
||||
same build directory as the shell. Icons are committed under
|
||||
`web/assets/icons/`; `web/make-icons.sh` regenerates them with
|
||||
`rsvg-convert` after editing the vector sources.
|
||||
|
||||
## Development
|
||||
|
||||
Backend (from `server/`):
|
||||
@@ -115,4 +132,6 @@ OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||
```
|
||||
|
||||
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||
re-downloads them if needed.
|
||||
re-downloads them if needed. PWA icons are committed under
|
||||
`web/assets/icons/`; `web/make-icons.sh` regenerates them from the SVG
|
||||
sources.
|
||||
|
||||
@@ -30,6 +30,9 @@ WORKDIR /web
|
||||
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
||||
# web/fetch-cards.sh can regenerate them.
|
||||
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/theme.css web/Trunk.toml ./
|
||||
# PWA manifest and service worker: trunk's copy-file entries need them at
|
||||
# the build root, and the runtime serves both from the site root (static.py).
|
||||
COPY web/manifest.webmanifest web/sw.js ./
|
||||
COPY web/assets ./assets
|
||||
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
|
||||
# image builds and runs without network access.
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"""SPA shell hosting for the compiled single-page application.
|
||||
|
||||
Static assets (wasm, js, css, card images) are served by Granian itself
|
||||
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
||||
Dockerfile) and never reach Python. This module only serves ``index.html``
|
||||
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||
Static assets (wasm, js, css, card images, PWA icons) are served by
|
||||
Granian itself under the ``/static`` and ``/assets`` prefixes
|
||||
(``GRANIAN_STATIC_PATH_*`` env vars; see the Dockerfile) and never reach
|
||||
Python. This module serves the few files that must live at the site root
|
||||
from ``STATIC_DIR``:
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||
in the API specification.
|
||||
- ``index.html`` at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes;
|
||||
- the web app manifest, whose ``start_url``/``scope`` cover the app;
|
||||
- the service worker, whose default scope is its own directory: served
|
||||
from ``/static`` it could only control ``/static``, so it is served at
|
||||
``/sw.js`` instead.
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so the fallback handler
|
||||
never appears in the API specification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
@@ -21,14 +29,30 @@ from .app import app
|
||||
from .config import settings
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
index = Path(settings.static_dir) / "index.html"
|
||||
if not index.is_file():
|
||||
async def _send_from_dist(ctx: HttpContext,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
cache_control: Optional[str] = None) -> None:
|
||||
"""Serve one file from ``STATIC_DIR``, or 404 when the build lacks it.
|
||||
|
||||
Unlike the SPA fallback, a missing file is not silently replaced by
|
||||
the app shell: a manifest or worker answer with the wrong content
|
||||
type would only confuse the browser.
|
||||
"""
|
||||
file = Path(settings.static_dir) / filename
|
||||
if not file.is_file():
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
body = await asyncio.to_thread(index.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||
body = await asyncio.to_thread(file.read_bytes)
|
||||
headers = {"content-type": (content_type,)}
|
||||
if cache_control is not None:
|
||||
headers["cache-control"] = (cache_control,)
|
||||
await ctx.send_bytes(200, body, headers)
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
await _send_from_dist(ctx, "index.html", "text/html; charset=utf-8")
|
||||
|
||||
|
||||
@app.GET("/")
|
||||
@@ -38,6 +62,25 @@ async def index(ctx: HttpContext) -> None:
|
||||
await _send_shell(ctx)
|
||||
|
||||
|
||||
@app.GET("/manifest.webmanifest")
|
||||
async def manifest(ctx: HttpContext) -> None:
|
||||
"""Serve the web app manifest (linked from index.html)."""
|
||||
await _send_from_dist(ctx, "manifest.webmanifest", "application/manifest+json")
|
||||
|
||||
|
||||
@app.GET("/sw.js")
|
||||
async def service_worker(ctx: HttpContext) -> None:
|
||||
"""Serve the service worker from the site root so its scope is the
|
||||
whole app. The script is revalidated on every load (no-cache) so a new
|
||||
build's worker takes over promptly."""
|
||||
await _send_from_dist(
|
||||
ctx,
|
||||
"sw.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
cache_control="no-cache",
|
||||
)
|
||||
|
||||
|
||||
@app.GET("/*", recursive=True)
|
||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||
"""SPA fallback: any path that matched no other route renders the app
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage", "ServiceWorkerContainer"] }
|
||||
js-sys = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<!-- Maskable variant: the felt background bleeds to the edges and the
|
||||
spade shrinks inside the 80% safe zone, so platform masks (circles,
|
||||
squircles, rounded squares) never clip the glyph. Also rasterized
|
||||
into the 180x180 apple-touch-icon, where iOS applies its own mask. -->
|
||||
<defs>
|
||||
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0" stop-color="#1d5c38"/>
|
||||
<stop offset="1" stop-color="#14341f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#felt)"/>
|
||||
<path fill="#e8c547" transform="translate(128 134) scale(16)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<!-- Tavolo app icon: the gold spade from the navbar on dark felt.
|
||||
The spade glyph is Bootstrap Icons' "suit-spade-fill" (MIT), the
|
||||
same license as the vendored Bootstrap CSS. Rasterized into the
|
||||
PNG sizes by make-icons.sh (rsvg-convert); not used directly at
|
||||
runtime except as the SVG favicon. -->
|
||||
<defs>
|
||||
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0" stop-color="#1d5c38"/>
|
||||
<stop offset="1" stop-color="#14341f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#felt)"/>
|
||||
<path fill="#e8c547" transform="translate(96 104) scale(20)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1022 B |
+16
-1
@@ -4,12 +4,27 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tavolo</title>
|
||||
<!-- PWA: installable app shell. The manifest and the (install-only)
|
||||
service worker are served from the site root, where the worker's
|
||||
default scope covers the whole app; the Python host routes them in
|
||||
production, trunk serve serves them in development. -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#14341f">
|
||||
<link rel="icon" href="/assets/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/assets/icons/icon-192.png" sizes="192x192" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Tavolo">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link data-trunk rel="rust" href="Cargo.toml">
|
||||
<link data-trunk rel="copy-file" href="manifest.webmanifest">
|
||||
<link data-trunk rel="copy-file" href="sw.js">
|
||||
<!-- Bootstrap 5.3.3 (MIT), vendored so builds work offline. -->
|
||||
<link data-trunk rel="css" href="vendor/bootstrap.min.css">
|
||||
<link data-trunk rel="css" href="theme.css">
|
||||
<link data-trunk rel="css" href="style.css">
|
||||
<!-- Card images (CC0 woodcut napoletane deck) copied verbatim into dist. -->
|
||||
<!-- Card images (CC0 woodcut napoletane deck) and the PWA icons
|
||||
(generated by make-icons.sh) copied verbatim into dist. -->
|
||||
<link data-trunk rel="copy-dir" href="assets">
|
||||
</head>
|
||||
<body></body>
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Regenerate the PWA icon PNGs from the vector sources in assets/icons/.
|
||||
# The PNGs are committed (like the card art) because the Docker builder has
|
||||
# no SVG rasterizer; run this only after editing icon.svg or
|
||||
# icon-maskable.svg. Requires rsvg-convert (librsvg).
|
||||
set -eu
|
||||
|
||||
ICONS="$(dirname "$0")/assets/icons"
|
||||
|
||||
rsvg-convert -w 192 -h 192 "$ICONS/icon.svg" -o "$ICONS/icon-192.png"
|
||||
rsvg-convert -w 512 -h 512 "$ICONS/icon.svg" -o "$ICONS/icon-512.png"
|
||||
rsvg-convert -w 512 -h 512 "$ICONS/icon-maskable.svg" -o "$ICONS/icon-maskable-512.png"
|
||||
# iOS masks the home-screen icon itself; a full-bleed square is expected.
|
||||
rsvg-convert -w 180 -h 180 "$ICONS/icon-maskable.svg" -o "$ICONS/apple-touch-icon.png"
|
||||
|
||||
echo "icons ready in $ICONS"
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Tavolo",
|
||||
"short_name": "Tavolo",
|
||||
"description": "Multiplayer card games: scopone, scopa and briscola.",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#14341f",
|
||||
"theme_color": "#14341f",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod auth;
|
||||
mod components;
|
||||
mod model;
|
||||
mod pages;
|
||||
mod pwa;
|
||||
mod ws;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
@@ -91,5 +92,6 @@ fn main() {
|
||||
)
|
||||
}
|
||||
});
|
||||
pwa::register();
|
||||
restore_page_after_login();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Service worker registration.
|
||||
//!
|
||||
//! Registering `/sw.js` is what makes the app installable as a PWA; the
|
||||
//! worker itself is install-only (no offline caching — see `web/sw.js`).
|
||||
//! Registration is best-effort: browsers without service worker support,
|
||||
//! and insecure origins other than localhost, simply skip it.
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
|
||||
/// Register the service worker, if the browser supports it.
|
||||
pub fn register() {
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let container = window.navigator().service_worker();
|
||||
spawn_local(async move {
|
||||
// Rejections (e.g. insecure origin) are ignored: the app works
|
||||
// without a service worker, it just is not installable.
|
||||
let _ = JsFuture::from(container.register("/sw.js")).await;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Tavolo service worker: installability only, no offline caching.
|
||||
//
|
||||
// The app is a thin client for a live game server: state arrives over
|
||||
// /api and /ws and is never safe to replay from a cache, and a new build
|
||||
// changes the hashed asset names, so stale cached files would break the
|
||||
// app until the cache expired. The worker therefore only performs the
|
||||
// passthrough fetch below — enough for the browser to treat the app as
|
||||
// installable — and leaves every request's caching to the network stack.
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// A new worker takes over on the next reload instead of waiting for
|
||||
// every tab of the old version to close.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
// Let the browser handle other origins and the live endpoints directly
|
||||
// (respondWith would only add a hop; /auth navigations must be allowed
|
||||
// to follow their redirects untouched).
|
||||
if (
|
||||
url.origin !== self.location.origin ||
|
||||
url.pathname.startsWith("/api/") ||
|
||||
url.pathname.startsWith("/auth/") ||
|
||||
url.pathname.startsWith("/ws")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
Reference in New Issue
Block a user