diff --git a/app/frontend_static.py b/app/frontend_static.py index e7a735c..ab53446 100644 --- a/app/frontend_static.py +++ b/app/frontend_static.py @@ -1,13 +1,27 @@ import logging from pathlib import Path -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles logger = logging.getLogger(__name__) +def _resolve_request_origin(request: Request) -> str: + """Resolve the external origin, honoring common reverse-proxy headers.""" + forwarded_proto = request.headers.get("x-forwarded-proto") + forwarded_host = request.headers.get("x-forwarded-host") + + if forwarded_proto and forwarded_host: + proto = forwarded_proto.split(",")[0].strip() + host = forwarded_host.split(",")[0].strip() + if proto and host: + return f"{proto}://{host}" + + return str(request.base_url).rstrip("/") + + def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool: """Register frontend static file routes if a built frontend is available. @@ -55,6 +69,41 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool: """Serve the frontend index.html.""" return FileResponse(index_file) + @app.get("/site.webmanifest") + async def serve_webmanifest(request: Request): + """Serve a dynamic web manifest using the active request origin.""" + origin = _resolve_request_origin(request) + manifest = { + "name": "RemoteTerm for MeshCore", + "short_name": "RemoteTerm", + "id": f"{origin}/", + "start_url": f"{origin}/", + "scope": f"{origin}/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "fullscreen"], + "theme_color": "#111419", + "background_color": "#111419", + "icons": [ + { + "src": f"{origin}/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable", + }, + { + "src": f"{origin}/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable", + }, + ], + } + return JSONResponse( + manifest, + media_type="application/manifest+json", + headers={"Cache-Control": "no-store"}, + ) + @app.get("/{path:path}") async def serve_frontend(path: str): """Serve frontend files, falling back to index.html for SPA routing.""" diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest deleted file mode 100644 index 92caeb2..0000000 --- a/frontend/public/site.webmanifest +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "RemoteTerm for MeshCore", - "short_name": "RemoteTerm", - "icons": [ - { - "src": "/web-app-manifest-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/web-app-manifest-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} \ No newline at end of file diff --git a/tests/test_frontend_static.py b/tests/test_frontend_static.py index be3faf3..3043f34 100644 --- a/tests/test_frontend_static.py +++ b/tests/test_frontend_static.py @@ -53,6 +53,16 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path): assert root_response.status_code == 200 assert "index page" in root_response.text + manifest_response = client.get("/site.webmanifest") + assert manifest_response.status_code == 200 + assert manifest_response.headers["content-type"].startswith("application/manifest+json") + manifest = manifest_response.json() + assert manifest["start_url"] == "http://testserver/" + assert manifest["scope"] == "http://testserver/" + assert manifest["id"] == "http://testserver/" + assert manifest["display"] == "standalone" + assert manifest["icons"][0]["src"] == "http://testserver/web-app-manifest-192x192.png" + file_response = client.get("/robots.txt") assert file_response.status_code == 200 assert file_response.text == "User-agent: *" @@ -64,3 +74,28 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path): asset_response = client.get("/assets/app.js") assert asset_response.status_code == 200 assert "console.log('ok');" in asset_response.text + + +def test_webmanifest_uses_forwarded_origin_headers(tmp_path): + app = FastAPI() + dist_dir = tmp_path / "frontend" / "dist" + dist_dir.mkdir(parents=True) + (dist_dir / "index.html").write_text("index page") + + registered = register_frontend_static_routes(app, dist_dir) + assert registered is True + + with TestClient(app) as client: + response = client.get( + "/site.webmanifest", + headers={ + "x-forwarded-proto": "https", + "x-forwarded-host": "mesh.example.com:8443", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["start_url"] == "https://mesh.example.com:8443/" + assert data["scope"] == "https://mesh.example.com:8443/" + assert data["id"] == "https://mesh.example.com:8443/"