122 lines
3.9 KiB
Python
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"]
|