feat(pwa): installable web app + landing web entry
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s

Make the web SPA an installable PWA and surface it to users:

- manifest.webmanifest + an install-only service worker + 192/512/maskable
  icons (ui/public); PWA head tags in index.html; the .webmanifest MIME type
  registered in the gateway (the distroless image has no /etc/mime.types).
- Platform-adaptive install CTA (components/InstallApp.svelte + lib/pwa):
  one-tap on Chromium, manual Add-to-Home-Screen instructions on iOS Safari,
  hidden elsewhere / once installed / inside a Mini App. Shown under the
  logged-out login card and at the bottom of Settings.
- The landing gains a third entry linking /app/ (the brand tile), with a
  caption under all three (Telegram / VK / Веб-версия).

The service worker is navigation-only (network-first, cached-shell fallback);
hashed assets and the Connect stream are untouched. It exists to satisfy
Chromium's installability requirement and is the single growth point for a
future opt-in offline mode.

Tests: pwa.ts unit tests; a webui probe (manifest/sw.js content-type + the
SPA-fallback-to-HTML trap); e2e for the one-tap CTA, the iOS instructions
modal and the landing web entry. Docs: ARCHITECTURE §13, FUNCTIONAL (+ru),
gateway README.
This commit is contained in:
Ilia Denisov
2026-07-05 21:05:52 +02:00
parent d61ba68e9b
commit 51147a1429
26 changed files with 662 additions and 29 deletions
+19 -6
View File
@@ -17,6 +17,7 @@ package webui
import (
"embed"
"io/fs"
"mime"
"net/http"
"path"
"strings"
@@ -35,13 +36,25 @@ func distFS() fs.FS {
return sub
}
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
// "/telegram/").
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
func init() {
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
}
// Handler serves the compile-time embedded UI build over the public edge — the game SPA under
// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/.
func Handler(stripPrefix, indexName string) http.Handler {
content := distFS()
return handlerFor(distFS(), stripPrefix, indexName)
}
// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get
// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side
// deep link still loads. When stripPrefix is non-empty it is removed from the request path before
// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from
// Handler so it can be exercised over an in-memory fs.FS in tests.
func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
files := http.FileServer(http.FS(content))
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")