feat: DSH编排基建首版(server/tests 88用例/P2插件/8020端口/PORT-NOTE/MOBILE_APP)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user