fix: serve PWA manifest from session routes

This commit is contained in:
Michael Lam committed 2026-05-14 11:52:12 -07:00
1 parent e5ca59bc8f
commit b15b4eda31
6 files changed
+271 -19

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
### Fixed
- **PR #2270** (closes #2226) — Firefox Android PWA installs from `/session/<id>` pages now resolve the Hermes manifest and icons instead of falling back to a generated letter icon. The dynamic `<base href>` script now runs before manifest/favicon links, `/session/manifest.json` and `/session/manifest.webmanifest` return the real manifest JSON, and session-prefixed manifest/icon paths have regression coverage.
- **PR #2265** by @Michaelyklam (closes #2245) — Model picker provider lookup now canonicalizes configured provider keys before loading their configured models. Pre-fix, custom provider keys with mixed casing or underscores such as `CLIPpoxy` or `snake_case_provider` were lower-cased during canonicalization, but the resulting canonical key didn't match the raw `config.yaml` key, so the model allowlist lookup silently returned empty and the model picker dropdown showed no models for that provider. The fix maps canonical provider IDs back to their raw config.yaml provider keys before loading `provider_cfg`. Original config keys are preserved for provider settings rendering. 242-line regression test covering CLIPpoxy + snake_case_provider plus built-in/fallback behavior.
- **PR #2234** by @Jordan-SkyLF (post-v0.51.62 rebase, follow-up to v0.51.62's category-refinement portion) — Update summary generation now routes through the documented `auxiliary.compression` text-model slot instead of a WebUI-only `auxiliary.update_summary` magic key. The reviewer concern was that `update_summary` would have been a non-discoverable WebUI-specific config key; using the existing documented compression/summarization slot keeps the PR self-contained to `hermes-webui` and gives users a way to override summary generation through an existing config surface. The existing main-model fallback is preserved if auxiliary resolution or generation fails. Adds route comment explaining why summary generation maps to compression instead of inventing a new task name.
+1
View File
@@ -50,6 +50,7 @@ PUBLIC_PATHS = frozenset({
'/login', '/health', '/favicon.ico', '/sw.js',
'/api/auth/login', '/api/auth/status',
'/manifest.json', '/manifest.webmanifest',
'/session/manifest.json', '/session/manifest.webmanifest',
})
COOKIE_NAME = 'hermes_session'
+32 -12
View File
@@ -3095,6 +3095,28 @@ def _serve_shell_unavailable(handler, exc: Exception) -> bool:
return True
def _serve_manifest(handler) -> bool:
"""Serve static/manifest.json with the correct PWA Content-Type.
Shared by the root (/manifest.json, /manifest.webmanifest) and
session-prefixed (/session/manifest.json, /session/manifest.webmanifest)
routes so Firefox Android can fetch the manifest when installing from
a /session/<id> page. See #2226.
"""
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)
def handle_get(handler, parsed) -> bool:
"""Handle all GET routes. Returns True if handled, False for 404."""
@@ -3105,6 +3127,15 @@ def handle_get(handler, parsed) -> bool:
stripped = parsed._replace(path=parsed.path[len("/session"):])
return _serve_static(handler, stripped)
# Firefox Android resolves <link rel="manifest"> against the page URL
# before the dynamic <base href> script runs when installing from
# /session/<id>, producing requests like /session/manifest.json.
# Without this guard the catch-all below returns index.html instead of
# the manifest, and Firefox falls back to a generated letter icon.
# See #2226.
if parsed.path in ("/session/manifest.json", "/session/manifest.webmanifest"):
return _serve_manifest(handler)
if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"):
try:
from urllib.parse import quote
@@ -3159,18 +3190,7 @@ def handle_get(handler, parsed) -> bool:
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)
return _serve_manifest(handler)
if parsed.path == "/sw.js":
static_root = Path(__file__).parent.parent / "static"
+4 -2
View File
@@ -4,6 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Hermes</title>
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash).
MUST appear before manifest/favicon links so browsers resolve relative URLs against the
correct base when the page is served from /session/<id> routes. See #2226. -->
<script>(function(){var path=location.pathname,marker='/session/',i=path.indexOf(marker),p;i>=0?p=(path.slice(0,i+1)||'/'):p=(path.endsWith('/')?path:(path.replace(/\/[^\/]*$/,'/')||'/'));document.write('<base href="'+location.origin+p+'">');})()</script>
<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="shortcut icon" href="static/favicon.ico">
@@ -13,8 +17,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var path=location.pathname,marker='/session/',i=path.indexOf(marker),p;i>=0?p=(path.slice(0,i+1)||'/'):p=(path.endsWith('/')?path:(path.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,sienna: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 fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
+207 -4
View File
@@ -141,11 +141,19 @@ class TestPWARoutes:
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"
# After the #2226 refactor, the root route delegates to _serve_manifest().
# Verify the helper exists and sets the correct Content-Type.
assert "_serve_manifest" in block, (
"manifest.json route must delegate to _serve_manifest()"
)
assert "no-store" in block or "Cache-Control" in block, (
"manifest.json should have Cache-Control: no-store so updates are picked up"
helper_idx = src.find("def _serve_manifest")
assert helper_idx != -1, "routes.py must define _serve_manifest helper"
helper_block = src[helper_idx:helper_idx + 800]
assert "application/manifest+json" in helper_block, (
"_serve_manifest must serve Content-Type: application/manifest+json"
)
assert "no-store" in helper_block or "Cache-Control" in helper_block, (
"_serve_manifest should set Cache-Control: no-store so updates are picked up"
)
def test_sw_route_injects_cache_version(self):
@@ -318,3 +326,198 @@ class TestIndexHtmlIntegration:
assert "apple-mobile-web-app-capable" in src, (
"index.html should include Apple PWA meta tags for iOS home-screen support"
)
# ── Regression tests for #2226 ──────────────────────────────────────────────
# Firefox Android resolves <link rel="manifest"> against the page URL before
# the dynamic <base href> script executes when installing from /session/<id>,
# producing requests like /session/manifest.json. Without the route guard
# the catch-all returns index.html and Firefox falls back to a generated
# letter icon. Two fixes: (1) move <base href> script above manifest/favicon
# links so browsers resolve them correctly, and (2) add /session/manifest.*
# route handlers that serve the real manifest JSON.
class TestBaseHrefOrdering:
"""Assert the dynamic <base href> script appears before manifest and
favicon links so browsers resolve relative URLs deterministically
even when the page is served from /session/<id> routes."""
def test_base_href_script_before_manifest_link(self):
src = INDEX.read_text(encoding="utf-8")
base_pos = src.find("document.write('<base href=")
manifest_pos = src.find('rel="manifest"')
assert base_pos != -1, "index.html must contain the dynamic base-href script"
assert manifest_pos != -1, "index.html must contain a manifest link"
assert base_pos < manifest_pos, (
"dynamic <base href> script must appear before <link rel=\"manifest\"> "
"so browsers resolve the manifest URL against the correct base when "
"served from /session/<id> — see #2226"
)
def test_base_href_script_before_favicon_links(self):
src = INDEX.read_text(encoding="utf-8")
base_pos = src.find("document.write('<base href=")
favicon_pos = src.find('rel="icon"')
assert base_pos != -1, "index.html must contain the dynamic base-href script"
assert favicon_pos != -1, "index.html must contain a favicon link"
assert base_pos < favicon_pos, (
"dynamic <base href> script must appear before <link rel=\"icon\"> "
"so browsers resolve favicon URLs against the correct base when "
"served from /session/<id> — see #2226"
)
class _FakeHandler:
"""Minimal request handler stub for exercising handle_get() in tests."""
def __init__(self):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def header(self, name):
for key, value in self.sent_headers:
if key.lower() == name.lower():
return value
return None
class TestSessionManifestRoute:
"""Assert /session/manifest.json and /session/manifest.webmanifest
return the real manifest JSON (not index.html) so Firefox Android can
find the Hermes icons when installing from a /session/<id> page."""
def _get(self, path):
from urllib.parse import urlparse
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse(f"http://example.com{path}")
handle_get(handler, parsed)
return handler
def test_session_manifest_json_returns_200(self):
handler = self._get("/session/manifest.json")
assert handler.status == 200
def test_session_manifest_json_has_manifest_content_type(self):
handler = self._get("/session/manifest.json")
ct = handler.header("Content-Type") or ""
assert ct.startswith("application/manifest+json"), (
f"expected application/manifest+json, got {ct!r}"
)
def test_session_manifest_json_is_parseable_json(self):
handler = self._get("/session/manifest.json")
data = json.loads(bytes(handler.body).decode("utf-8"))
assert isinstance(data, dict)
def test_session_manifest_json_has_hermes_name(self):
handler = self._get("/session/manifest.json")
data = json.loads(bytes(handler.body).decode("utf-8"))
assert data.get("name") == "Hermes"
def test_session_manifest_json_has_512_icon(self):
handler = self._get("/session/manifest.json")
data = json.loads(bytes(handler.body).decode("utf-8"))
icons = data.get("icons", [])
sizes = [icon.get("sizes", "") for icon in icons]
assert any("512" in s for s in sizes), (
f"manifest must include a 512x512 icon for PWA install, got sizes: {sizes}"
)
def test_session_manifest_json_is_not_html(self):
handler = self._get("/session/manifest.json")
body_start = bytes(handler.body[:200]).lower()
assert b"<!doctype html>" not in body_start, (
"/session/manifest.json must return manifest JSON, not the HTML index"
)
def test_session_manifest_webmanifest_returns_200(self):
handler = self._get("/session/manifest.webmanifest")
assert handler.status == 200
def test_session_manifest_webmanifest_has_manifest_content_type(self):
handler = self._get("/session/manifest.webmanifest")
ct = handler.header("Content-Type") or ""
assert ct.startswith("application/manifest+json"), (
f"expected application/manifest+json, got {ct!r}"
)
def test_session_manifest_webmanifest_is_parseable_json(self):
handler = self._get("/session/manifest.webmanifest")
data = json.loads(bytes(handler.body).decode("utf-8"))
assert data.get("name") == "Hermes"
def test_session_manifest_webmanifest_is_not_html(self):
handler = self._get("/session/manifest.webmanifest")
body_start = bytes(handler.body[:200]).lower()
assert b"<!doctype html>" not in body_start
class TestRootManifestRoute:
"""Assert root /manifest.json still works after the _serve_manifest refactor."""
def _get(self, path):
from urllib.parse import urlparse
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse(f"http://example.com{path}")
handle_get(handler, parsed)
return handler
def test_root_manifest_json_returns_200(self):
handler = self._get("/manifest.json")
assert handler.status == 200
def test_root_manifest_json_has_manifest_content_type(self):
handler = self._get("/manifest.json")
ct = handler.header("Content-Type") or ""
assert ct.startswith("application/manifest+json"), (
f"expected application/manifest+json, got {ct!r}"
)
def test_root_manifest_json_has_hermes_name_and_512_icon(self):
handler = self._get("/manifest.json")
data = json.loads(bytes(handler.body).decode("utf-8"))
assert data.get("name") == "Hermes"
icons = data.get("icons", [])
sizes = [icon.get("sizes", "") for icon in icons]
assert any("512" in s for s in sizes)
def test_root_manifest_webmanifest_returns_200(self):
handler = self._get("/manifest.webmanifest")
assert handler.status == 200
class TestSessionManifestAuthExemption:
"""Assert /session/manifest.* paths are auth-exempt so the browser
can fetch the manifest during PWA install without being redirected."""
def test_session_manifest_json_is_public(self, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth, _invalidate_password_hash_cache
from types import SimpleNamespace
_invalidate_password_hash_cache()
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/session/manifest.json", query="")) is True
def test_session_manifest_webmanifest_is_public(self, monkeypatch):
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth, _invalidate_password_hash_cache
from types import SimpleNamespace
_invalidate_password_hash_cache()
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/session/manifest.webmanifest", query="")) is True
+25 -1
View File
@@ -116,7 +116,9 @@ def test_session_static_auth_exemption(monkeypatch):
"""
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth
from api.auth import check_auth, _invalidate_password_hash_cache
_invalidate_password_hash_cache()
# /session/static/* is public (matches /static/* policy)
handler = _FakeHandler()
@@ -129,3 +131,25 @@ def test_session_static_auth_exemption(monkeypatch):
# And confirm a non-static /session/* path still requires auth
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/session/abc123", query="")) is False
def test_session_static_favicon_512_returns_png():
"""/session/static/favicon-512.png must return image/png with a PNG signature.
Firefox Android fetches PWA icons from the manifest's icon URLs. When the
page is /session/<id>, the manifest's relative icon paths resolve to
/session/static/favicon-512.png. This test ensures the existing
/session/static/* alias serves the real PNG icon, not the HTML index.
See #2226.
"""
from api.routes import handle_get
handler = _FakeHandler()
parsed = urlparse("http://example.com/session/static/favicon-512.png")
assert handle_get(handler, parsed) is True
assert handler.status == 200
ct = handler.header("Content-Type") or ""
assert ct.startswith("image/png"), f"expected image/png, got {ct!r}"
# PNG signature: first 8 bytes are \x89PNG\r\n\x1a\n
body = bytes(handler.body)
assert body[:4] == b"\x89PNG", "favicon-512.png must start with PNG signature"