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>
2026-04-23 22:14:21 +00:00
|
|
|
/**
|
|
|
|
|
* 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',
|
2026-04-26 14:47:10 +00:00
|
|
|
'./static/terminal.js',
|
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>
2026-04-23 22:14:21 +00:00
|
|
|
'./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;
|
|
|
|
|
|
2026-04-30 16:18:01 +00:00
|
|
|
// Never intercept the service worker script itself. Returning a cached sw.js
|
|
|
|
|
// prevents the browser from seeing a new cache version after local patches.
|
|
|
|
|
if (url.pathname.endsWith('/sw.js')) return;
|
|
|
|
|
|
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
|
|
|
// API and streaming endpoints — always go to network.
|
|
|
|
|
// The WebUI may be mounted under a subpath such as /hermes/, so API
|
|
|
|
|
// requests can look like /hermes/api/sessions rather than /api/sessions.
|
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>
2026-04-23 22:14:21 +00:00
|
|
|
if (
|
|
|
|
|
url.pathname.startsWith('/api/') ||
|
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
|
|
|
url.pathname.includes('/api/') ||
|
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>
2026-04-23 22:14:21 +00:00
|
|
|
url.pathname.includes('/stream') ||
|
release: v0.50.240
## Release v0.50.240
Batch release of 13 PRs that passed full triage + code review + test suite (3199 tests, 0 failures).
---
### Added
- **Compact tool activity mode** (`simplified_tool_calling`, default on) — groups tool calls and thinking traces into a single collapsed "Activity" disclosure card per assistant turn. Also adds a new **Calm Console** theme with earth/slate palette and serif prose. @Michaelyklam — #1282
- **PDF first-page preview** — `MEDIA:` `.pdf` files render a canvas thumbnail via PDF.js CDN (4 MB cap). **HTML sandbox iframe** — `.html`/`.htm` files render inline in a sandboxed `<iframe srcdoc>` (256 KB cap). 10 i18n keys × 7 locales. @bergeouss — #1280, closes #480 #482
- **Inline Excalidraw diagram preview** — `.excalidraw` files render as pure SVG (no external deps; rectangles, ellipses, diamonds, text, lines, arrows, freehand; 512 KB cap). @bergeouss — #1279, closes #479
- **Inline CSV table rendering** — fenced `csv` blocks and `MEDIA:` CSV files render as scrollable HTML tables with auto-separator detection. @bergeouss — #1277, closes #485
- **Inline SVG, audio, and video rendering** — SVG as `<img>`, audio as `<audio controls>`, video as `<video controls>`. @bergeouss — #1276, closes #481
- **Batch session select mode** — multi-select sessions for bulk Archive/Delete/Move. 11 i18n keys × 7 locales. @bergeouss — #1275, closes #568
- **Collapsible skill category headers** — click to collapse/expand without re-render; state persists across filter cycles. @bergeouss — #1281
- **`providers.only_configured` setting** — opt-in flag to restrict the model picker to explicitly configured providers. @KingBoyAndGirl — #1268
- **OpenCode Go model catalog** — adds Kimi K2.6, DeepSeek V4 Pro/Flash, MiMo V2.5/Pro, Qwen3.6/3.5 Plus. @nesquena-hermes — #1284, closes #1269
### Fixed
- **Profile `TERMINAL_CWD` TypeError** — `_build_agent_thread_env()` helper merges env before `_set_thread_env()` call. @hi-friday — #1266
- **Service worker subpath cache bypass** — regex now matches `/api/*` under any mount prefix. @Michaelyklam — #1278
- **SSE client disconnect leaks** — `TimeoutError`/`OSError` treated as clean disconnects; server backlog 64, threads daemonized; session list renders before saved-session restore. @KayZz69 — #1267
- **i18n locale corrections** — Korean MCP strings (23), Chinese MCP strings (23), zh-Hant missing keys (41), de missing keys (229). @bergeouss — #1274, closes #1273
---
### Test results
```
3199 passed, 2 skipped, 3 xpassed in 72.79s
```
### PRs on hold (not included)
#1265 (draft), #1271 (superseded by #1266), #1272 (skipped XSS tests), #1232 (partial test run), #1222 (review questions open), #1134 (live-server tests), #1132 (superseded by #1134), #1108 (negative UX review), #1084 (empty description)
2026-04-30 00:42:32 +00:00
|
|
|
url.pathname.startsWith('/health') ||
|
|
|
|
|
url.pathname.includes('/health')
|
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>
2026-04-23 22:14:21 +00:00
|
|
|
) {
|
|
|
|
|
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' } }
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|