From b15b4eda318fba9365fd8ba4c51ea9526dc7df85 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Thu, 14 May 2026 11:50:20 -0700 Subject: [PATCH] fix: serve PWA manifest from session routes --- CHANGELOG.md | 2 + api/auth.py | 1 + api/routes.py | 44 ++++-- static/index.html | 6 +- tests/test_pwa_manifest_sw.py | 211 +++++++++++++++++++++++++++- tests/test_session_static_assets.py | 26 +++- 6 files changed, 271 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f13b06..bba2d3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2270** (closes #2226) — Firefox Android PWA installs from `/session/` pages now resolve the Hermes manifest and icons instead of falling back to a generated letter icon. The dynamic `` 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. diff --git a/api/auth.py b/api/auth.py index 0c9c7064..f323d1c9 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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' diff --git a/api/routes.py b/api/routes.py index 1a70378f..f952b101 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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/ 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 against the page URL + # before the dynamic script runs when installing from + # /session/, 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" diff --git a/static/index.html b/static/index.html index e63f6716..c3a37bfb 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,10 @@ Hermes + + @@ -13,8 +17,6 @@ - - diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index e1f84b3c..ea08a003 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -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 against the page URL before +# the dynamic script executes when installing from /session/, +# 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 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 script appears before manifest and + favicon links so browsers resolve relative URLs deterministically + even when the page is served from /session/ routes.""" + + def test_base_href_script_before_manifest_link(self): + src = INDEX.read_text(encoding="utf-8") + base_pos = src.find("document.write(' script must appear before " + "so browsers resolve the manifest URL against the correct base when " + "served from /session/ — see #2226" + ) + + def test_base_href_script_before_favicon_links(self): + src = INDEX.read_text(encoding="utf-8") + base_pos = src.find("document.write(' script must appear before " + "so browsers resolve favicon URLs against the correct base when " + "served from /session/ — 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/ 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"" 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"" 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 diff --git a/tests/test_session_static_assets.py b/tests/test_session_static_assets.py index c933b11e..15522798 100644 --- a/tests/test_session_static_assets.py +++ b/tests/test_session_static_assets.py @@ -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/, 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"