Skip to content

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

Merged
nesquena-hermes merged 2 commits intomasterfrom
fix/911-pwa
Apr 23, 2026
Merged

feat: add PWA support (manifest, service worker, install prompt)#920
nesquena-hermes merged 2 commits intomasterfrom
fix/911-pwa

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

PWA installability for Hermes WebUI. From PR #911 (@bsgdigital), rebased onto current master.

Enables "Add to Home Screen" on Android, iOS, and desktop Chrome:

  • static/manifest.json — name, standalone display, dark theme color, SVG + 32px icons
  • static/sw.js — cache-first for app shell assets; all /api/* + /stream + /health always bypass to network; no offline API caching; cache name injected at request-time with git version for automatic busting
  • static/index.html — manifest link, Apple PWA meta tags, SW registration script
  • api/routes.py — dedicated routes for /manifest.json, /manifest.webmanifest, /sw.js with correct MIME types and no-store headers

No offline mode — the UI requires a live backend. The service worker enables installability and repeat-visit shell caching only.

2003 tests passing.

Closes #685
Co-authored-by: bsgdigital

Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — end-to-end ✅ (one dead-code offline-fallback bug fixed)

Traced against upstream hermes-agent

Fresh nousresearch/hermes-agent tarball pulled. Confirmed this PR is webui-internal: new manifest.json, new sw.js, index.html meta tags + SW registration, and api/routes.py GET handlers for serving both files. No hermes-agent interaction. No config.yaml writes, no session state.

End-to-end trace

manifest.json (static/manifest.json)

  • start_url: "./" — relative, works with subpath mounts ✓
  • display: "standalone" — installable PWA ✓
  • icons: static/favicon.svg (any + maskable) + static/favicon-32.png — both files exist in the repo ✓
  • theme_color + background_color match the app's dark theme

sw.js (static/sw.js)

Fetch strategy traced for every request class:

  • Cross-origin → untouched (line 64: if (url.origin !== self.location.origin) return;)
  • /api/*, */stream*, /health* → always network (line 67-71: early return, no caching)
  • Shell assets (cache-first with network fallback, cache on success, line 78-88)
  • Navigation on offline (cache-first for './', then fallback HTML)

Cross-checked the API/stream bypass list against api/routes.py: all SSE streaming endpoints live under /api/* (/api/chat/stream/status, /api/chat/stream, /api/sessions/gateway/stream). The startsWith('/api/') check catches all of them.

Routes (api/routes.py:579-611)

  • /manifest.json + /manifest.webmanifest → 200 with Content-Type: application/manifest+json, Cache-Control: no-store
  • /sw.js → 200 with Content-Type: application/javascript, Cache-Control: no-store, Service-Worker-Allowed: /
  • Both use read_bytes() / read_text() with a resolved path inside static/ — no path traversal
  • WEBUI_VERSION from api/updates.py:95 is the git describe --tags --always --dirty output or baked-in __version__ from api/_version.py. Values like v0.50.175, v0.50.175-1-ge91325d, or unknown — all safe for substring replacement into const CACHE_NAME = 'hermes-shell-...' (git forbids tag names containing quotes/backslashes/newlines)

index.html (static/index.html:10-36)

  • <link rel="manifest" href="manifest.json"> — relative, works with subpath mount + <base href> pattern already in the page
  • Apple PWA meta tags present (apple-mobile-web-app-capable, apple-mobile-web-app-status-bar-style, apple-touch-icon)
  • SW registration script: navigator.serviceWorker.register('sw.js') — relative, default scope = directory of sw.js (root at root-mount, /hermes/ at subpath mount)

What I caught — offline fallback was dead code

The original fetch handler offline fallback:

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

caches.match() returns a Promise, which is always truthy in a || check. So new Response(...) was never evaluated — on actual offline, caches.match('./') resolves to undefined (no cache hit for the root), the Promise is returned as-is, the SW's event.respondWith resolves to undefined, and the browser falls back to its own default offline page. The hardcoded "Hermes requires a server connection" HTML was unreachable.

What I pushed — ecf2f93

Threaded the match through .then() so the resolved value feeds the ||:

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

Added 13 regression tests in tests/test_pwa_manifest_sw.py:

  • test_sw_offline_fallback_awaits_caches_match — explicitly rejects the broken caches.match() || new Response(...) shape via regex so it can't regress
  • manifest.json validity + required PWA fields + icon-file existence
  • sw.js __CACHE_VERSION__ placeholder + API/stream bypass + no-cache of API responses
  • Routes serve correct Content-Type / Cache-Control / Service-Worker-Allowed / inject WEBUI_VERSION
  • index.html links manifest, registers SW, has iOS PWA meta tags

Security audit

  • No secrets in manifest or SW: both are pure client-config files
  • Subpath-mount safety: relative paths (./, manifest.json, sw.js) honor <base href>. SW scope defaults to the directory of sw.js, so at root mount = /, at subpath = /subpath/ — no cross-app SW interception on shared hosts
  • Service-Worker-Allowed: / header: expands the MAX scope the SW can request. Since register() is called without an explicit {scope} option, actual scope stays at the SW file's directory (safe default). The header is a defensive allowance, not a forced broadening
  • API response caching: explicitly bypassed (/api/*, */stream*, /health*) — no chance of stale auth responses, no session-cookie-keyed content ending up in shared cache
  • Cache-bust on deploy: __CACHE_VERSION__ substitution means every new WEBUI_VERSION produces a new cache name. Old caches are deleted in the activate handler. Clean on deploy
  • Custom offline HTML: hardcoded, no user data interpolation ✓
  • Path traversal in routes: (static_root / "manifest.json").resolve() — the .resolve() normalizes the path, but the match against the literal filename + the fact the requesting path is compared as a hardcoded string (parsed.path == "/manifest.json") means there's no user-controlled path component at all
  • application/javascript MIME for SW: required by spec, present ✓

