From 3057201102e0cc959d6e3185ff195d3c53ed9467 Mon Sep 17 00:00:00 2001 From: Steve Dogiakos Date: Sun, 29 Mar 2026 19:20:06 -0600 Subject: [PATCH] feat: add PWA manifest and service worker routes Serves /manifest.webmanifest dynamically from Flask so SITE_TITLE and LOGO_URL env vars flow into the manifest at runtime. Serves /sw.js from /static/sw.js with Service-Worker-Allowed: / header to allow root scope. Service worker caches static assets and passes all app routes to network. --- app.py | 38 ++++++++++++++++++++++++++++++++++++++ static/sw.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 static/sw.js diff --git a/app.py b/app.py index e2349c0..184a488 100644 --- a/app.py +++ b/app.py @@ -520,6 +520,44 @@ def api_guests(): ] return jsonify(guests) +# --------------------------------------------------------------------------- +# PWA +# --------------------------------------------------------------------------- + +@app.route('/manifest.webmanifest') +@csrf.exempt +def pwa_manifest(): + import json as _json + site_title = os.environ.get('SITE_TITLE', 'Guestbook') + logo_url = os.environ.get('LOGO_URL', '/static/images/logo.png') + manifest = { + "name": site_title, + "short_name": site_title, + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3a9cb8", + "orientation": "any", + "icons": [ + { + "src": logo_url, + "sizes": "500x500", + "type": "image/png", + "purpose": "any maskable" + } + ] + } + from flask import Response + return Response(_json.dumps(manifest), mimetype='application/manifest+json') + +@app.route('/sw.js') +@csrf.exempt +def service_worker(): + response = app.send_static_file('sw.js') + response.headers['Service-Worker-Allowed'] = '/' + response.headers['Cache-Control'] = 'no-cache' + return response + if __name__ == '__main__': migrate_db() logger.info("Starting development server at http://0.0.0.0:8000") diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..241efe2 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,49 @@ +const CACHE_NAME = 'guestbook-v1'; +const STATIC_ASSETS = [ + 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', + 'https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js', + 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js', + 'https://fonts.googleapis.com/css2?family=Vollkorn:wght@700&family=Open+Sans&display=swap', + '/static/images/logo.png', +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + + // Cache-first for CDN and local static assets + const isStatic = STATIC_ASSETS.includes(event.request.url) || + (url.origin === self.location.origin && url.pathname.startsWith('/static/')); + + if (isStatic) { + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) return cached; + return fetch(event.request).then(response => { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); + return response; + }); + }) + ); + return; + } + + // Network-only for all app routes (/, /admin/*, /api/*, /manifest.webmanifest) + // No caching of authenticated or dynamic content +});