Files

122 lines
3.9 KiB
Python

"""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"]