"""Self-hosted client pages: the mobile PWA, its assets, and APK distribution. Everything here is deliberately unauthenticated. These pages are what a new member opens *before* they hold a token, and the APK embeds no credentials — it is a generic WebView shell that asks for a server address on first run. """ from __future__ import annotations import json from pathlib import Path from fastapi import APIRouter, Request from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from ..config import Config from ..storage import apk_meta_path, apk_path STATIC_DIR = Path(__file__).resolve().parent.parent / "static" router = APIRouter(include_in_schema=False) def _static(name: str, media_type: str, cache: str) -> FileResponse: return FileResponse(STATIC_DIR / name, media_type=media_type, headers={"Cache-Control": cache}) @router.get("/app") def mobile_app(): """Self-hosted mobile web app (PWA) — open on a phone, add to home screen.""" return _static("mobile.html", "text/html", "no-cache") @router.get("/sw.js") def service_worker(): """Service worker: offline app shell + last-known API responses.""" return _static("sw.js", "text/javascript", "no-cache") @router.get("/manifest.webmanifest") def webmanifest(): return _static("manifest.webmanifest", "application/manifest+json", "no-cache") @router.get("/icon.svg") def app_icon(): return _static("icon.svg", "image/svg+xml", "public, max-age=86400") @router.get("/favicon.ico") def favicon(): """Browsers ask for this unprompted; redirect to the SVG we do have.""" return RedirectResponse("/icon.svg", status_code=307) # ------------------------------------------------------------------ APK def apk_metadata(cfg: Config) -> dict: """Describe the built APK, or report that it has not been built.""" apk = apk_path(cfg) if not apk.is_file(): return {"available": False, "build_hint": "bash android/build.sh"} meta = {"available": True, "size_bytes": apk.stat().st_size, "filename": apk.name} sidecar = apk_meta_path(cfg) if sidecar.is_file(): try: meta.update(json.loads(sidecar.read_text(encoding="utf-8"))) except (OSError, ValueError): # A corrupt sidecar must not take the download down with it. pass return meta @router.get("/apk") def apk_page(): """Standalone install page — reachable without a token, like /app.""" return _static("apk.html", "text/html", "no-cache") @router.get("/apk/info") def apk_info(request: Request): return JSONResponse(apk_metadata(request.app.state.cfg)) @router.get("/apk/dsh-sync.apk") def apk_download(request: Request): cfg: Config = request.app.state.cfg apk = apk_path(cfg) if not apk.is_file(): return JSONResponse({"detail": "APK has not been built on this server"}, status_code=404) return FileResponse( apk, media_type="application/vnd.android.package-archive", filename="dsh-sync.apk", headers={"Cache-Control": "no-cache"}, ) @router.get("/apk/qr") def apk_qr(request: Request, host: str | None = None): """QR of the download page, so a phone can scan straight to the installer.""" import io import segno from ..connect import build_base_urls, host_port scheme = request.headers.get("x-forwarded-proto") or request.url.scheme if host: _orig, port = host_port(request.headers.get("host"), scheme) base = f"{scheme}://{host}:{port}" if port else f"{scheme}://{host}" else: bases = build_base_urls(request) base = bases[0][: -len("/app")] if bases else "http://127.0.0.1:8020" qr = segno.make(base + "/apk", error="m") buf = io.BytesIO() qr.save(buf, kind="svg", scale=8, border=2, dark="#000000", light="#ffffff") return Response(buf.getvalue(), media_type="image/svg+xml") __all__ = ["router", "apk_metadata", "dist_dir"]