Cross-tool (CLI) check

  • manifest.json / sw.js are client-only static files
  • api/routes.py GET handlers serve them as-is
  • CLI never hits /manifest.json, /sw.js, or registers service workers
  • No state shared with CLI ✓

Edge-case trace

Scenario Behaviour
First load on Chrome Android Manifest links, SW registers, "Add to Home Screen" prompt available ✅
Subpath mount (/hermes/) Relative paths resolve via <base href>; routes match after reverse-proxy path-strip; SW scope = /hermes/ (default) ✅
Deploy new version (WEBUI_VERSION changes) Every request to /sw.js returns fresh SW with new cache name; activate handler deletes old caches ✅
Offline navigation to / Before fix: `caches.match('./')
Offline navigation to cached shell asset caches.match(event.request) hits → cached asset served ✅
Authenticated user hits /api/chat/stream SW early-returns on /api/* → no interception, direct network ✅
Shared device: user A logs in, logs out, user B loads app Shell is cached (no auth state), client JS checks /api/auth/status (network-bypass) → user B sees login UI ✅
Service worker install fails addAll(SHELL_ASSETS).catch(...) logs non-fatal, activation proceeds ✅
Service worker update with non-matching CACHE_NAME Old cache deleted in activate handler (keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) ✅
/api/auth/login (POST) offline SW doesn't intercept /api/* → browser sends fetch → fails network → app handles normally ✅

Tests

  • 13/13 pass in new tests/test_pwa_manifest_sw.py
  • node --check static/sw.js — clean
  • python3 -c "import json; json.load(open('static/manifest.json'))" — valid JSON
  • Full local suite: 1969 passed, 47 skipped, 0 failed

Minor observations (non-blocking)

  • The url.pathname.includes('/stream') check at sw.js:69 is a substring match. For today's codebase it's safe because all streaming endpoints are under /api/* (caught by the earlier startsWith('/api/') check), but a future static asset at a path containing /stream (e.g. /static/test/stream.js) would accidentally bypass SW caching. Consider tightening to url.pathname.startsWith('/api/') alone or startsWith('/stream'). Non-urgent.
  • The SW's .catch block only handles the navigate mode; non-navigate requests that fail (e.g. a missed shell asset while offline) return undefined → browser shows the generic fetch error. Acceptable for a non-offline app.
  • SHELL_ASSETS hardcodes the list of JS files (boot.js, ui.js, messages.js, etc.). If a new JS file is added later, it won't be pre-cached until someone updates this list. Consider a build-time generation, or a dynamic-discovery pattern. Non-urgent.
  • No icon file larger than 32px in the manifest. For high-DPI Android / desktop Chrome, a 192px and 512px icon is conventional (and listed in the PWA manifest spec as "recommended"). Could be a polish follow-up.
  • WEBUI_VERSION == 'unknown' fallback case — if git fails AND no api/_version.py exists, the cache name becomes hermes-shell-unknown. Multiple deploys in this state share a cache, so stale shell would survive across deploys. Edge case; production builds should always have one or the other set.

Recommendation

Well-scoped PWA support. One real functional bug caught (dead-code offline fallback) and fixed with regression coverage. Security posture is clean: correct API bypass, no auth-keyed caching, subpath-mount safe, cache-bust on every deploy. Test coverage is thorough enough to lock the key invariants.

Approved after the fix. Ready for merge + v0.50.178 tag.

nesquena-hermes and others added 2 commits April 23, 2026 22:13
…tually 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) <[email protected]>
@nesquena-hermes nesquena-hermes merged commit 1011918 into master Apr 23, 2026
3 checks passed
@nesquena-hermes nesquena-hermes deleted the fix/911-pwa branch April 24, 2026 01:43
24601 added a commit to 24601/hermes-webui that referenced this pull request Apr 24, 2026
PR nesquena#920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

Adds manifest-src 'self' before base-uri. No origin set is changed.
Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py.

Co-authored with Claude Sonnet 4.6 / Anthropic.
24601 added a commit to 24601/hermes-webui that referenced this pull request Apr 24, 2026
PR nesquena#920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

Adds manifest-src 'self' before base-uri. No origin set is changed.
Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py.

Co-authored with Claude Sonnet 4.6 / Anthropic.
nesquena-hermes pushed a commit that referenced this pull request Apr 24, 2026
PR #920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

Adds manifest-src 'self' before base-uri. No origin set is changed.
Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py.

Co-authored with Claude Sonnet 4.6 / Anthropic.
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
…quena#920)

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

Co-authored-by: bsgdigital
Closes nesquena#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) <[email protected]>

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
PR nesquena#920 added static/manifest.json and sw.js for PWA support. The CSP
in _security_headers() had no explicit manifest-src directive, so browsers
fell back to default-src 'self' and emitted a console warning on every page
load. The fallback is functionally correct but non-compliant with CSP Level 3
best practice of declaring each directive explicitly.

Adds manifest-src 'self' before base-uri. No origin set is changed.
Regression test added alongside existing CSP coverage in test_pwa_manifest_csp.py.

Co-authored with Claude Sonnet 4.6 / Anthropic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PWA would be nice

2 participants