I noticed when I install the Hermes WebUI PWA on Android (using Firefox), it does not give me a nice icon, just a bit letter "W".
Analyse my locally checked out hermes-webui repository.
Write me a full bug report with your issue analysis and proposed solution that I can post on the hermes-webui GitHub issue tracker. It should contain all required context, and be instructional enough so that an AI coding agent can implement a full bug fix from it.
Title
Firefox Android PWA installed from a /session/... URL gets a generated “W” app icon because manifest.json resolves to /session/manifest.json and returns HTML
Summary
When installing Hermes WebUI as a PWA from Firefox on Android, the installed home-screen app can show a generated icon — in my case a large “W” — instead of the Hermes favicon/app icon.
This appears to happen when the user installs the PWA while currently on a session route such as:
https://webui.example.com/session/<session_id>
The source confirms that static/index.html declares the manifest as a relative URL before the page injects its dynamic <base> tag:
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
...
<script>
... document.write('<base href="'+location.origin+p+'">') ...
</script>
As a result, at least Firefox Android appears able to resolve the manifest relative to the current session URL:
But the backend route handler treats all /session/* paths as app-shell routes and returns index.html, not manifest JSON. Firefox then fails to parse the manifest and falls back to a generated shortcut icon.
Environment
Observed on:
- Hermes WebUI local install:
~/.hermes/hermes-webui
- WebUI version served:
v0.51.59
- Browser: Firefox on Android
- Install mode: Firefox Android “Install” / “Add to Home screen”
- Reverse proxy: Caddy in front of local WebUI, though the bug appears to be in WebUI routing/HTML and should reproduce without Caddy
Actual behaviour
If the PWA is installed from a session URL, the installed Android home-screen icon is not the Hermes app icon. Firefox Android creates a fallback/generated icon. In my case it displayed a large “W”, presumably derived from “webui”.
Expected behaviour
Installing Hermes WebUI as a PWA from any valid app route, including /session/<id>, should use the Hermes app icons declared in manifest.json, e.g.:
{
"src": "static/favicon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
Reproduction steps
-
Open Hermes WebUI in Firefox Android.
-
Navigate to an existing session, e.g.:
https://webui.example.com/session/b7194f404c53
-
Use Firefox Android’s install/add-to-home-screen action.
-
Observe the installed app icon on the Android home screen.
Result: generated “W” icon rather than Hermes icon.
Control case:
-
Open the root URL:
https://webui.example.com/
-
Install from there.
Likely result: Firefox can fetch /manifest.json, so the proper icon should be available.
Source analysis
1. Manifest link appears before <base>
In static/index.html, the manifest and favicon links are declared before the dynamic base-href 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">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
...
<!-- 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>
This means the browser may process or resolve:
<link rel="manifest" href="manifest.json">
against the document URL before the later <base> is inserted.
If the current page is:
then the manifest URL can become:
2. /session/manifest.json returns the app shell, not the manifest
In api/routes.py, handle_get() handles /session/* before /manifest.json:
if parsed.path.startswith("/session/static/"):
stripped = parsed._replace(path=parsed.path[len("/session"):])
return _serve_static(handler, stripped)
if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"):
try:
...
html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token)
return t(
handler,
inject_extension_tags(html),
content_type="text/html; charset=utf-8",
)
except Exception as exc:
return _serve_shell_unavailable(handler, exc)
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
...
handler.send_header("Content-Type", "application/manifest+json; charset=utf-8")
...
So:
returns the manifest correctly, but:
GET /session/manifest.json
matches parsed.path.startswith("/session/") first and returns index.html.
I verified this locally:
GET /session/manifest.json
→ 200 OK
→ Content-Type: text/html; charset=utf-8
→ Body starts with <!doctype html>
This is invalid for a Web App Manifest request.
3. The declared icon files themselves are valid
static/manifest.json contains the expected icons:
{
"name": "Hermes",
"short_name": "Hermes",
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"display": "standalone",
"background_color": "#0D0D1A",
"theme_color": "#0D0D1A",
"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"
},
{
"src": "static/favicon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "static/favicon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
The PNG files exist and are served correctly:
static/favicon-32.png → 32x32 PNG
static/favicon-192.png → 192x192 PNG
static/favicon-512.png → 512x512 PNG
The issue is not missing icon assets. It is incorrect manifest URL resolution / route handling.
Proposed fix
There are two reasonable layers of fix. I recommend doing both: one client-side prevention and one server-side hardening.
Fix A — make manifest and icon URLs robust
Move the dynamic <base> insertion before any relative manifest/icon/static links or make the manifest link immune to session-relative resolution.
Because Hermes WebUI supports subpath mounting, avoid simply changing this to an absolute root path:
<link rel="manifest" href="/manifest.json">
That would break installs under subpath deployments such as:
https://example.com/hermes/
Instead, prefer moving the existing dynamic base-href script before the manifest/favicon links:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title>
<!-- 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>
<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">
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
...
With this ordering, a page at:
should have a base href of:
https://webui.example.com/
so the manifest resolves to:
https://webui.example.com/manifest.json
For subpath deployments, the base logic should continue to resolve relative to the subpath.
Fix B — harden route handling for session-prefixed PWA assets
Add explicit aliases before the generic /session/* shell route for session-prefixed PWA resources:
if parsed.path in ("/session/manifest.json", "/session/manifest.webmanifest"):
stripped = parsed._replace(path=parsed.path[len("/session"):])
return handle_get(handler, stripped)
if parsed.path == "/session/sw.js":
stripped = parsed._replace(path=parsed.path[len("/session"):])
return handle_get(handler, stripped)
Or refactor to avoid recursion and call shared helpers for manifest and service-worker serving.
The important part: /session/manifest.json must not return index.html.
This is a defensive compatibility fix for browsers that resolve the manifest before or independently of the dynamic <base> tag. It also makes the failure mode self-correcting if another relative PWA path escapes through the same route shape.
Optional Fix C — make manifest icon src entries explicitly relative to the manifest
The manifest currently uses:
"src": "static/favicon-512.png"
That should resolve relative to the manifest URL, so it is fine if the manifest is fetched from /manifest.json.
If supporting /session/manifest.json as an alias, ensure either:
- the alias returns the same manifest but browser resolves icon URLs relative to
/session/manifest.json, which would become /session/static/favicon-512.png; this currently works because /session/static/* is mapped to /static/*, or
- rewrite manifest icon
src values to ./static/... consistently, or
- serve
/session/manifest.json with a manifest whose icon URLs are correct for that path.
Given /session/static/* already works, option 1 is probably sufficient.
Suggested tests
Add regression tests covering both the static HTML ordering and the live route behaviour.
1. index.html ordering test
Assert that the dynamic base script appears before the manifest link:
def test_base_href_script_precedes_manifest_link():
src = Path("static/index.html").read_text(encoding="utf-8")
base_idx = src.index("document.write('<base href=")
manifest_idx = src.index('<link rel="manifest"')
assert base_idx < manifest_idx
This prevents reintroducing the same ordering bug.
2. /manifest.json still serves manifest JSON
Existing tests may already cover this, but ensure:
def test_manifest_json_served_as_manifest_json(test_server):
r = requests.get(test_server.url + "/manifest.json")
assert r.status_code == 200
assert "application/manifest+json" in r.headers["Content-Type"]
assert r.json()["icons"]
3. /session/manifest.json must not serve HTML
This is the key regression test:
def test_session_manifest_json_does_not_return_app_shell(test_server):
r = requests.get(test_server.url + "/session/manifest.json")
assert r.status_code == 200
assert "application/manifest+json" in r.headers["Content-Type"]
data = r.json()
assert data["name"] == "Hermes"
assert any(icon.get("sizes") == "512x512" for icon in data["icons"])
assert not r.text.lstrip().startswith("<!doctype html>")
If the chosen fix is “base ordering only” and not route hardening, this test would fail. I recommend route hardening as well because it catches real-world browser behaviour and prevents the same generated-icon failure.
4. Session-prefixed icons still work
Since manifest icon URLs may resolve through /session/static/..., preserve this behaviour:
def test_session_static_favicon_512_serves_png(test_server):
r = requests.get(test_server.url + "/session/static/favicon-512.png")
assert r.status_code == 200
assert r.headers["Content-Type"].startswith("image/png")
assert r.content.startswith(b"\x89PNG\r\n\x1a\n")
5. Optional service worker alias test
If adding /session/sw.js hardening:
def test_session_sw_js_serves_javascript(test_server):
r = requests.get(test_server.url + "/session/sw.js")
assert r.status_code == 200
assert "application/javascript" in r.headers["Content-Type"]
assert "self.addEventListener" in r.text
Acceptance criteria
- Installing from
/ still works.
- Installing from
/session/<id> uses the Hermes icon, not a generated “W”.
GET /manifest.json returns manifest JSON.
GET /session/manifest.json returns manifest JSON, not HTML.
GET /session/static/favicon-512.png returns the PNG icon.
- Subpath deployments continue to work; do not hardcode root-relative
/manifest.json unless subpath support is handled separately.
- Existing PWA/service-worker cache-busting behaviour is preserved.
Title
Firefox Android PWA installed from a
/session/...URL gets a generated “W” app icon becausemanifest.jsonresolves to/session/manifest.jsonand returns HTMLSummary
When installing Hermes WebUI as a PWA from Firefox on Android, the installed home-screen app can show a generated icon — in my case a large “W” — instead of the Hermes favicon/app icon.
This appears to happen when the user installs the PWA while currently on a session route such as:
The source confirms that
static/index.htmldeclares the manifest as a relative URL before the page injects its dynamic<base>tag:As a result, at least Firefox Android appears able to resolve the manifest relative to the current session URL:
But the backend route handler treats all
/session/*paths as app-shell routes and returnsindex.html, not manifest JSON. Firefox then fails to parse the manifest and falls back to a generated shortcut icon.Environment
Observed on:
~/.hermes/hermes-webuiv0.51.59Actual behaviour
If the PWA is installed from a session URL, the installed Android home-screen icon is not the Hermes app icon. Firefox Android creates a fallback/generated icon. In my case it displayed a large “W”, presumably derived from “webui”.
Expected behaviour
Installing Hermes WebUI as a PWA from any valid app route, including
/session/<id>, should use the Hermes app icons declared inmanifest.json, e.g.:{ "src": "static/favicon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }Reproduction steps
Open Hermes WebUI in Firefox Android.
Navigate to an existing session, e.g.:
Use Firefox Android’s install/add-to-home-screen action.
Observe the installed app icon on the Android home screen.
Result: generated “W” icon rather than Hermes icon.
Control case:
Open the root URL:
Install from there.
Likely result: Firefox can fetch
/manifest.json, so the proper icon should be available.Source analysis
1. Manifest link appears before
<base>In
static/index.html, the manifest and favicon links are declared before the dynamic base-href script:This means the browser may process or resolve:
against the document URL before the later
<base>is inserted.If the current page is:
then the manifest URL can become:
2.
/session/manifest.jsonreturns the app shell, not the manifestIn
api/routes.py,handle_get()handles/session/*before/manifest.json:So:
returns the manifest correctly, but:
matches
parsed.path.startswith("/session/")first and returnsindex.html.I verified this locally:
This is invalid for a Web App Manifest request.
3. The declared icon files themselves are valid
static/manifest.jsoncontains the expected icons:{ "name": "Hermes", "short_name": "Hermes", "description": "Hermes AI Agent Web UI", "start_url": "./", "display": "standalone", "background_color": "#0D0D1A", "theme_color": "#0D0D1A", "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" }, { "src": "static/favicon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "static/favicon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] }The PNG files exist and are served correctly:
The issue is not missing icon assets. It is incorrect manifest URL resolution / route handling.
Proposed fix
There are two reasonable layers of fix. I recommend doing both: one client-side prevention and one server-side hardening.
Fix A — make manifest and icon URLs robust
Move the dynamic
<base>insertion before any relative manifest/icon/static links or make the manifest link immune to session-relative resolution.Because Hermes WebUI supports subpath mounting, avoid simply changing this to an absolute root path:
That would break installs under subpath deployments such as:
Instead, prefer moving the existing dynamic base-href script before the manifest/favicon links:
With this ordering, a page at:
should have a base href of:
so the manifest resolves to:
For subpath deployments, the base logic should continue to resolve relative to the subpath.
Fix B — harden route handling for session-prefixed PWA assets
Add explicit aliases before the generic
/session/*shell route for session-prefixed PWA resources:Or refactor to avoid recursion and call shared helpers for manifest and service-worker serving.
The important part:
/session/manifest.jsonmust not returnindex.html.This is a defensive compatibility fix for browsers that resolve the manifest before or independently of the dynamic
<base>tag. It also makes the failure mode self-correcting if another relative PWA path escapes through the same route shape.Optional Fix C — make manifest icon
srcentries explicitly relative to the manifestThe manifest currently uses:
That should resolve relative to the manifest URL, so it is fine if the manifest is fetched from
/manifest.json.If supporting
/session/manifest.jsonas an alias, ensure either:/session/manifest.json, which would become/session/static/favicon-512.png; this currently works because/session/static/*is mapped to/static/*, orsrcvalues to./static/...consistently, or/session/manifest.jsonwith a manifest whose icon URLs are correct for that path.Given
/session/static/*already works, option 1 is probably sufficient.Suggested tests
Add regression tests covering both the static HTML ordering and the live route behaviour.
1.
index.htmlordering testAssert that the dynamic base script appears before the manifest link:
This prevents reintroducing the same ordering bug.
2.
/manifest.jsonstill serves manifest JSONExisting tests may already cover this, but ensure:
3.
/session/manifest.jsonmust not serve HTMLThis is the key regression test:
If the chosen fix is “base ordering only” and not route hardening, this test would fail. I recommend route hardening as well because it catches real-world browser behaviour and prevents the same generated-icon failure.
4. Session-prefixed icons still work
Since manifest icon URLs may resolve through
/session/static/..., preserve this behaviour:5. Optional service worker alias test
If adding
/session/sw.jshardening:Acceptance criteria
/still works./session/<id>uses the Hermes icon, not a generated “W”.GET /manifest.jsonreturns manifest JSON.GET /session/manifest.jsonreturns manifest JSON, not HTML.GET /session/static/favicon-512.pngreturns the PNG icon./manifest.jsonunless subpath support is handled separately.