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

@@ -33,6 +33,10 @@
### Fixed ### Fixed
- **Settings dialog and message controls unusable on mobile** — three mobile usability fixes: (1) settings tab strip replaced by a native `<select>` dropdown on narrow viewports, panel goes full-width; (2) provider card Save/Remove buttons become icon-only on mobile so the API key input fills the available width; (3) message timestamps, copy, and edit buttons are always visible on touch screens (no hover state on mobile). (`static/index.html`, `static/panels.js`, `static/style.css`) Co-authored by @bsgdigital. - **Settings dialog and message controls unusable on mobile** — three mobile usability fixes: (1) settings tab strip replaced by a native `<select>` dropdown on narrow viewports, panel goes full-width; (2) provider card Save/Remove buttons become icon-only on mobile so the API key input fills the available width; (3) message timestamps, copy, and edit buttons are always visible on touch screens (no hover state on mobile). (`static/index.html`, `static/panels.js`, `static/style.css`) Co-authored by @bsgdigital.
## [v0.50.178] — 2026-04-23
### Added
- **PWA support — installable as a standalone app** — adds a Web App Manifest (`manifest.json`) and a minimal service worker (`sw.js`) with cache-first strategy for app shell assets and network-bypass for all `/api/*` and `/stream` endpoints. Cache name auto-busts on every deploy via git-derived version injection. Enables "Add to Home Screen" on Android, iOS, and desktop Chrome without any offline API response caching (live backend always required). (`static/manifest.json`, `static/sw.js`, `static/index.html`, `api/routes.py`) Closes #685. Co-authored by @bsgdigital.
## [v0.50.176] — 2026-04-23 ## [v0.50.176] — 2026-04-23

View File

@@ -576,6 +576,41 @@ def handle_get(handler, parsed) -> bool:
logged_in = bool(cv and verify_session(cv)) logged_in = bool(cv and verify_session(cv))
return j(handler, {"auth_enabled": is_auth_enabled(), "logged_in": logged_in}) 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": if parsed.path == "/favicon.ico":
static_root = Path(__file__).parent.parent / "static" static_root = Path(__file__).parent.parent / "static"
ico_path = (static_root / "favicon.ico").resolve() ico_path = (static_root / "favicon.ico").resolve()

View File

@@ -7,6 +7,12 @@
<link rel="icon" type="image/svg+xml" href="static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="static/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png"> <link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32.png">
<link rel="shortcut icon" href="static/favicon.ico"> <link rel="shortcut icon" href="static/favicon.ico">
<link rel="manifest" href="manifest.json">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" href="static/favicon.svg">
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) --> <!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script> <script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script> <script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
@@ -19,6 +25,16 @@
<link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous"> <link id="prism-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" integrity="sha384-MXybTpajaBV0AkcBaCPT4KIvo0FzoCiWXgcihYsw4FUkEz0Pv3JGV6tk2G8vJtDc" crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script> <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha384-Uq05+JLko69eOiPr39ta9bh7kld5PKZoU+fF7g0EXTAriEollhZ+DrN8Q/Oi8J2Q" crossorigin="anonymous" defer></script>
<!-- PWA service worker registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('sw.js').catch(function(err) {
console.warn('[pwa] Service worker registration failed:', err);
});
});
}
</script>
</head> </head>
<body> <body>
<div class="layout"> <div class="layout">

23
static/manifest.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "Hermes",
"short_name": "Hermes",
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"orientation": "portrait-primary",
"icons": [
{
"src": "static/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "static/favicon-32.png",
"sizes": "32x32",
"type": "image/png"
}
]
}

106
static/sw.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* Hermes WebUI Service Worker
* Minimal PWA service worker — enables "Add to Home Screen".
* No offline caching of API responses (the UI requires a live backend).
* Caches only static shell assets so the app shell loads fast on repeat visits.
*/
// Cache version is injected by the server at request time (routes.py /sw.js handler).
// Bumps automatically whenever the git commit changes — no manual edits needed.
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
// Static assets that form the app shell
const SHELL_ASSETS = [
'./',
'./static/style.css',
'./static/boot.js',
'./static/ui.js',
'./static/messages.js',
'./static/sessions.js',
'./static/panels.js',
'./static/commands.js',
'./static/icons.js',
'./static/i18n.js',
'./static/workspace.js',
'./static/onboarding.js',
'./static/favicon.svg',
'./static/favicon-32.png',
'./manifest.json',
];
// Install: pre-cache the app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(SHELL_ASSETS).catch((err) => {
// Non-fatal: if any asset fails, still activate
console.warn('[sw] Shell pre-cache partial failure:', err);
});
})
);
self.skipWaiting();
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
// Fetch strategy:
// - API calls (/api/*, /stream) → always network (never cache)
// - Shell assets → cache-first with network fallback
// - Everything else → network-first, fall back to offline page
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never intercept cross-origin requests
if (url.origin !== self.location.origin) return;
// API and streaming endpoints — always go to network
if (
url.pathname.startsWith('/api/') ||
url.pathname.includes('/stream') ||
url.pathname.startsWith('/health')
) {
return; // let browser handle normally
}
// Shell assets: cache-first
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// Cache successful GET responses for shell assets
if (
event.request.method === 'GET' &&
response.status === 200
) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
}).catch(() => {
// Offline fallback for navigation requests.
// Note: caches.match() returns a Promise (always truthy in a `||` check),
// so we must await/then to unwrap it — otherwise the `new Response(...)`
// branch is dead code and the browser falls back to its default offline page.
if (event.request.mode === 'navigate') {
return caches.match('./').then((cached) => cached || new Response(
'<html><body style="font-family:sans-serif;padding:2rem;background:#1a1a1a;color:#ccc">' +
'<h2>You are offline</h2>' +
'<p>Hermes requires a server connection. Please check your network and try again.</p>' +
'</body></html>',
{ headers: { 'Content-Type': 'text/html' } }
));
}
});
})
);
});

