diff --git a/README.md b/README.md index 7df91f9..21c2d7c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/server/Dockerfile b/server/Dockerfile index d929eb0..19b6d0b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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. diff --git a/server/packages/tavolo-app/src/tavolo/static.py b/server/packages/tavolo-app/src/tavolo/static.py index 067525a..72bde7e 100644 --- a/server/packages/tavolo-app/src/tavolo/static.py +++ b/server/packages/tavolo-app/src/tavolo/static.py @@ -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/`` 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/`` 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 diff --git a/server/tests/test_routes_me.py b/server/tests/test_routes_me.py index 619d0bc..a829043 100644 --- a/server/tests/test_routes_me.py +++ b/server/tests/test_routes_me.py @@ -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("spa") + (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("spa") + + 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") diff --git a/web/Cargo.toml b/web/Cargo.toml index fdaa36f..38c4c55 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -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" diff --git a/web/assets/icons/apple-touch-icon.png b/web/assets/icons/apple-touch-icon.png new file mode 100644 index 0000000..da128b3 Binary files /dev/null and b/web/assets/icons/apple-touch-icon.png differ diff --git a/web/assets/icons/icon-192.png b/web/assets/icons/icon-192.png new file mode 100644 index 0000000..3c27bfb Binary files /dev/null and b/web/assets/icons/icon-192.png differ diff --git a/web/assets/icons/icon-512.png b/web/assets/icons/icon-512.png new file mode 100644 index 0000000..7d289b2 Binary files /dev/null and b/web/assets/icons/icon-512.png differ diff --git a/web/assets/icons/icon-maskable-512.png b/web/assets/icons/icon-maskable-512.png new file mode 100644 index 0000000..87f8f91 Binary files /dev/null and b/web/assets/icons/icon-maskable-512.png differ diff --git a/web/assets/icons/icon-maskable.svg b/web/assets/icons/icon-maskable.svg new file mode 100644 index 0000000..d830e61 --- /dev/null +++ b/web/assets/icons/icon-maskable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/web/assets/icons/icon.svg b/web/assets/icons/icon.svg new file mode 100644 index 0000000..f9fa1d2 --- /dev/null +++ b/web/assets/icons/icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/web/index.html b/web/index.html index 1280622..69df316 100644 --- a/web/index.html +++ b/web/index.html @@ -4,12 +4,27 @@ Tavolo + + + + + + + + + + + - + diff --git a/web/make-icons.sh b/web/make-icons.sh new file mode 100755 index 0000000..260edf7 --- /dev/null +++ b/web/make-icons.sh @@ -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" diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..641be49 --- /dev/null +++ b/web/manifest.webmanifest @@ -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" + } + ] +} diff --git a/web/src/main.rs b/web/src/main.rs index ed0c83a..2a9774c 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -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(); } diff --git a/web/src/pwa.rs b/web/src/pwa.rs new file mode 100644 index 0000000..fa76d21 --- /dev/null +++ b/web/src/pwa.rs @@ -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; + }); +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..d576ed0 --- /dev/null +++ b/web/sw.js @@ -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)); +});