Make the frontend an installable PWA

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-14 22:14:43 +00:00
parent 532cb16f94
commit 8c6ad10bf6
17 changed files with 271 additions and 19 deletions
+1 -1
View File
@@ -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

+14
View File
@@ -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

+15
View File
@@ -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
View File
@@ -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>
+16
View File
@@ -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"
+31
View File
@@ -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"
}
]
}
+2
View File
@@ -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();
}
+20
View File
@@ -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;
});
}
+34
View File
@@ -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));
});