View File

@@ -0,0 +1,152 @@
"""Regression tests for PWA support (manifest + service worker).
Covers:
- manifest.json is valid JSON with required PWA fields
- sw.js has the `__CACHE_VERSION__` placeholder the server replaces at request time
- sw.js offline-fallback uses a resolved promise (not `caches.match() || fallback`
which is broken — Promise objects are always truthy in `||` checks, so the
fallback Response would never be used)
- /manifest.json, /manifest.webmanifest, /sw.js routes serve correct Content-Type
"""
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MANIFEST = ROOT / "static" / "manifest.json"
SW = ROOT / "static" / "sw.js"
INDEX = ROOT / "static" / "index.html"
ROUTES = ROOT / "api" / "routes.py"
class TestManifest:
def test_manifest_is_valid_json(self):
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert isinstance(data, dict)
def test_manifest_has_required_pwa_fields(self):
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
for field in ("name", "start_url", "display", "icons"):
assert field in data, f"manifest.json missing required field: {field}"
assert data["display"] == "standalone", (
"manifest.display must be 'standalone' for installable PWA"
)
assert isinstance(data["icons"], list) and len(data["icons"]) > 0, (
"manifest.icons must be a non-empty list"
)
def test_manifest_icons_reference_existing_files(self):
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
for icon in data["icons"]:
src = icon.get("src", "")
if src.startswith("http"):
continue # external icon, skip
# Paths are relative to the app root (where manifest is served)
# 'static/favicon.svg' or './static/favicon.svg' both valid
clean = src.lstrip("./")
p = ROOT / clean
assert p.exists(), f"manifest.json references missing icon: {src}"
class TestServiceWorker:
def test_sw_has_cache_version_placeholder(self):
src = SW.read_text(encoding="utf-8")
assert "__CACHE_VERSION__" in src, (
"sw.js must contain __CACHE_VERSION__ placeholder for the server "
"handler at /sw.js to replace with WEBUI_VERSION at request time"
)
def test_sw_bypasses_api_and_stream(self):
src = SW.read_text(encoding="utf-8")
assert "/api/" in src, "SW must bypass /api/* (no cached auth/session responses)"
assert "/stream" in src, "SW must bypass streaming endpoints"
def test_sw_offline_fallback_awaits_caches_match(self):
"""caches.match() returns a Promise (always truthy in `||`), so the pattern
`caches.match('./') || new Response(...)` is broken — the fallback Response
is dead code and the browser falls back to its default offline page.
The correct pattern chains the match through .then() or awaits it so the
resolved value is what gets the `||` fallback.
"""
src = SW.read_text(encoding="utf-8")
# Must not use the broken shape
broken_pattern = re.compile(
r"caches\.match\([^)]*\)\s*\|\|\s*new\s+Response",
re.DOTALL,
)
assert not broken_pattern.search(src), (
"sw.js offline fallback uses `caches.match('./') || new Response(...)` "
"which is dead code — caches.match() returns a Promise that's always "
"truthy. Use `.then((cached) => cached || new Response(...))` instead."
)
# Positive assertion that SOME form of the working pattern is present
has_then = ".then(" in src and "cached" in src
has_await = "await caches.match" in src
assert has_then or has_await, (
"sw.js must await/then the caches.match() result before applying the fallback"
)
def test_sw_never_caches_api_responses(self):
"""Defensive: the SW must not cache responses from /api/* paths.
Currently enforced by early-return before the shell-asset cache block."""
src = SW.read_text(encoding="utf-8")
# Look for the early-return pattern in the fetch handler
assert "return;" in src and "/api/" in src, (
"SW fetch handler must early-return for /api/* paths (no caching)"
)
class TestPWARoutes:
def test_manifest_route_serves_correct_content_type(self):
src = ROUTES.read_text(encoding="utf-8")
# The handler block for /manifest.json
idx = src.find('"/manifest.json"')
assert idx != -1, "routes.py must handle /manifest.json"
block = src[idx:idx + 800]
assert "application/manifest+json" in block, (
"manifest.json route must serve Content-Type: application/manifest+json"
)
assert "no-store" in block or "Cache-Control" in block, (
"manifest.json should have Cache-Control: no-store so updates are picked up"
)
def test_sw_route_injects_cache_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('"/sw.js"')
assert idx != -1, "routes.py must handle /sw.js"
block = src[idx:idx + 1000]
assert "__CACHE_VERSION__" in block, (
"sw.js route must replace __CACHE_VERSION__ with the current WEBUI_VERSION"
)
assert "WEBUI_VERSION" in block, (
"sw.js route must import and use WEBUI_VERSION for cache busting"
)
def test_sw_route_sets_service_worker_allowed(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('"/sw.js"')
block = src[idx:idx + 1000]
assert "Service-Worker-Allowed" in block, (
"sw.js route must set Service-Worker-Allowed header so the SW can control "
"the expected scope"
)
class TestIndexHtmlIntegration:
def test_index_links_manifest(self):
src = INDEX.read_text(encoding="utf-8")
assert 'rel="manifest"' in src, "index.html must link to manifest.json"
def test_index_registers_service_worker(self):
src = INDEX.read_text(encoding="utf-8")
assert "serviceWorker" in src and "register" in src, (
"index.html must register the service worker"
)
def test_index_has_ios_pwa_meta_tags(self):
src = INDEX.read_text(encoding="utf-8")
assert "apple-mobile-web-app-capable" in src, (
"index.html should include Apple PWA meta tags for iOS home-screen support"
)