feat: add PWA support (manifest, service worker, install prompt) (#920)

* feat: add PWA support (manifest, service worker, install prompt) (v0.50.178, #911)

Co-authored-by: bsgdigital
Closes #685

* fix(sw): await caches.match() before `|| fallback` so offline HTML actually shows

The offline-navigation fallback was dead code:

    return caches.match('./') || new Response('<html>...</html>', ...);

`caches.match()` returns a Promise, and Promise objects are always truthy
in a `||` check — so the `new Response(...)` branch was never taken. On
actual offline, `caches.match('./')` resolves to undefined (no cache hit
for the root), the SW returns undefined, and the browser falls back to
its own default offline page. The custom "Hermes requires a server
connection" HTML was unreachable.

Fix by threading the match through `.then()` so the resolved value (not
the Promise object) feeds the `||`:

    return caches.match('./').then((cached) => cached || new Response(...));

Added 13 regression tests in tests/test_pwa_manifest_sw.py covering:
- manifest.json validity + required PWA fields + icon existence
- sw.js cache-version placeholder + API/stream bypass + correct offline
  pattern (explicitly rejects the broken `|| new Response` shape so it
  can't regress)
- /manifest.json + /sw.js routes serve correct Content-Type,
  Cache-Control, Service-Worker-Allowed headers and inject WEBUI_VERSION
- index.html links manifest, registers SW, has iOS PWA meta tags

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-23 15:14:21 -07:00
committed by GitHub
parent 07caaec6ef
commit 1011918d50
6 changed files with 336 additions and 0 deletions

View File

@@ -576,6 +576,41 @@ def handle_get(handler, parsed) -> bool:
logged_in = bool(cv and verify_session(cv))
return j(handler, {"auth_enabled": is_auth_enabled(), "logged_in": logged_in})
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
static_root = Path(__file__).parent.parent / "static"
manifest_path = (static_root / "manifest.json").resolve()
if manifest_path.exists():
data = manifest_path.read_bytes()
handler.send_response(200)
handler.send_header("Content-Type", "application/manifest+json; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.send_header("Content-Length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
return True
return j(handler, {"error": "not found"}, status=404)
if parsed.path == "/sw.js":
static_root = Path(__file__).parent.parent / "static"
sw_path = (static_root / "sw.js").resolve()
if sw_path.exists():
# Inject the current git-derived version as the cache name so the
# service worker cache busts automatically on every new deploy.
from api.updates import WEBUI_VERSION
text = sw_path.read_text(encoding="utf-8").replace(
"__CACHE_VERSION__", WEBUI_VERSION
)
data = text.encode("utf-8")
handler.send_response(200)
handler.send_header("Content-Type", "application/javascript; charset=utf-8")
handler.send_header("Cache-Control", "no-store")
handler.send_header("Service-Worker-Allowed", "/")
handler.send_header("Content-Length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
return True
return j(handler, {"error": "not found"}, status=404)
if parsed.path == "/favicon.ico":
static_root = Path(__file__).parent.parent / "static"
ico_path = (static_root / "favicon.ico").resolve()