990cb4a68d
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Failing after 11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
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.
97 lines
3.9 KiB
Go
97 lines
3.9 KiB
Go
// Package webui serves the embedded static UI build over the public edge.
|
|
//
|
|
// The committed dist/ holds only a placeholder index.html so the gateway module
|
|
// compiles with a plain `go build` (and in CI) without a UI build. The production
|
|
// gateway image replaces dist/ with the real Vite build — minus landing.html, which
|
|
// ships in the separate landing container — before compiling (see
|
|
// gateway/Dockerfile), so the binary ships the UI inside it. Because Vite is built
|
|
// with a relative asset base, one build serves under any path: the game SPA is
|
|
// mounted at /app/ (web) and /telegram/ (the Telegram Mini App) — the single-origin
|
|
// model in docs/ARCHITECTURE.md §13.
|
|
//
|
|
// Caching: Vite emits hash-named files under assets/, so those are immutable and
|
|
// cached hard (a reload/relaunch is a cache hit, not a re-download); the HTML shells carry
|
|
// no-cache so a new deploy is picked up immediately.
|
|
package webui
|
|
|
|
import (
|
|
"embed"
|
|
"io/fs"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed all:dist
|
|
var dist embed.FS
|
|
|
|
// distFS returns the embedded build rooted at dist/. The directory is embedded at
|
|
// compile time, so its absence is a build error rather than a runtime condition.
|
|
func distFS() fs.FS {
|
|
sub, err := fs.Sub(dist, "dist")
|
|
if err != nil {
|
|
panic("webui: embedded dist/ missing: " + err.Error())
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// 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 {
|
|
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), "/")
|
|
if name == "" {
|
|
serveIndex(w, content, indexName)
|
|
return
|
|
}
|
|
if info, err := fs.Stat(content, name); err != nil || info.IsDir() {
|
|
// Unknown path or a directory: serve the shell, never a listing.
|
|
serveIndex(w, content, indexName)
|
|
return
|
|
}
|
|
// Hash-named build assets are immutable — cache them for a year so reopening the
|
|
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
|
if strings.HasPrefix(name, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
files.ServeHTTP(w, r)
|
|
})
|
|
if p := strings.TrimSuffix(stripPrefix, "/"); p != "" {
|
|
return http.StripPrefix(p, h)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// serveIndex writes the named HTML shell with a 200 status, so a client-routed deep link
|
|
// still loads the app rather than a 404. The shell is marked no-cache so a new deploy's
|
|
// shell (and the asset URLs it references) is fetched fresh.
|
|
func serveIndex(w http.ResponseWriter, content fs.FS, indexName string) {
|
|
data, err := fs.ReadFile(content, indexName)
|
|
if err != nil {
|
|
http.Error(w, "ui not built", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(data)
|
|
}
|