feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README.
This commit was merged in pull request #160.
This commit is contained in:
@@ -73,6 +73,9 @@ jobs:
|
||||
go=false; ui=false
|
||||
if echo "$files" | grep -qE '^(backend/|pkg/|gateway/|platform/|loadtest/|go\.work)'; then go=true; fi
|
||||
if echo "$files" | grep -qE '^ui/'; then ui=true; fi
|
||||
# The render sidecar bundles ui/src/lib, so its dir rides the ui lane (the
|
||||
# deploy's compose build picks it up either way).
|
||||
if echo "$files" | grep -qE '^renderer/'; then ui=true; fi
|
||||
# A workflow or deploy change re-runs everything as a safety net.
|
||||
if echo "$files" | grep -qE '^(\.gitea/workflows/|deploy/)'; then go=true; ui=true; fi
|
||||
else
|
||||
@@ -199,6 +202,14 @@ jobs:
|
||||
- name: Bundle-size budget
|
||||
run: node scripts/bundle-size.mjs
|
||||
|
||||
# The render sidecar executes the shared ui/src/lib/gameimage.ts on skia-canvas;
|
||||
# its smoke test guards the bundling + skia seam (docs/TESTING.md).
|
||||
- name: Render sidecar test
|
||||
working-directory: renderer
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install chromium webkit
|
||||
timeout-minutes: 5
|
||||
@@ -319,6 +330,8 @@ jobs:
|
||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||
|
||||
@@ -59,10 +59,10 @@ jobs:
|
||||
working-directory: deploy
|
||||
run: |
|
||||
export TAG="${{ steps.ver.outputs.tag }}" APP_VERSION="${{ steps.ver.outputs.tag }}" SCRABBLE_CONFIG_DIR=.
|
||||
# The four main-stack images via compose (reuses the build args, incl. VERSION);
|
||||
# The main-stack images via compose (reuses the build args, incl. VERSION);
|
||||
# the bot separately, since it is profiled out of the prod compose.
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml push backend gateway landing validator renderer
|
||||
docker build -f ../platform/telegram/Dockerfile --target bot --build-arg VERSION="$TAG" -t "$REGISTRY/scrabble-telegram-bot:$TAG" ..
|
||||
docker push "$REGISTRY/scrabble-telegram-bot:$TAG"
|
||||
|
||||
@@ -84,6 +84,8 @@ jobs:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||
@@ -139,6 +141,7 @@ jobs:
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
export EXPORT_SIGN_KEY='$EXPORT_SIGN_KEY'
|
||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||
EOF
|
||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||
|
||||
@@ -125,9 +125,10 @@ gateway/ # module scrabble/gateway: Connect-RPC edge, embeds
|
||||
ui/ # Svelte + Vite SPA + landing (Node project, not in go.work)
|
||||
pkg/ # shared: telemetry, version, wire/FlatBuffers, proto, mtls
|
||||
platform/telegram/ # Telegram side-service: cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
|
||||
renderer/ # image-render sidecar (Node + skia-canvas): runs ui/src/lib/gameimage.ts server-side for the finished-game PNG export
|
||||
loadtest/ # module scrabble/loadtest: the load/stress harness
|
||||
docs/ .gitea/workflows/ CLAUDE.md README.md
|
||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless; gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile renderer/Dockerfile # multi-stage distroless (renderer: node:22-slim + skia-canvas + fonts); gateway/Dockerfile has the `landing` target, platform/telegram/Dockerfile has `validator`+`bot` targets
|
||||
deploy/ # docker-compose (+ prod overlay + bot host) + ansible provisioning + caddy + landing + otelcol (OTLP + docker_stats) + prometheus/tempo/grafana + node_exporter + postgres_exporter; prod-deploy.sh
|
||||
```
|
||||
|
||||
@@ -143,6 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||
|
||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
|
||||
pnpm start # UI mock mode: lobby -> game, no backend
|
||||
cd renderer && pnpm install && pnpm test # image-render sidecar (bundles ui/src/lib/gameimage.ts, skia smoke)
|
||||
|
||||
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
|
||||
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/robot"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -232,6 +233,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// block and the banner admin console section.
|
||||
adsSvc := ads.NewService(ads.NewStore(db))
|
||||
|
||||
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||
var renderer *render.Client
|
||||
if cfg.RendererURL != "" {
|
||||
renderer = render.New(cfg.RendererURL)
|
||||
}
|
||||
|
||||
srv := server.New(cfg.HTTPAddr, server.Deps{
|
||||
Logger: logger,
|
||||
DB: db,
|
||||
@@ -253,6 +261,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Notifier: hub,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
})
|
||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ type Config struct {
|
||||
// GuestRetention is the account age past which an unused guest (no game seat)
|
||||
// is eligible for deletion by the reaper.
|
||||
GuestRetention time.Duration
|
||||
// ExportSignKey signs the finished-game export download URLs. Empty leaves
|
||||
// the export-URL endpoints disabled (503 on mint, 404 on download).
|
||||
ExportSignKey string
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
RendererURL string
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -151,6 +157,8 @@ func Load() (Config, error) {
|
||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
||||
GuestReapInterval: guestReapInterval,
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
|
||||
@@ -1363,30 +1363,53 @@ func (svc *Service) SetupDraws(ctx context.Context, gameID uuid.UUID) ([]SetupDr
|
||||
return svc.store.SetupDraws(ctx, gameID)
|
||||
}
|
||||
|
||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It
|
||||
// is allowed only on a finished game: exporting an in-progress game would leak the
|
||||
// full move journal mid-play, so an active game yields ErrGameActive.
|
||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
// ExportView returns a finished game with its journal and per-seat display names —
|
||||
// the material every export artifact (the GCG text, the PNG render payload) is built
|
||||
// from. It is allowed only on a finished game: exporting an in-progress game would
|
||||
// leak the full move journal mid-play, so an active game yields ErrGameActive. In an
|
||||
// honest-AI game the robot seat is labelled "AI", not its pool name.
|
||||
func (svc *Service) ExportView(ctx context.Context, gameID uuid.UUID) (Game, []HistoryMove, []string, error) {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Game{}, nil, nil, err
|
||||
}
|
||||
if g.Status != StatusFinished {
|
||||
return "", ErrGameActive
|
||||
return Game{}, nil, nil, ErrGameActive
|
||||
}
|
||||
moves, err := svc.store.GetJournal(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Game{}, nil, nil, err
|
||||
}
|
||||
names := svc.seatNames(ctx, g)
|
||||
if g.VsAI {
|
||||
// Label the robot seat "AI" in an honest-AI game's export, not its pool name.
|
||||
for _, s := range g.Seats {
|
||||
if robot, err := svc.accounts.IsRobot(ctx, s.AccountID); err == nil && robot {
|
||||
names[s.Seat] = aiPlayerName
|
||||
}
|
||||
}
|
||||
}
|
||||
return g, moves, names, nil
|
||||
}
|
||||
|
||||
// EnsureExportable reports whether a game may be exported (it exists and is
|
||||
// finished) without loading the journal — the export-URL mint check.
|
||||
func (svc *Service) EnsureExportable(ctx context.Context, gameID uuid.UUID) error {
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Status != StatusFinished {
|
||||
return ErrGameActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportGCG renders a game as GCG text from the journal alone (no dictionary).
|
||||
func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||
g, moves, names, err := svc.ExportView(ctx, gameID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return writeGCG(g, names, moves), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package render is the backend's client for the internal image-render sidecar: it POSTs
|
||||
// a finished game's export payload and receives the rasterized PNG. The sidecar runs the
|
||||
// same drawing code the web client unit-tests (ui/src/lib/gameimage.ts on skia-canvas);
|
||||
// authentication and the signed public URL live in the server layer — this client only
|
||||
// carries bytes.
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxPNG caps a render response; a scale-2 export of the largest plausible game is well
|
||||
// under 2 MiB, so anything bigger is a sidecar malfunction, not a valid image.
|
||||
const maxPNG = 8 << 20
|
||||
|
||||
// Client calls the render sidecar over the internal network.
|
||||
type Client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a client for the sidecar at baseURL (e.g. http://renderer:8090). A render
|
||||
// of a full game takes well under a second; the timeout leaves room for a cold start.
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{base: baseURL, http: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
// Render posts payload (the JSON request shape the sidecar consumes) and returns the PNG.
|
||||
func (c *Client) Render(ctx context.Context, payload any) ([]byte, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: marshal payload: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/render", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("render: sidecar status %d", resp.StatusCode)
|
||||
}
|
||||
png, err := io.ReadAll(io.LimitReader(resp.Body, maxPNG+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render: read response: %w", err)
|
||||
}
|
||||
if len(png) > maxPNG {
|
||||
return nil, fmt.Errorf("render: response exceeds %d bytes", maxPNG)
|
||||
}
|
||||
return png, nil
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Finished-game export downloads: a signed, short-lived, relative URL minted for a
|
||||
// participant and later fetched with no credentials at all — the platforms' native
|
||||
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain browser
|
||||
// anchor) strip cookies and headers, so the HMAC in the URL is the whole grant.
|
||||
// The client resolves the relative path against its own origin; the edge routes
|
||||
// /dl/* to the gateway, which forwards it here (/api/v1/public/dl/...).
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// exportURLTTL is how long a minted download URL stays valid. Long enough for the
|
||||
// platform's download dialog and a retry, short enough that a leaked link dies fast.
|
||||
const exportURLTTL = 10 * time.Minute
|
||||
|
||||
// Query/label size caps: the presentation params ride the signed URL, so they are
|
||||
// bounded defensively (they only ever hold a BCP-47 tag and four short UI words).
|
||||
const (
|
||||
maxDateLocaleLen = 35
|
||||
maxLabelsLen = 120
|
||||
maxTimeZoneLen = 50
|
||||
)
|
||||
|
||||
// exportActionOrder fixes the position of each non-play move label in the `t` CSV.
|
||||
var exportActionOrder = [4]string{"pass", "exchange", "resign", "timeout"}
|
||||
|
||||
// signExport computes the URL signature over the canonical parameter string. The
|
||||
// canonical form is independent of the public path prefix, so the gateway may mount
|
||||
// the route anywhere.
|
||||
func (s *Server) signExport(gameID, kind, exp, dateLocale, timeZone, labels string) string {
|
||||
mac := hmac.New(sha256.New, s.exportKey)
|
||||
fmt.Fprintf(mac, "%s|%s|%s|%s|%s|%s", gameID, kind, exp, dateLocale, timeZone, labels)
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// exportURLDTO is the minted link: a relative signed path plus the download filename.
|
||||
type exportURLDTO struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// handleExportURL mints the signed download path for a finished game's artifact.
|
||||
// GET /api/v1/user/games/:id/export-url?kind=png|gcg&dl=<date-locale>&tz=<IANA zone>&t=<labels CSV>
|
||||
func (s *Server) handleExportURL(c *gin.Context) {
|
||||
if len(s.exportKey) == 0 {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
|
||||
errorResponse{Error: errorBody{Code: "export_unavailable", Message: "export signing is not configured"}})
|
||||
return
|
||||
}
|
||||
_, gameID, ok := s.userGame(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind := c.Query("kind")
|
||||
if kind != "png" && kind != "gcg" {
|
||||
abortBadRequest(c, "invalid kind")
|
||||
return
|
||||
}
|
||||
dateLocale := c.Query("dl")
|
||||
timeZone := c.Query("tz")
|
||||
labels := c.Query("t")
|
||||
if len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||
abortBadRequest(c, "presentation params too long")
|
||||
return
|
||||
}
|
||||
if err := s.games.EnsureExportable(c.Request.Context(), gameID); err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
exp := strconv.FormatInt(time.Now().Add(exportURLTTL).Unix(), 10)
|
||||
sig := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||
q := url.Values{"e": {exp}, "s": {sig}}
|
||||
if dateLocale != "" {
|
||||
q.Set("l", dateLocale)
|
||||
}
|
||||
if timeZone != "" {
|
||||
q.Set("z", timeZone)
|
||||
}
|
||||
if labels != "" {
|
||||
q.Set("t", labels)
|
||||
}
|
||||
c.JSON(http.StatusOK, exportURLDTO{
|
||||
Path: "/dl/" + gameID.String() + "/" + kind + "?" + q.Encode(),
|
||||
Filename: exportFilename(gameID, kind),
|
||||
})
|
||||
}
|
||||
|
||||
func exportFilename(gameID uuid.UUID, kind string) string {
|
||||
return "game-" + gameID.String()[:8] + "." + kind
|
||||
}
|
||||
|
||||
// handleExportDownload serves a minted artifact. It is a public route: the signature
|
||||
// and expiry are the only authorization (see the package comment). Every failure is
|
||||
// a plain 404 so the endpoint is not a validity oracle.
|
||||
// GET /api/v1/public/dl/:id/:kind?e=&l=&z=&t=&s=
|
||||
func (s *Server) handleExportDownload(c *gin.Context) {
|
||||
if len(s.exportKey) == 0 {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
gameID, err := uuid.Parse(c.Param("id"))
|
||||
kind := c.Param("kind")
|
||||
exp := c.Query("e")
|
||||
dateLocale := c.Query("l")
|
||||
timeZone := c.Query("z")
|
||||
labels := c.Query("t")
|
||||
sig := c.Query("s")
|
||||
if err != nil || (kind != "png" && kind != "gcg") ||
|
||||
len(dateLocale) > maxDateLocaleLen || len(labels) > maxLabelsLen || len(timeZone) > maxTimeZoneLen {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
want := s.signExport(gameID.String(), kind, exp, dateLocale, timeZone, labels)
|
||||
if subtleNeq(sig, want) {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if unix, err := strconv.ParseInt(exp, 10, 64); err != nil || time.Now().Unix() > unix {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
filename := exportFilename(gameID, kind)
|
||||
if kind == "gcg" {
|
||||
gcg, err := s.games.ExportGCG(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
serveAttachment(c, filename, "text/plain; charset=utf-8", []byte(gcg))
|
||||
return
|
||||
}
|
||||
|
||||
if s.renderer == nil {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
g, moves, names, err := s.games.ExportView(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
payload, err := renderPayload(g, moves, names, dateLocale, timeZone, labels, c.GetHeader("X-Public-Host"))
|
||||
if err != nil {
|
||||
s.log.Warn("export render payload", zap.Error(err))
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
png, err := s.renderer.Render(c.Request.Context(), payload)
|
||||
if err != nil {
|
||||
s.log.Warn("export render", zap.Error(err))
|
||||
c.String(http.StatusServiceUnavailable, "render unavailable")
|
||||
return
|
||||
}
|
||||
serveAttachment(c, filename, "image/png", png)
|
||||
}
|
||||
|
||||
// subtleNeq compares two signature strings in constant time.
|
||||
func subtleNeq(got, want string) bool {
|
||||
return !hmac.Equal([]byte(got), []byte(want))
|
||||
}
|
||||
|
||||
// serveAttachment writes bytes as a named file download. Content-Length is set
|
||||
// explicitly: a body over net/http's write buffer otherwise goes out chunked, and
|
||||
// Android's system DownloadManager (which VKWebAppDownloadFile delegates to) hangs
|
||||
// forever on a download of unknown length.
|
||||
func serveAttachment(c *gin.Context, filename, contentType string, data []byte) {
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(filename)+`"`)
|
||||
c.Header("Cache-Control", "private, max-age=0")
|
||||
c.Header("Content-Length", strconv.Itoa(len(data)))
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
// --- the render-sidecar request payload (the ui-model shapes drawGameImage consumes) ---
|
||||
|
||||
type renderSeatJSON struct {
|
||||
Seat int `json:"seat"`
|
||||
AccountID string `json:"accountId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Score int `json:"score"`
|
||||
HintsUsed int `json:"hintsUsed"`
|
||||
IsWinner bool `json:"isWinner"`
|
||||
}
|
||||
|
||||
type renderGameJSON struct {
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant"`
|
||||
Status string `json:"status"`
|
||||
Players int `json:"players"`
|
||||
LastActivityUnix int64 `json:"lastActivityUnix"`
|
||||
Seats []renderSeatJSON `json:"seats"`
|
||||
}
|
||||
|
||||
type renderTileJSON struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter string `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
type renderMoveJSON struct {
|
||||
Player int `json:"player"`
|
||||
Action string `json:"action"`
|
||||
Dir string `json:"dir"`
|
||||
MainRow int `json:"mainRow"`
|
||||
MainCol int `json:"mainCol"`
|
||||
Tiles []renderTileJSON `json:"tiles"`
|
||||
Words []string `json:"words"`
|
||||
Count int `json:"count"`
|
||||
Score int `json:"score"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type renderAlphaJSON struct {
|
||||
Index int `json:"index"`
|
||||
Letter string `json:"letter"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
type renderRequestJSON struct {
|
||||
Game renderGameJSON `json:"game"`
|
||||
Moves []renderMoveJSON `json:"moves"`
|
||||
Alphabet []renderAlphaJSON `json:"alphabet"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
DateLocale string `json:"dateLocale"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// renderPayload assembles the sidecar request from the export view. Letters are
|
||||
// upper-cased for display exactly as the client codec does; the localized non-play
|
||||
// labels arrive as a positional CSV (see exportActionOrder) minted into the URL.
|
||||
func renderPayload(g game.Game, moves []game.HistoryMove, names []string, dateLocale, timeZone, labelsCSV, host string) (renderRequestJSON, error) {
|
||||
alpha, err := engine.AlphabetTable(g.Variant)
|
||||
if err != nil {
|
||||
return renderRequestJSON{}, fmt.Errorf("alphabet for %s: %w", g.Variant, err)
|
||||
}
|
||||
req := renderRequestJSON{
|
||||
Game: renderGameJSON{
|
||||
ID: g.ID.String(),
|
||||
Variant: g.Variant.String(),
|
||||
Status: g.Status,
|
||||
Players: g.Players,
|
||||
},
|
||||
Labels: map[string]string{},
|
||||
DateLocale: dateLocale,
|
||||
TimeZone: timeZone,
|
||||
Hostname: host,
|
||||
}
|
||||
finished := g.UpdatedAt
|
||||
if g.FinishedAt != nil {
|
||||
finished = *g.FinishedAt
|
||||
}
|
||||
req.Game.LastActivityUnix = finished.Unix()
|
||||
for _, st := range g.Seats {
|
||||
name := st.DisplayName
|
||||
if st.Seat < len(names) {
|
||||
name = names[st.Seat]
|
||||
}
|
||||
req.Game.Seats = append(req.Game.Seats, renderSeatJSON{
|
||||
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: name,
|
||||
Score: st.Score, HintsUsed: st.HintsUsed, IsWinner: st.IsWinner,
|
||||
})
|
||||
}
|
||||
for _, m := range moves {
|
||||
mv := renderMoveJSON{
|
||||
Player: m.Seat, Action: m.Action, Dir: m.Dir,
|
||||
MainRow: m.MainRow, MainCol: m.MainCol,
|
||||
Words: make([]string, 0, len(m.Words)),
|
||||
Count: len(m.Exchanged), Score: m.Score, Total: m.RunningTotal,
|
||||
Tiles: make([]renderTileJSON, 0, len(m.Tiles)),
|
||||
}
|
||||
for _, w := range m.Words {
|
||||
mv.Words = append(mv.Words, strings.ToUpper(w))
|
||||
}
|
||||
for _, t := range m.Tiles {
|
||||
mv.Tiles = append(mv.Tiles, renderTileJSON{Row: t.Row, Col: t.Col, Letter: strings.ToUpper(t.Letter), Blank: t.Blank})
|
||||
}
|
||||
req.Moves = append(req.Moves, mv)
|
||||
}
|
||||
for _, a := range alpha {
|
||||
req.Alphabet = append(req.Alphabet, renderAlphaJSON{Index: int(a.Index), Letter: strings.ToUpper(a.Letter), Value: a.Value})
|
||||
}
|
||||
if labelsCSV != "" {
|
||||
parts := strings.Split(labelsCSV, ",")
|
||||
for i, action := range exportActionOrder {
|
||||
if i < len(parts) && parts[i] != "" {
|
||||
req.Labels[action] = parts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// newExportServer is the routing harness with a signing key installed, so the
|
||||
// pre-service branches of the export endpoints are reachable.
|
||||
func newExportServer() *Server {
|
||||
return New(":0", Deps{
|
||||
Sessions: &session.Service{},
|
||||
Accounts: &account.Store{},
|
||||
Games: &game.Service{},
|
||||
ExportSignKey: "test-key",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) {
|
||||
s := newExportServer()
|
||||
sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен")
|
||||
if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") {
|
||||
t.Fatal("same inputs produced different signatures")
|
||||
}
|
||||
for _, tampered := range []string{
|
||||
s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"),
|
||||
s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"),
|
||||
s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"),
|
||||
} {
|
||||
if tampered == sig {
|
||||
t.Fatal("tampered input produced the same signature")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportURLWithoutKeyIs503(t *testing.T) {
|
||||
s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}})
|
||||
rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "",
|
||||
map[string]string{"X-User-ID": uuid.NewString()})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("mint without key = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportURLRejectsBadParams(t *testing.T) {
|
||||
s := newExportServer()
|
||||
uid := map[string]string{"X-User-ID": uuid.NewString()}
|
||||
gid := uuid.NewString()
|
||||
|
||||
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad kind = %d, want 400", rec.Code)
|
||||
}
|
||||
long := strings.Repeat("x", maxLabelsLen+1)
|
||||
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("oversized labels = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportDownloadRejectsBadSignatures(t *testing.T) {
|
||||
s := newExportServer()
|
||||
gid := uuid.NewString()
|
||||
exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||||
|
||||
// A wrong signature never reaches the domain (404 before any service call).
|
||||
url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged"
|
||||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("forged signature = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// A valid signature over an EXPIRED timestamp is refused the same way.
|
||||
old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||||
url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "")
|
||||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expired link = %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// An unknown kind is refused before signature checks.
|
||||
url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x"
|
||||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("bad kind = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPayloadMapsTheExportView(t *testing.T) {
|
||||
gid := uuid.New()
|
||||
finished := time.Unix(1_782_997_629, 0)
|
||||
g := game.Game{
|
||||
ID: gid,
|
||||
Variant: engine.VariantRussianScrabble,
|
||||
Status: game.StatusFinished,
|
||||
Players: 2,
|
||||
FinishedAt: &finished,
|
||||
Seats: []game.Seat{
|
||||
{Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"},
|
||||
{Seat: 1, Score: 30},
|
||||
},
|
||||
}
|
||||
moves := []game.HistoryMove{
|
||||
{
|
||||
Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4,
|
||||
Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}},
|
||||
Words: []string{"чика"}, Score: 9, RunningTotal: 9,
|
||||
},
|
||||
{Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0},
|
||||
}
|
||||
names := []string{"Аня", "Боб"}
|
||||
|
||||
p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru")
|
||||
if err != nil {
|
||||
t.Fatalf("renderPayload: %v", err)
|
||||
}
|
||||
if p.Game.LastActivityUnix != finished.Unix() {
|
||||
t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix())
|
||||
}
|
||||
if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner {
|
||||
t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0])
|
||||
}
|
||||
if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" {
|
||||
t.Fatalf("letters not upper-cased: %+v", p.Moves[0])
|
||||
}
|
||||
if p.Moves[1].Count != 2 {
|
||||
t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count)
|
||||
}
|
||||
if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" {
|
||||
t.Fatalf("labels not mapped positionally: %v", p.Labels)
|
||||
}
|
||||
if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" {
|
||||
t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone)
|
||||
}
|
||||
for _, a := range p.Alphabet {
|
||||
if a.Letter != strings.ToUpper(a.Letter) {
|
||||
t.Fatalf("alphabet letter %q not upper-cased", a.Letter)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,17 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/games/:id/complaint", s.handleComplaint)
|
||||
u.GET("/games/:id/history", s.handleHistory)
|
||||
u.GET("/games/:id/gcg", s.handleExportGCG)
|
||||
u.GET("/games/:id/export-url", s.handleExportURL)
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
// Raw dictionary download for the client-side local move preview, keyed by
|
||||
// the game's pinned (variant, version); immutable, so cached hard.
|
||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||
// The signed finished-game export download — the only public data route:
|
||||
// the URL's HMAC + expiry are the whole grant (export.go), because the
|
||||
// platforms' native download calls carry no credentials.
|
||||
s.public.GET("/dl/:id/:kind", s.handleExportDownload)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/session"
|
||||
"scrabble/backend/internal/social"
|
||||
"scrabble/backend/internal/telemetry"
|
||||
@@ -96,6 +97,12 @@ type Deps struct {
|
||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||
// them (notify.Nop).
|
||||
Notifier notify.Publisher
|
||||
// ExportSignKey signs the finished-game export download URLs (export.go). An
|
||||
// empty key leaves the export-URL endpoints answering 503/404.
|
||||
ExportSignKey string
|
||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||
Renderer *render.Client
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -124,6 +131,8 @@ type Server struct {
|
||||
ads *ads.Service
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
renderer *render.Client
|
||||
|
||||
public *gin.RouterGroup
|
||||
user *gin.RouterGroup
|
||||
@@ -173,8 +182,12 @@ func New(addr string, deps Deps) *Server {
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
}
|
||||
if deps.ExportSignKey != "" {
|
||||
s.exportKey = []byte(deps.ExportSignKey)
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
s.registerAPIGroups(engine)
|
||||
s.registerRoutes()
|
||||
|
||||
@@ -21,6 +21,11 @@ DICT_VERSION=v1.3.0
|
||||
# --- Logging ----------------------------------------------------------------
|
||||
LOG_LEVEL=info
|
||||
|
||||
# --- Finished-game export ----------------------------------------------------
|
||||
# HMAC key signing the public export download URLs (/dl/*). Required; generate a
|
||||
# real contour value with `openssl rand -base64 32` (Gitea TEST_/PROD_EXPORT_SIGN_KEY).
|
||||
EXPORT_SIGN_KEY=dev-export-sign-key
|
||||
|
||||
# --- Edge / caddy -----------------------------------------------------------
|
||||
# Test: ":80" (the host caddy terminates TLS and forwards to scrabble:80 on the
|
||||
# external `edge` network). Prod: a domain so caddy does its own ACME.
|
||||
|
||||
+5
-2
@@ -16,6 +16,7 @@ operational reference for **every environment variable**.
|
||||
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
|
||||
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
|
||||
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
|
||||
| `renderer` | built (`renderer/Dockerfile`) | Finished-game image-render sidecar (Node + skia-canvas running the shared `ui/src/lib/gameimage.ts`); internal-only HTTP at `renderer:8090`, called by the backend for the PNG export artifact. |
|
||||
| `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
|
||||
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot, gated to the **`telegram-local`** profile; egresses through the AmneziaWG sidecar and dials the gateway bot-link (mTLS) at `gateway:9443`. The test contour activates the profile; the prod **main** host omits it and runs the bot standalone on its **own host** (`docker-compose.bot.yml`, no VPN — native Bot API egress). |
|
||||
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
|
||||
@@ -62,6 +63,7 @@ compose binds from this directory.
|
||||
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
|
||||
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
|
||||
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
|
||||
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
|
||||
|
||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||
@@ -106,7 +108,8 @@ at each other on the `internal` network — listed here so they are not mistaken
|
||||
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
||||
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
||||
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
||||
bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
||||
bot-link relay `gateway:9092`), `BACKEND_RENDERER_URL` (→ `renderer:8090`, the image-render
|
||||
sidecar), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
||||
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
||||
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
||||
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
||||
@@ -194,7 +197,7 @@ players arrive.
|
||||
|
||||
**`PROD_` Gitea set** (mirrors `TEST_`, mapped onto the unprefixed names above) — secrets:
|
||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||
TELEGRAM_PROMO_BOT_TOKEN, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables:
|
||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
|
||||
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /metrics/* /scrabble.edge.v1.Gateway/*
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
|
||||
handle @gateway {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -50,6 +50,13 @@ services:
|
||||
limits:
|
||||
memory: 384M
|
||||
|
||||
renderer:
|
||||
image: ${REGISTRY:?set REGISTRY}/scrabble-renderer:${TAG:?set TAG}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 160M
|
||||
|
||||
validator:
|
||||
image: ${REGISTRY:?set REGISTRY}/scrabble-telegram-validator:${TAG:?set TAG}
|
||||
deploy:
|
||||
|
||||
@@ -61,6 +61,36 @@ services:
|
||||
memory: 512M
|
||||
networks: [internal]
|
||||
|
||||
# The finished-game image-render sidecar: internal-only, called by the backend for the
|
||||
# PNG export artifact. It runs the same ui/src/lib/gameimage.ts the web client
|
||||
# unit-tests, on skia-canvas (renderer/README.md). node:22-slim carries a shell, so —
|
||||
# uniquely among the built services — it has a real container healthcheck the backend's
|
||||
# depends_on gates on.
|
||||
renderer:
|
||||
container_name: scrabble-renderer
|
||||
image: scrabble-renderer:latest
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: renderer/Dockerfile
|
||||
args:
|
||||
VERSION: ${APP_VERSION:-dev}
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
environment:
|
||||
RENDERER_PORT: "8090"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
# A render peaks well under 100 MiB (a 2-MP canvas + skia); idle sits near 60 MiB.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 192M
|
||||
networks: [internal]
|
||||
|
||||
backend:
|
||||
container_name: scrabble-backend
|
||||
image: scrabble-backend:latest
|
||||
@@ -80,9 +110,15 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
renderer:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# search_path=backend matches the migrations (00001 creates the schema).
|
||||
BACKEND_POSTGRES_DSN: postgres://${POSTGRES_USER:-scrabble}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-scrabble}?sslmode=disable&search_path=backend
|
||||
# The finished-game export: the render sidecar address + the HMAC key signing the
|
||||
# public download URLs (deploy env: TEST_/PROD_EXPORT_SIGN_KEY).
|
||||
BACKEND_RENDERER_URL: http://renderer:8090
|
||||
BACKEND_EXPORT_SIGN_KEY: ${EXPORT_SIGN_KEY:?set EXPORT_SIGN_KEY — the export download URL signing secret}
|
||||
# The pool caps at 25 conns (~28 backends) around 500 players; 40 gives headroom
|
||||
# for bursts. Postgres (2 cores / 512 MiB) handles it.
|
||||
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
|
||||
|
||||
@@ -134,6 +134,7 @@ fi
|
||||
|
||||
# Roll one service at a time, least -> most dependent; any failure rolls everything back.
|
||||
roll postgres health_postgres || { rollback; exit 1; }
|
||||
roll renderer health_running scrabble-renderer || { rollback; exit 1; }
|
||||
roll backend health_backend || { rollback; exit 1; }
|
||||
roll gateway health_running scrabble-gateway || { rollback; exit 1; }
|
||||
roll landing health_landing || { rollback; exit 1; }
|
||||
|
||||
+34
-5
@@ -785,11 +785,40 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
|
||||
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
||||
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
|
||||
otherwise), so an in-progress journal is never leaked mid-play; the client
|
||||
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
|
||||
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
|
||||
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
|
||||
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
|
||||
otherwise), so an in-progress journal is never leaked mid-play.
|
||||
|
||||
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
|
||||
and the rendered **PNG of the final position** — travel one uniform route on every
|
||||
platform: the client calls the authenticated `game.export_url` op, the backend mints a
|
||||
**relative, HMAC-signed, short-lived path**
|
||||
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
|
||||
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
|
||||
service ever needs to know the public host) and hands it to the best affordance
|
||||
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
|
||||
chooser there is the native showPopup — activation-free bridge calls end to end),
|
||||
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
|
||||
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
|
||||
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
|
||||
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
|
||||
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
|
||||
iframe). The gateway serves the bytes via
|
||||
`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
|
||||
hangs on chunked bodies of unknown length). The GET is the gateway's
|
||||
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
|
||||
per-IP public rate limiter): the native download calls carry no cookies or headers, so
|
||||
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
|
||||
time, every failure a uniform 404 — is the whole grant, and minting requires an
|
||||
authenticated caller on a finished game. The PNG is rasterized on demand by the
|
||||
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
|
||||
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
|
||||
`renderer/README.md`); the backend rebuilds the render payload from the journal +
|
||||
`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized
|
||||
non-play labels ride the signed URL so the server render matches the player's
|
||||
presentation. Nothing is stored: the artifact is re-derived from the immutable
|
||||
finished journal on each GET. The only degraded platform is a legacy Telegram client
|
||||
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||
image option is not offered.
|
||||
|
||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||
|
||||
+32
-25
@@ -322,33 +322,40 @@ Finished games are archived in a dictionary-independent form and exportable in
|
||||
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
|
||||
the final position; the export is offered **only once a game is finished**, and
|
||||
never for an honest-AI practice game (a live game's export would leak the move
|
||||
journal; an AI game is throwaway). The format chooser is always the app's **own
|
||||
modal** — deliberately not Telegram's native popup, whose callback runs without
|
||||
user activation and silently breaks the share/clipboard delivery it leads to.
|
||||
journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
|
||||
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
|
||||
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
|
||||
is the OS share sheet, which a native-popup callback cannot open, and VK has no
|
||||
native chooser.
|
||||
|
||||
The **image** is rendered on the client (Canvas 2D, always the light theme): the
|
||||
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
|
||||
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
|
||||
right: each seat's name and final score in the header (🏆 by the winner), then one
|
||||
row per move carrying the main word's classic coordinate (an across play is
|
||||
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
|
||||
its points; extra words of a multi-word play ride a smaller second line, non-play
|
||||
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
|
||||
shows the endgame rack settlement when there was one (the final scores are
|
||||
authoritative — running totals alone do not include it). The footer stamps the site
|
||||
host and the finish date in the device locale. The scoresheet typography is fixed;
|
||||
a long game stretches the board (never below its minimum) so the image carries no
|
||||
dead space.
|
||||
The **image** is rendered on the server (the internal render sidecar runs the same
|
||||
drawing module the web client tests; always the light theme): the final board with
|
||||
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
|
||||
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
|
||||
name and final score in the header (🏆 by the winner), then one row per move
|
||||
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
|
||||
down play column-first, `H8` — the GCG convention), the word and its points; extra
|
||||
words of a multi-word play ride a smaller second line, non-play moves show as
|
||||
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
|
||||
endgame rack settlement when there was one (the final scores are authoritative —
|
||||
running totals alone do not include it). The footer stamps the site host and the
|
||||
finish date in the device locale. The scoresheet typography is fixed; a long game
|
||||
stretches the board (never below its minimum) so the image carries no dead space.
|
||||
|
||||
Delivery per format: the **GCG** file is Web-Shared where the platform supports it;
|
||||
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
|
||||
working file download, it copies the GCG text to the clipboard (with a confirming
|
||||
toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the
|
||||
same way, but a binary image has no clipboard-text fallback at all — so the image
|
||||
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
|
||||
offered on the plain web and mobile browsers only; it returns there with the
|
||||
server-rendered signed-URL delivery (Telegram `downloadFile` /
|
||||
`VKWebAppDownloadFile`). Statistics (durable accounts only):
|
||||
Delivery is **one signed, short-lived link for both formats on every platform**,
|
||||
handed to the best affordance each platform has (each branch verified on-device):
|
||||
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
|
||||
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
|
||||
takes both formats through VK's download into its native share flow, while on the
|
||||
**VK Android client** — whose downloader hangs — the image opens in VK's native
|
||||
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
|
||||
desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets
|
||||
the OS share sheet** (nothing lands in Downloads first); a desktop browser
|
||||
downloads the file. The link needs no login to fetch (the platforms' downloaders
|
||||
carry none) and is valid for minutes. The single exception is a legacy Telegram
|
||||
client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
|
||||
the old clipboard copy (with the confirming toast) and the image option is not
|
||||
offered. Statistics (durable accounts only):
|
||||
wins, losses, draws, max points in a game, and max points for a single move (the
|
||||
best play, which already includes every word it formed plus the all-tiles bonus). It
|
||||
also shows the player's **move count** (their plays — passes and exchanges do not
|
||||
|
||||
+21
-13
@@ -331,11 +331,14 @@ Telegram.
|
||||
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
|
||||
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
|
||||
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
|
||||
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения,
|
||||
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
|
||||
молча ломает доставку через share/буфер, к которой ведёт выбор.
|
||||
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
|
||||
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
|
||||
которым user activation не нужна) и собственный модал приложения в остальных
|
||||
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
|
||||
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
|
||||
|
||||
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
|
||||
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
|
||||
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
|
||||
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
||||
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
||||
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
||||
@@ -348,15 +351,20 @@ Telegram.
|
||||
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
||||
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
||||
|
||||
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
|
||||
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
|
||||
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
|
||||
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
|
||||
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
|
||||
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
|
||||
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
|
||||
и доставкой по подписанному URL (Telegram `downloadFile` /
|
||||
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
|
||||
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
|
||||
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
|
||||
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
|
||||
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
|
||||
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
|
||||
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
|
||||
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
|
||||
(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер
|
||||
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
|
||||
браузер скачивает файл. Ссылка не требует логина при
|
||||
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
|
||||
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
|
||||
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
|
||||
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||
|
||||
+9
-2
@@ -19,8 +19,15 @@ tests or touching CI.
|
||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||
invitations section, the stats screen, profile editing, and the GCG export's
|
||||
finished-only visibility.
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted).
|
||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||
real self-played 35-move game) must rasterize to a plausible PNG
|
||||
(`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
|
||||
notation) stay unit-tested in `ui/` — the sidecar test guards only the
|
||||
skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
|
||||
hosts).
|
||||
- **Local-eval conformance** — the client's on-device move preview (the ported
|
||||
dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the
|
||||
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
|
||||
|
||||
+30
-23
@@ -406,29 +406,36 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
||||
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
|
||||
(the word(s) and the move score, no running total); the 📤 in the history header appears
|
||||
only once the game is finished — never in an honest-AI game (throwaway practice) — and
|
||||
opens the **format chooser**: always the app's own modal, never Telegram's native popup
|
||||
(a `showPopup` callback carries no user activation, which silently breaks the
|
||||
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
|
||||
leads with the image where offered, the GCG file is the quiet alternative (GCG needs the
|
||||
connection, the image renders locally). The GCG file goes by Web Share where available, a
|
||||
clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor
|
||||
a working download), else a Blob download. The image option shows **only outside the in-app
|
||||
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
|
||||
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
|
||||
returns there with the server-rendered signed-URL delivery. Confirming a resign reveals
|
||||
the full board: it closes the history drawer (portrait) and zooms the board out.
|
||||
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
|
||||
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
|
||||
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
|
||||
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
|
||||
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
|
||||
the winner), one fixed-typography row per move — muted classic coordinate (across =
|
||||
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
|
||||
words of a multi-word play on a smaller second line; italic localized notes for
|
||||
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
|
||||
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
|
||||
height drives the board side (never below its minimum): a long game stretches the board,
|
||||
never the typography, so the image has no dead space.
|
||||
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
|
||||
there, since that chain is all bridge calls needing no user activation (a popup callback
|
||||
must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
|
||||
own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
|
||||
click's activation; VK has no native chooser; the accent button leads with the image,
|
||||
both options need the connection). Both formats mint the same signed relative link
|
||||
(`game.export_url`), resolved against the app's own origin, then delivered per platform
|
||||
(each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
|
||||
shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
|
||||
`VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
|
||||
DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
|
||||
works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a
|
||||
mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop
|
||||
browser an anchor download. A legacy Telegram
|
||||
client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
|
||||
and stays on the app modal. Confirming a resign reveals the full board: it closes the
|
||||
history drawer (portrait) and zooms the board out.
|
||||
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
|
||||
executes; the browser app no longer draws it): always the light palette (pinned constants
|
||||
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
|
||||
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
|
||||
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
|
||||
the scoresheet: per-seat name over the final score (🏆 by the winner), one
|
||||
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
|
||||
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
|
||||
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
|
||||
closing muted ± row for the endgame rack settlement when present. Footer:
|
||||
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
|
||||
board side (never below its minimum): a long game stretches the board, never the
|
||||
typography, so the image has no dead space.
|
||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The response structs and client methods mirror the backend's social,
|
||||
@@ -347,3 +348,28 @@ func (c *Client) ExportGCG(ctx context.Context, userID, gameID string) (GcgResp,
|
||||
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ExportURLResp is the minted signed download link for an export artifact.
|
||||
type ExportURLResp struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// ExportURL mints a signed download URL for a finished game's artifact (kind
|
||||
// "png" or "gcg"). dateLocale and the localized non-play labels ride the URL so
|
||||
// the server render matches the player's presentation.
|
||||
func (c *Client) ExportURL(ctx context.Context, userID, gameID, kind, dateLocale, timeZone string, labels []string) (ExportURLResp, error) {
|
||||
q := url.Values{"kind": {kind}}
|
||||
if dateLocale != "" {
|
||||
q.Set("dl", dateLocale)
|
||||
}
|
||||
if timeZone != "" {
|
||||
q.Set("tz", timeZone)
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
q.Set("t", strings.Join(labels, ","))
|
||||
}
|
||||
var out ExportURLResp
|
||||
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/export-url")+"?"+q.Encode(), userID, "", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -39,10 +39,18 @@ const backendMaxIdleConns = 512
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
conn *grpc.ClientConn
|
||||
push pushv1.PushClient
|
||||
// dl serves the export downloads: the backend may wait on the render sidecar
|
||||
// for several seconds, so these calls get their own, longer deadline than the
|
||||
// ordinary REST budget.
|
||||
dl *http.Client
|
||||
conn *grpc.ClientConn
|
||||
push pushv1.PushClient
|
||||
}
|
||||
|
||||
// exportDownloadTimeout bounds one export-download fetch end to end (the backend's
|
||||
// own sidecar budget is 15s).
|
||||
const exportDownloadTimeout = 30 * time.Second
|
||||
|
||||
// New dials the backend push gRPC endpoint and prepares the REST client. The
|
||||
// backend lives on a trusted network segment, so the gRPC connection uses
|
||||
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
||||
@@ -62,11 +70,39 @@ func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(httpURL, "/"),
|
||||
http: &http.Client{Timeout: timeout, Transport: transport},
|
||||
dl: &http.Client{Timeout: exportDownloadTimeout, Transport: transport},
|
||||
conn: conn,
|
||||
push: pushv1.NewPushClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExportDownload fetches a signed finished-game export artifact from the backend's
|
||||
// public group, forwarding the caller's public Host for the image footer. It returns
|
||||
// the bytes with the backend's Content-Type and Content-Disposition. rest is the
|
||||
// public path suffix after /dl (e.g. "/<game>/<kind>?e=…&s=…").
|
||||
func (c *Client) ExportDownload(ctx context.Context, rest, publicHost string) ([]byte, string, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/public/dl"+rest, nil)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("backendclient: new request: %w", err)
|
||||
}
|
||||
if publicHost != "" {
|
||||
req.Header.Set("X-Public-Host", publicHost)
|
||||
}
|
||||
resp, err := c.dl.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("backendclient: GET export: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("backendclient: read export: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, "", "", parseAPIError(resp.StatusCode, data)
|
||||
}
|
||||
return data, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Disposition"), nil
|
||||
}
|
||||
|
||||
// Close releases the gRPC connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
@@ -193,6 +194,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// The client-side local move preview pulls each game's pinned dictionary blob
|
||||
// through this session-gated route (not public); see dictBytesHandler.
|
||||
mux.Handle("/dict/", s.dictBytesHandler())
|
||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||
@@ -415,6 +417,63 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
|
||||
// user-class limiter, and forwards the backend's immutable Cache-Control so the
|
||||
// browser caches the blob hard. Only GET is allowed; the path is
|
||||
// /dict/{variant}/{version}.
|
||||
// exportDownloadHandler serves the signed finished-game export downloads (/dl/*).
|
||||
// It is the gateway's only unauthenticated data route: the platforms' native
|
||||
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain anchor)
|
||||
// carry no session, so the URL's HMAC — verified by the backend — is the whole
|
||||
// grant. The gateway only rate-limits by IP and forwards, passing the public Host
|
||||
// along for the image footer.
|
||||
func (s *Server) exportDownloadHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ip := peerIP(r.RemoteAddr, r.Header)
|
||||
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
||||
s.noteRateLimited(r.Context(), classPublic, ip, "export-dl")
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/dl")
|
||||
if rest == "" || rest == "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.RawQuery != "" {
|
||||
rest += "?" + r.URL.RawQuery
|
||||
}
|
||||
data, contentType, disposition, err := s.backend.ExportDownload(r.Context(), rest, r.Host)
|
||||
if err != nil {
|
||||
var apiErr *backendclient.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
|
||||
http.NotFound(w, r) // invalid, expired or unknown link
|
||||
return
|
||||
}
|
||||
s.log.Warn("export download failed", zap.Error(err))
|
||||
http.Error(w, "bad gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if disposition != "" {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "private, max-age=0")
|
||||
// ServeContent (not a bare Write): the platforms' native downloaders are picky —
|
||||
// Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a
|
||||
// chunked body of unknown length and may probe with Range. ServeContent emits
|
||||
// Content-Length, honours Range/If-* and answers 206s from the buffered artifact.
|
||||
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) dictBytesHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backend == nil {
|
||||
|
||||
@@ -251,6 +251,18 @@ func encodeInvitationList(r backendclient.InvitationListResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeExportURL builds an ExportUrl payload.
|
||||
func encodeExportURL(r backendclient.ExportURLResp) []byte {
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
path := b.CreateString(r.Path)
|
||||
fn := b.CreateString(r.Filename)
|
||||
fb.ExportUrlStart(b)
|
||||
fb.ExportUrlAddPath(b, path)
|
||||
fb.ExportUrlAddFilename(b, fn)
|
||||
b.Finish(fb.ExportUrlEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeGcg builds a GcgExport payload.
|
||||
func encodeGcg(r backendclient.GcgResp) []byte {
|
||||
b := flatbuffers.NewBuilder(1024)
|
||||
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
MsgProfileUpdate = "profile.update"
|
||||
MsgStatsGet = "stats.get"
|
||||
MsgGameGCG = "game.gcg"
|
||||
MsgGameExportURL = "game.export_url"
|
||||
)
|
||||
|
||||
// registerSocialOps adds the social, account and history operations to the
|
||||
@@ -56,6 +57,7 @@ func registerSocialOps(r *Registry, backend *backendclient.Client) {
|
||||
r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
|
||||
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
|
||||
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
|
||||
r.ops[MsgGameExportURL] = Op{Handler: exportURLHandler(backend), Auth: true}
|
||||
}
|
||||
|
||||
// --- friends ---
|
||||
@@ -290,6 +292,21 @@ func gcgHandler(backend *backendclient.Client) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func exportURLHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsExportUrlRequest(req.Payload, 0)
|
||||
labels := make([]string, 0, in.ActionLabelsLength())
|
||||
for i := range in.ActionLabelsLength() {
|
||||
labels = append(labels, string(in.ActionLabels(i)))
|
||||
}
|
||||
res, err := backend.ExportURL(ctx, req.UserID, string(in.GameId()), string(in.Kind()), string(in.DateLocale()), string(in.TimeZone()), labels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeExportURL(res), nil
|
||||
}
|
||||
}
|
||||
|
||||
// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest.
|
||||
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
|
||||
n := in.InviteeIdsLength()
|
||||
|
||||
@@ -263,6 +263,54 @@ func TestGcgRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportURLRoundTrip(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/user/games/g-1/export-url" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("kind") != "png" || q.Get("dl") != "ru-RU" || q.Get("tz") != "Europe/Moscow" || q.Get("t") != "пас,обмен,сдался,таймаут" {
|
||||
t.Errorf("unexpected query %q", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"path":"/dl/g-1/png?e=1&s=x","filename":"game-g-1.png"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
op, _ := reg.Lookup(transcode.MsgGameExportURL)
|
||||
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
gid := b.CreateString("g-1")
|
||||
kind := b.CreateString("png")
|
||||
dl := b.CreateString("ru-RU")
|
||||
tz := b.CreateString("Europe/Moscow")
|
||||
labels := make([]flatbuffers.UOffsetT, 0, 4)
|
||||
for _, s := range []string{"пас", "обмен", "сдался", "таймаут"} {
|
||||
labels = append(labels, b.CreateString(s))
|
||||
}
|
||||
fb.ExportUrlRequestStartActionLabelsVector(b, len(labels))
|
||||
for i := len(labels) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(labels[i])
|
||||
}
|
||||
vec := b.EndVector(len(labels))
|
||||
fb.ExportUrlRequestStart(b)
|
||||
fb.ExportUrlRequestAddGameId(b, gid)
|
||||
fb.ExportUrlRequestAddKind(b, kind)
|
||||
fb.ExportUrlRequestAddDateLocale(b, dl)
|
||||
fb.ExportUrlRequestAddActionLabels(b, vec)
|
||||
fb.ExportUrlRequestAddTimeZone(b, tz)
|
||||
b.Finish(fb.ExportUrlRequestEnd(b))
|
||||
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
res := fb.GetRootAsExportUrl(payload, 0)
|
||||
if string(res.Path()) != "/dl/g-1/png?e=1&s=x" || string(res.Filename()) != "game-g-1.png" {
|
||||
t.Fatalf("export url decoded wrong: path=%q filename=%q", res.Path(), res.Filename())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileUpdateRoundTripAway(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -658,6 +658,27 @@ table GcgExport {
|
||||
content:string;
|
||||
}
|
||||
|
||||
// ExportUrlRequest asks for a signed download URL of a finished game's export artifact.
|
||||
// kind is "png" or "gcg". For the PNG the client passes its presentation context — the
|
||||
// UI-localized non-play move labels (pass, exchange, resign, timeout, in that order),
|
||||
// the device date locale and the device IANA time zone — which ride the signed URL so
|
||||
// the server render matches what the player would have seen locally.
|
||||
table ExportUrlRequest {
|
||||
game_id:string;
|
||||
kind:string;
|
||||
date_locale:string;
|
||||
action_labels:[string];
|
||||
time_zone:string;
|
||||
}
|
||||
|
||||
// ExportUrl is the minted download link: a relative, signed, short-lived path the client
|
||||
// resolves against its own origin (the SPA and the gateway share it), plus the filename
|
||||
// the download will carry.
|
||||
table ExportUrl {
|
||||
path:string;
|
||||
filename:string;
|
||||
}
|
||||
|
||||
// --- push event payloads ---
|
||||
|
||||
// YourTurnEvent signals that it is now the recipient's turn. The trailing fields enrich the
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ExportUrl struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ExportUrl{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsExportUrl(buf []byte, offset flatbuffers.UOffsetT) *ExportUrl {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ExportUrl{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedExportUrlBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ExportUrl) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ExportUrl) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ExportUrl) Path() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExportUrl) Filename() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExportUrlStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func ExportUrlAddPath(builder *flatbuffers.Builder, path flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(path), 0)
|
||||
}
|
||||
func ExportUrlAddFilename(builder *flatbuffers.Builder, filename flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(filename), 0)
|
||||
}
|
||||
func ExportUrlEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ExportUrlRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ExportUrlRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsExportUrlRequest(buf []byte, offset flatbuffers.UOffsetT) *ExportUrlRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ExportUrlRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedExportUrlRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) GameId() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) Kind() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) DateLocale() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) ActionLabels(j int) []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
a := rcv._tab.Vector(o)
|
||||
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) ActionLabelsLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ExportUrlRequest) TimeZone() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExportUrlRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(5)
|
||||
}
|
||||
func ExportUrlRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
|
||||
}
|
||||
func ExportUrlRequestAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(kind), 0)
|
||||
}
|
||||
func ExportUrlRequestAddDateLocale(builder *flatbuffers.Builder, dateLocale flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(dateLocale), 0)
|
||||
}
|
||||
func ExportUrlRequestAddActionLabels(builder *flatbuffers.Builder, actionLabels flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actionLabels), 0)
|
||||
}
|
||||
func ExportUrlRequestStartActionLabelsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func ExportUrlRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(timeZone), 0)
|
||||
}
|
||||
func ExportUrlRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,29 @@
|
||||
# renderer — the finished-game image-render sidecar: Node + skia-canvas running the SAME
|
||||
# ui/src/lib/gameimage.ts the web client unit-tests, bundled at build time (renderer/README.md).
|
||||
# Debian slim, not the repo's usual alpine/distroless: skia-canvas ships prebuilt glibc
|
||||
# binaries and resolves fonts through fontconfig, and the runtime fonts are baked into the
|
||||
# image — Liberation Sans (the UI font stack's Linux face) + Noto Color Emoji (the 🏆).
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /src/renderer
|
||||
RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
|
||||
COPY renderer/package.json renderer/pnpm-lock.yaml renderer/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY renderer/build.mjs ./
|
||||
COPY renderer/src ./src
|
||||
COPY ui/src/lib /src/ui/src/lib
|
||||
RUN pnpm run bundle
|
||||
|
||||
FROM node:22-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fonts-liberation fonts-noto-color-emoji \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV NODE_ENV=production
|
||||
ARG VERSION=dev
|
||||
ENV APP_VERSION=${VERSION}
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/renderer/node_modules ./node_modules
|
||||
COPY --from=build /src/renderer/dist ./dist
|
||||
COPY renderer/src/server.mjs renderer/src/render.mjs ./src/
|
||||
USER node
|
||||
EXPOSE 8090
|
||||
CMD ["node", "src/server.mjs"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# renderer — the finished-game image-render sidecar
|
||||
|
||||
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
|
||||
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
|
||||
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
|
||||
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
|
||||
pixel-identical to the design the owner signed off in the browser.
|
||||
|
||||
## Interface
|
||||
|
||||
- `POST /render` — `{game, moves, alphabet, labels, dateLocale, hostname, scale?}` →
|
||||
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
|
||||
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
|
||||
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
|
||||
`hostname` + `dateLocale` feed the footer.
|
||||
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
|
||||
|
||||
The service draws and nothing else: authentication, the participant check and the signed
|
||||
public download URL all live in the backend (`backend/internal/server/export.go`); the
|
||||
network path is client → gateway `/dl/*` → backend → this sidecar.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
|
||||
# (allowBuilds) or the native binary is silently never fetched
|
||||
pnpm test # bundles, then node --test against testdata/request.json
|
||||
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
|
||||
```
|
||||
|
||||
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
|
||||
UI font stack's Linux face) and Noto Color Emoji (the scoresheet 🏆); skia-canvas picks
|
||||
both up through fontconfig. The fixture `testdata/request.json` is a real 35-move
|
||||
self-played Russian Scrabble game.
|
||||
@@ -0,0 +1,13 @@
|
||||
// Bundles the shared ui/src/lib game-image renderer (see src/entry.ts) into
|
||||
// dist/gameimage.mjs for the sidecar runtime. Uses the esbuild JS API — the CLI shim is
|
||||
// unreliable under pnpm's bin wrapper (it re-runs the native binary through node).
|
||||
import { build } from 'esbuild';
|
||||
|
||||
await build({
|
||||
entryPoints: ['src/entry.ts'],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
outfile: 'dist/gameimage.mjs',
|
||||
logLevel: 'info',
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "scrabble-renderer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Internal sidecar that renders the finished-game export PNG with the same ui/src/lib/gameimage.ts the browser build tests, on skia-canvas.",
|
||||
"scripts": {
|
||||
"bundle": "node build.mjs",
|
||||
"test": "pnpm run bundle && node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"skia-canvas": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"skia-canvas"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+366
@@ -0,0 +1,366 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
skia-canvas:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.8
|
||||
devDependencies:
|
||||
esbuild:
|
||||
specifier: ^0.25.0
|
||||
version: 0.25.12
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
parenthesis@3.1.8:
|
||||
resolution: {integrity: sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==}
|
||||
|
||||
skia-canvas@3.0.8:
|
||||
resolution: {integrity: sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==}
|
||||
|
||||
string-split-by@1.0.0:
|
||||
resolution: {integrity: sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
'@esbuild/android-arm': 0.25.12
|
||||
'@esbuild/android-arm64': 0.25.12
|
||||
'@esbuild/android-x64': 0.25.12
|
||||
'@esbuild/darwin-arm64': 0.25.12
|
||||
'@esbuild/darwin-x64': 0.25.12
|
||||
'@esbuild/freebsd-arm64': 0.25.12
|
||||
'@esbuild/freebsd-x64': 0.25.12
|
||||
'@esbuild/linux-arm': 0.25.12
|
||||
'@esbuild/linux-arm64': 0.25.12
|
||||
'@esbuild/linux-ia32': 0.25.12
|
||||
'@esbuild/linux-loong64': 0.25.12
|
||||
'@esbuild/linux-mips64el': 0.25.12
|
||||
'@esbuild/linux-ppc64': 0.25.12
|
||||
'@esbuild/linux-riscv64': 0.25.12
|
||||
'@esbuild/linux-s390x': 0.25.12
|
||||
'@esbuild/linux-x64': 0.25.12
|
||||
'@esbuild/netbsd-arm64': 0.25.12
|
||||
'@esbuild/netbsd-x64': 0.25.12
|
||||
'@esbuild/openbsd-arm64': 0.25.12
|
||||
'@esbuild/openbsd-x64': 0.25.12
|
||||
'@esbuild/openharmony-arm64': 0.25.12
|
||||
'@esbuild/sunos-x64': 0.25.12
|
||||
'@esbuild/win32-arm64': 0.25.12
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
parenthesis@3.1.8: {}
|
||||
|
||||
skia-canvas@3.0.8:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
follow-redirects: 1.16.0
|
||||
https-proxy-agent: 7.0.6
|
||||
string-split-by: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
string-split-by@1.0.0:
|
||||
dependencies:
|
||||
parenthesis: 3.1.8
|
||||
@@ -0,0 +1,6 @@
|
||||
# pnpm 11 records build-script approval here. esbuild's postinstall materialises its CLI
|
||||
# shim; skia-canvas's postinstall downloads the prebuilt native .node binary — without the
|
||||
# approval it is silently skipped and the service fails at boot.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
skia-canvas: true
|
||||
@@ -0,0 +1,6 @@
|
||||
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
|
||||
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
|
||||
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
|
||||
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
|
||||
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
|
||||
export { setAlphabet } from '../../ui/src/lib/alphabet';
|
||||
@@ -0,0 +1,24 @@
|
||||
// The render core, separated from the HTTP wiring so the node test can call it directly.
|
||||
import { Canvas } from 'skia-canvas';
|
||||
import { drawGameImage, setAlphabet } from '../dist/gameimage.mjs';
|
||||
|
||||
/**
|
||||
* renderRequest rasterizes one export request — the JSON shape the backend sends:
|
||||
* { game, moves, alphabet, labels, dateLocale, hostname, scale? } — into a PNG buffer.
|
||||
* game/moves are the ui-model shapes (GameView / MoveRecord[]) the shared drawGameImage
|
||||
* consumes; alphabet is the per-variant (index, letter, value) table it scores tiles with.
|
||||
*/
|
||||
export async function renderRequest(payload) {
|
||||
const { game, moves, alphabet, labels, dateLocale, timeZone, hostname, scale } = payload;
|
||||
if (!game || !Array.isArray(moves)) throw Object.assign(new Error('bad payload'), { status: 400 });
|
||||
setAlphabet(game.variant, alphabet ?? []);
|
||||
const canvas = new Canvas(1, 1);
|
||||
drawGameImage(canvas, game, moves, {
|
||||
actionLabel: (a) => (labels && labels[a]) || a,
|
||||
hostname: hostname || '',
|
||||
dateLocale: dateLocale || undefined,
|
||||
timeZone: timeZone || undefined,
|
||||
scale: scale || 2,
|
||||
});
|
||||
return canvas.toBuffer('png');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
|
||||
// finished-game export image. It runs the same drawGameImage the browser build ships
|
||||
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
|
||||
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
|
||||
//
|
||||
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
|
||||
// GET /healthz → 200
|
||||
//
|
||||
// The service is internal-only (docker network `internal`); authentication, participant
|
||||
// checks and the signed public URL all live in the backend — this process only draws.
|
||||
import { createServer } from 'node:http';
|
||||
import { renderRequest } from './render.mjs';
|
||||
|
||||
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
||||
// A render request is a finished game's journal — generously capped.
|
||||
const MAX_BODY = 2 * 1024 * 1024;
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(Object.assign(new Error('body too large'), { status: 413 }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET' && req.url === '/healthz') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/render') {
|
||||
const png = await renderRequest(JSON.parse(await readBody(req)));
|
||||
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
} catch (err) {
|
||||
const status = err.status || (err instanceof SyntaxError ? 400 : 500);
|
||||
console.error(`render error: ${err.message}`);
|
||||
res.writeHead(status, { 'content-type': 'text/plain' }).end('render failed');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`renderer listening on :${PORT}`));
|
||||
@@ -0,0 +1,37 @@
|
||||
// Smoke test for the render core: feeds the committed fixture (a real 35-move self-played
|
||||
// Russian Scrabble game) through renderRequest and asserts a sane PNG comes back. Pixel
|
||||
// goldens are deliberately avoided — font rasterization differs across hosts; the shared
|
||||
// drawing logic itself is unit-tested in ui/ (gameimage.test.ts).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { renderRequest } from '../src/render.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = () => JSON.parse(readFileSync(join(here, '../testdata/request.json'), 'utf8'));
|
||||
|
||||
function pngSize(buf) {
|
||||
// IHDR is the first chunk: width/height are big-endian u32 at offsets 16/20.
|
||||
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
test('renders the fixture game to a plausible PNG', async () => {
|
||||
const png = await renderRequest(fixture());
|
||||
assert.ok(png.length > 50_000, `png too small: ${png.length}`);
|
||||
assert.deepEqual([...png.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const { w, h } = pngSize(png);
|
||||
// scale 2 over a MIN_SIDE 620 board + two seat columns: sanity bounds, not a golden.
|
||||
assert.ok(w > 1600 && w < 3000, `unexpected width ${w}`);
|
||||
assert.ok(h > 1000 && h < 3000, `unexpected height ${h}`);
|
||||
});
|
||||
|
||||
test('rejects a payload without a game', async () => {
|
||||
await assert.rejects(() => renderRequest({ moves: [] }), /bad payload/);
|
||||
});
|
||||
|
||||
test('renders concurrently without cross-talk', async () => {
|
||||
const [a, b] = await Promise.all([renderRequest(fixture()), renderRequest(fixture())]);
|
||||
assert.equal(a.length, b.length);
|
||||
});
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+175
-31
@@ -1,18 +1,31 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// The finished-game export: the history header's 📤 button opens the app's own format
|
||||
// chooser modal — never Telegram's native popup, whose callback runs without user
|
||||
// activation and so kills navigator.share / clipboard writes (verified on-device).
|
||||
// The PNG option is offered only outside the in-app WebViews (no working binary
|
||||
// delivery there until the server-rendered signed-URL path lands); the GCG file is
|
||||
// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is
|
||||
// force-disabled per test so both engines deterministically exercise the download
|
||||
// branch.
|
||||
// The finished-game export: one signed relative URL (game.export_url) resolved against
|
||||
// the app's own origin, delivered by the most native affordance per platform — Telegram's
|
||||
// showPopup chooser + downloadFile dialog (all bridge calls, activation-safe), VK's
|
||||
// native viewer/download, the OS share sheet with the fetched file on a mobile browser,
|
||||
// a plain anchor download on desktop. g3 ("vs Rick") is the seeded finished game.
|
||||
// The mock gateway returns a shape-faithful /dl/... path; the tests intercept that
|
||||
// route and serve bytes, so the download itself is exercised end to end.
|
||||
|
||||
async function disableWebShare(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true });
|
||||
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
|
||||
// A minimal valid 1×1 PNG, hex-decoded without node typings (the e2e tsconfig has none).
|
||||
const PNG_HEX =
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' +
|
||||
'0000000a49444154789c6360000000020001e221bc330000000049454e44ae426082';
|
||||
// Buffer, not Uint8Array: route.fulfill silently hangs a FETCH interception on a
|
||||
// Uint8Array body (a navigation download tolerates it) — the share test deadlocked.
|
||||
const PNG_BYTES = Buffer.from(PNG_HEX, 'hex');
|
||||
|
||||
async function routeDl(page: Page): Promise<void> {
|
||||
await page.route('**/dl/**', (route) => {
|
||||
const png = route.request().url().includes('/png');
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: png ? 'image/png' : 'text/plain; charset=utf-8',
|
||||
headers: { 'Content-Disposition': 'attachment' },
|
||||
body: png ? PNG_BYTES : '#character-encoding UTF-8\n',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,20 +38,18 @@ async function openFinishedHistory(page: Page): Promise<void> {
|
||||
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => {
|
||||
await disableWebShare(page);
|
||||
test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => {
|
||||
await routeDl(page);
|
||||
await openFinishedHistory(page);
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
// The app's own chooser modal offers both formats on the plain web.
|
||||
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||
expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/);
|
||||
});
|
||||
|
||||
test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => {
|
||||
await disableWebShare(page);
|
||||
test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => {
|
||||
await routeDl(page);
|
||||
await openFinishedHistory(page);
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
@@ -47,24 +58,29 @@ test('the export chooser downloads the GCG file on a plain browser', async ({ pa
|
||||
expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/);
|
||||
});
|
||||
|
||||
test('inside Telegram the chooser is the app modal and withholds the image option', async ({
|
||||
test('inside Telegram the chooser is the native popup and delivery the native downloadFile', async ({
|
||||
page,
|
||||
}) => {
|
||||
await disableWebShare(page);
|
||||
// A Telegram WebApp stub with showPopup present: were the chooser still native, it would
|
||||
// be called — the spy locks the regression (its activation-less callback breaks share and
|
||||
// clipboard delivery on real devices).
|
||||
// A Telegram stub with showPopup AND downloadFile (Bot API 8.0): the whole chain stays
|
||||
// in bridge calls — the popup picks the image, the artifact goes to the native download
|
||||
// dialog, never an in-webview anchor. (Activation-safe: no gesture-gated Web APIs.)
|
||||
await page.addInitScript(() => {
|
||||
Object.assign(window, {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
platform: 'android',
|
||||
ready() {},
|
||||
expand() {},
|
||||
showPopup(_params: unknown, cb: (id: string) => void) {
|
||||
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
|
||||
setTimeout(() => cb(''), 0);
|
||||
showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) {
|
||||
const w = window as unknown as { __popup?: unknown };
|
||||
w.__popup = params;
|
||||
setTimeout(() => cb('png'), 0);
|
||||
},
|
||||
downloadFile(params: { url: string; file_name: string }) {
|
||||
const w = window as unknown as { __downloads?: { url: string; file_name: string }[] };
|
||||
(w.__downloads ??= []).push(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -77,11 +93,139 @@ test('inside Telegram the chooser is the app modal and withholds the image optio
|
||||
await page.locator('.scoreboard').click();
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
// The app's own modal opened with the GCG option only — no PNG in an in-app WebView…
|
||||
// The native popup carried both formats plus cancel, and no in-app modal was mounted.
|
||||
const popup = await page.evaluate(
|
||||
() => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup,
|
||||
);
|
||||
expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['png', 'gcg', 'cancel']);
|
||||
await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads),
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const dl = await page.evaluate(
|
||||
() => (window as unknown as { __downloads: { url: string; file_name: string }[] }).__downloads[0],
|
||||
);
|
||||
// The relative signed path was resolved against the app's own origin — absolute for TG.
|
||||
expect(dl.url).toMatch(/^http.+\/dl\/.+\/png\?/);
|
||||
expect(dl.file_name).toMatch(/^game-.+\.png$/);
|
||||
});
|
||||
|
||||
test('a mobile browser gets the OS share sheet with the fetched file', async ({ page }) => {
|
||||
// Web Share with files present (a mobile browser): the artifact is fetched from the
|
||||
// signed URL and handed to navigator.share as a File — the sheet opens directly,
|
||||
// nothing lands in Downloads first.
|
||||
await page.addInitScript(() => {
|
||||
const shared: { name: string; type: string }[] = [];
|
||||
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true });
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: async (data: { files?: File[] }) => {
|
||||
for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type });
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
(window as unknown as { __shared: typeof shared }).__shared = shared;
|
||||
});
|
||||
await routeDl(page);
|
||||
await openFinishedHistory(page);
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
|
||||
.toHaveLength(1);
|
||||
const shared = await page.evaluate(
|
||||
() => (window as unknown as { __shared: { name: string; type: string }[] }).__shared[0],
|
||||
);
|
||||
expect(shared.name).toMatch(/^game-.+\.png$/);
|
||||
expect(shared.type).toBe('image/png');
|
||||
});
|
||||
|
||||
test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async ({ page }) => {
|
||||
// TG iOS: WKWebView has navigator.share, and the sheet is the preferred delivery — but
|
||||
// it needs the user activation a native-popup callback cannot provide, so the chooser
|
||||
// stays the app modal on this one platform.
|
||||
await page.addInitScript(() => {
|
||||
const shared: { name: string; type: string }[] = [];
|
||||
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true });
|
||||
Object.defineProperty(navigator, 'share', {
|
||||
value: async (data: { files?: File[] }) => {
|
||||
for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type });
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
(window as unknown as { __shared: typeof shared }).__shared = shared;
|
||||
Object.assign(window, {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
platform: 'ios',
|
||||
ready() {},
|
||||
expand() {},
|
||||
showPopup() {
|
||||
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
|
||||
},
|
||||
downloadFile() {
|
||||
(window as unknown as { __downloadCalled?: boolean }).__downloadCalled = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await routeDl(page);
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
await page.getByRole('button', { name: /Rick/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
// The app modal opened (both formats) — the native popup was not used…
|
||||
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||
// …and the artifact went to the OS share sheet, not TG's download dialog.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
|
||||
.toHaveLength(1);
|
||||
const flags = await page.evaluate(() => {
|
||||
const w = window as unknown as { __popupCalled?: boolean; __downloadCalled?: boolean };
|
||||
return { popup: !!w.__popupCalled, download: !!w.__downloadCalled };
|
||||
});
|
||||
expect(flags).toEqual({ popup: false, download: false });
|
||||
});
|
||||
|
||||
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
// Headless engines deny the real clipboard; a permissive stub keeps the legacy
|
||||
// copy path deterministic in both browsers.
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: async () => {} },
|
||||
configurable: true,
|
||||
});
|
||||
Object.assign(window, {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
ready() {},
|
||||
expand() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
await page.getByRole('button', { name: /Rick/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
// No image option without the native download; the GCG stays available (clipboard path).
|
||||
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0);
|
||||
// …and the native popup was never used for the chooser.
|
||||
expect(
|
||||
await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled),
|
||||
).toBeFalsy();
|
||||
await page.getByRole('button', { name: 'GCG file' }).click();
|
||||
await expect(page.getByText('GCG copied to the clipboard.')).toBeVisible();
|
||||
});
|
||||
|
||||
+85
-28
@@ -22,12 +22,12 @@
|
||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { hintsLeft } from '../lib/hints';
|
||||
import { shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share';
|
||||
import { insideVK, vkCopyText } from '../lib/vk';
|
||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||
import { patchLobbyGame } from '../lib/lobbycache';
|
||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
||||
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramPlatform, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
|
||||
import { haptic } from '../lib/haptics';
|
||||
import {
|
||||
BLANK,
|
||||
@@ -931,26 +931,51 @@
|
||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
||||
}
|
||||
|
||||
// The finished-game export offers two formats — the GCG file and the rendered PNG image —
|
||||
// behind one 📤 button, always through the app's own modal. Never Telegram's native popup
|
||||
// here: its callback runs with NO user activation, so navigator.share / clipboard writes
|
||||
// silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in
|
||||
// the modal keeps the gesture alive for the delivery APIs.
|
||||
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
||||
// image — behind one 📤 button, both minted as one signed relative URL and delivered by
|
||||
// the best affordance each platform actually has (every branch owner-verified on-device):
|
||||
// TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own
|
||||
// preview shares onwards). All bridge calls — activation-safe.
|
||||
// TG iOS our modal chooser → the OS share sheet (WKWebView has
|
||||
// navigator.share; a native-popup callback could not open it — no
|
||||
// user activation — so the chooser stays the app modal here).
|
||||
// VK iOS our modal (VK has no native chooser) → VKWebAppDownloadFile for
|
||||
// both formats — the client lands in its native share/preview flow.
|
||||
// VK Android our modal → the PNG opens in VK's native image viewer (its save
|
||||
// works; the Android DownloadFile hangs), the GCG copies to the
|
||||
// clipboard (the pre-URL route, always solid there).
|
||||
// VK desktop iframe our modal → plain anchor downloads (an ordinary browser).
|
||||
// mobile browsers our modal → the OS share sheet with the fetched file.
|
||||
// desktop browsers our modal → plain anchor download.
|
||||
let exportOpen = $state(false);
|
||||
// The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image
|
||||
// has no working delivery there (no Web Share, a dead <a download>, text-only clipboard,
|
||||
// and the long-press menu mangles data: URLs). It returns with the server-rendered
|
||||
// signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile).
|
||||
const exportImageAvailable = $derived(!(insideTelegram() || insideVK()));
|
||||
// TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a
|
||||
// legacy client without downloadFile keeps the old GCG clipboard copy and hides the
|
||||
// image option (and the chooser stays our modal).
|
||||
const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios');
|
||||
const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile());
|
||||
|
||||
function onExportClick() {
|
||||
async function onExportClick() {
|
||||
if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) {
|
||||
const choice = await telegramShowPopup({
|
||||
message: t('game.exportChoice'),
|
||||
buttons: [
|
||||
{ id: 'png', type: 'default', text: t('game.exportImageOpt') },
|
||||
{ id: 'gcg', type: 'default', text: t('game.exportGcgOpt') },
|
||||
{ type: 'cancel' },
|
||||
],
|
||||
});
|
||||
if (choice === 'png' || choice === 'gcg') void exportArtifact(choice);
|
||||
return;
|
||||
}
|
||||
exportOpen = true;
|
||||
}
|
||||
|
||||
async function exportGcg() {
|
||||
// Legacy-Telegram GCG delivery (no downloadFile): fetch the text and copy it to the
|
||||
// clipboard, as before the unified URL route.
|
||||
async function exportGcgLegacy() {
|
||||
try {
|
||||
const gcg = await gateway.exportGcg(id);
|
||||
const outcome = await shareOrDownloadGcg(gcg, insideTelegram() || insideVK(), copyGcgText);
|
||||
const outcome = await shareOrDownloadGcg(gcg, true, copyGcgText);
|
||||
if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info');
|
||||
else if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
||||
} catch (e) {
|
||||
@@ -958,17 +983,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the
|
||||
// renderer stays out of the startup bundle) and delivers it — Web Share where the platform
|
||||
// supports it, else a download (only reachable outside the in-app WebViews, see
|
||||
// exportImageAvailable).
|
||||
async function exportImage() {
|
||||
if (!view) return;
|
||||
// exportArtifact delivers a finished game's artifact by the unified signed-URL route.
|
||||
// The device date locale, its IANA time zone and the UI-localized non-play labels ride
|
||||
// the URL so the server-rendered image matches what the player would have seen locally.
|
||||
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
|
||||
async function exportArtifact(kind: 'png' | 'gcg') {
|
||||
if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) {
|
||||
void exportGcgLegacy();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { renderGameImage } = await import('../lib/gameimage');
|
||||
const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel });
|
||||
const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' });
|
||||
await shareOrDownloadImage(file);
|
||||
const labels = EXPORT_ACTIONS.map((a) => t(`move.${a}` as MessageKey));
|
||||
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
||||
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
||||
const url = new URL(path, location.origin).href;
|
||||
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
||||
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
||||
if (insideVK()) {
|
||||
// Per-VK-client delivery (each branch owner-verified): the iOS client's
|
||||
// VKWebAppDownloadFile lands in a native share flow and is perfect; the ANDROID
|
||||
// client's download hangs indefinitely, so the PNG opens in VK's native image
|
||||
// viewer (its "save" works) and the GCG goes to the clipboard; the desktop
|
||||
// iframe is an ordinary browser — plain anchor downloads.
|
||||
if (vkAndroidWebView()) {
|
||||
if (kind === 'png' && (await vkShowImages(url))) return;
|
||||
if (kind === 'gcg') {
|
||||
void exportGcgLegacy();
|
||||
return;
|
||||
}
|
||||
} else if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) {
|
||||
return;
|
||||
}
|
||||
downloadUrl(url, filename);
|
||||
return;
|
||||
}
|
||||
// TG iOS and plain browsers: the OS share sheet with the fetched file (falls back to
|
||||
// a download where files cannot be shared — the desktop browser).
|
||||
const outcome = await shareUrlAsFile(url, filename, mime);
|
||||
if (outcome === 'failed') showToast(t('error.generic'), 'error');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -1492,13 +1545,17 @@
|
||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||
<div class="export-opts">
|
||||
{#if exportImageAvailable}
|
||||
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
|
||||
<button
|
||||
class="confirm"
|
||||
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
||||
disabled={!connection.online}
|
||||
>
|
||||
{t('game.exportImageOpt')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||
onclick={() => { exportOpen = false; void exportGcg(); }}
|
||||
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
||||
disabled={!connection.online}
|
||||
>
|
||||
{t('game.exportGcgOpt')}
|
||||
|
||||
@@ -23,6 +23,8 @@ export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
|
||||
export { EvalRequest } from './scrabblefb/eval-request.js';
|
||||
export { EvalResult } from './scrabblefb/eval-result.js';
|
||||
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
|
||||
export { ExportUrl } from './scrabblefb/export-url.js';
|
||||
export { ExportUrlRequest } from './scrabblefb/export-url-request.js';
|
||||
export { FeedbackReply } from './scrabblefb/feedback-reply.js';
|
||||
export { FeedbackState } from './scrabblefb/feedback-state.js';
|
||||
export { FeedbackSubmitRequest } from './scrabblefb/feedback-submit-request.js';
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class ExportUrlRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):ExportUrlRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsExportUrlRequest(bb:flatbuffers.ByteBuffer, obj?:ExportUrlRequest):ExportUrlRequest {
|
||||
return (obj || new ExportUrlRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsExportUrlRequest(bb:flatbuffers.ByteBuffer, obj?:ExportUrlRequest):ExportUrlRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new ExportUrlRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
gameId():string|null
|
||||
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
gameId(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
kind():string|null
|
||||
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
kind(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
dateLocale():string|null
|
||||
dateLocale(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
dateLocale(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
actionLabels(index: number):string
|
||||
actionLabels(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
|
||||
actionLabels(index: number,optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
actionLabelsLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
timeZone():string|null
|
||||
timeZone(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
timeZone(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 12);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startExportUrlRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(5);
|
||||
}
|
||||
|
||||
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, gameIdOffset, 0);
|
||||
}
|
||||
|
||||
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, kindOffset, 0);
|
||||
}
|
||||
|
||||
static addDateLocale(builder:flatbuffers.Builder, dateLocaleOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, dateLocaleOffset, 0);
|
||||
}
|
||||
|
||||
static addActionLabels(builder:flatbuffers.Builder, actionLabelsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(3, actionLabelsOffset, 0);
|
||||
}
|
||||
|
||||
static createActionLabelsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startActionLabelsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addTimeZone(builder:flatbuffers.Builder, timeZoneOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(4, timeZoneOffset, 0);
|
||||
}
|
||||
|
||||
static endExportUrlRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createExportUrlRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, kindOffset:flatbuffers.Offset, dateLocaleOffset:flatbuffers.Offset, actionLabelsOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
ExportUrlRequest.startExportUrlRequest(builder);
|
||||
ExportUrlRequest.addGameId(builder, gameIdOffset);
|
||||
ExportUrlRequest.addKind(builder, kindOffset);
|
||||
ExportUrlRequest.addDateLocale(builder, dateLocaleOffset);
|
||||
ExportUrlRequest.addActionLabels(builder, actionLabelsOffset);
|
||||
ExportUrlRequest.addTimeZone(builder, timeZoneOffset);
|
||||
return ExportUrlRequest.endExportUrlRequest(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class ExportUrl {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):ExportUrl {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsExportUrl(bb:flatbuffers.ByteBuffer, obj?:ExportUrl):ExportUrl {
|
||||
return (obj || new ExportUrl()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsExportUrl(bb:flatbuffers.ByteBuffer, obj?:ExportUrl):ExportUrl {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new ExportUrl()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
path():string|null
|
||||
path(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
path(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
filename():string|null
|
||||
filename(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
filename(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startExportUrl(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addPath(builder:flatbuffers.Builder, pathOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, pathOffset, 0);
|
||||
}
|
||||
|
||||
static addFilename(builder:flatbuffers.Builder, filenameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, filenameOffset, 0);
|
||||
}
|
||||
|
||||
static endExportUrl(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createExportUrl(builder:flatbuffers.Builder, pathOffset:flatbuffers.Offset, filenameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
ExportUrl.startExportUrl(builder);
|
||||
ExportUrl.addPath(builder, pathOffset);
|
||||
ExportUrl.addFilename(builder, filenameOffset);
|
||||
return ExportUrl.endExportUrl(builder);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
ExportUrl,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
@@ -161,6 +162,10 @@ export interface GatewayClient {
|
||||
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
||||
statsGet(): Promise<Stats>;
|
||||
exportGcg(gameId: string): Promise<GcgExport>;
|
||||
/** Mints a signed download URL for a finished game's artifact. dateLocale and the
|
||||
* localized non-play labels (pass/exchange/resign/timeout, in that order) ride the
|
||||
* URL so the server-rendered image matches the player's presentation. */
|
||||
exportUrl(gameId: string, kind: 'png' | 'gcg', dateLocale: string, actionLabels: string[], timeZone: string): Promise<ExportUrl>;
|
||||
|
||||
// --- account linking & merge ---
|
||||
linkEmailRequest(email: string): Promise<void>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
decodeBlockStatus,
|
||||
decodeDraftView,
|
||||
decodeEvent,
|
||||
decodeExportUrl,
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
encodeDraftSave,
|
||||
encodeEnqueue,
|
||||
encodeExchange,
|
||||
encodeExportUrlRequest,
|
||||
encodeGuestLogin,
|
||||
encodeStateRequest,
|
||||
encodeSubmitPlay,
|
||||
@@ -34,6 +36,30 @@ import {
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('round-trips the export-url request and response', () => {
|
||||
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
|
||||
new ByteBuffer(
|
||||
encodeExportUrlRequest('g-1', 'png', 'ru-RU', ['пас', 'обмен', 'сдался', 'таймаут'], 'Europe/Moscow'),
|
||||
),
|
||||
);
|
||||
expect(req.gameId()).toBe('g-1');
|
||||
expect(req.kind()).toBe('png');
|
||||
expect(req.dateLocale()).toBe('ru-RU');
|
||||
expect(req.timeZone()).toBe('Europe/Moscow');
|
||||
expect(req.actionLabelsLength()).toBe(4);
|
||||
expect(req.actionLabels(1)).toBe('обмен');
|
||||
|
||||
const b = new Builder(128);
|
||||
const path = b.createString('/dl/g-1/png?e=1&s=x');
|
||||
const fn = b.createString('game-g-1.png');
|
||||
fb.ExportUrl.startExportUrl(b);
|
||||
fb.ExportUrl.addPath(b, path);
|
||||
fb.ExportUrl.addFilename(b, fn);
|
||||
b.finish(fb.ExportUrl.endExportUrl(b));
|
||||
const out = decodeExportUrl(b.asUint8Array());
|
||||
expect(out).toEqual({ path: '/dl/g-1/png?e=1&s=x', filename: 'game-g-1.png' });
|
||||
});
|
||||
|
||||
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
|
||||
for (const vsAi of [false, true]) {
|
||||
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
EvalResult,
|
||||
FeedbackState,
|
||||
FriendCode,
|
||||
ExportUrl,
|
||||
GameList,
|
||||
GameView,
|
||||
GcgExport,
|
||||
@@ -882,6 +883,36 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
|
||||
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
|
||||
}
|
||||
|
||||
export function encodeExportUrlRequest(
|
||||
gameId: string,
|
||||
kind: 'png' | 'gcg',
|
||||
dateLocale: string,
|
||||
actionLabels: string[],
|
||||
timeZone: string,
|
||||
): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const gid = b.createString(gameId);
|
||||
const k = b.createString(kind);
|
||||
const dl = b.createString(dateLocale);
|
||||
const tz = b.createString(timeZone);
|
||||
const labels = fb.ExportUrlRequest.createActionLabelsVector(
|
||||
b,
|
||||
actionLabels.map((l) => b.createString(l)),
|
||||
);
|
||||
fb.ExportUrlRequest.startExportUrlRequest(b);
|
||||
fb.ExportUrlRequest.addGameId(b, gid);
|
||||
fb.ExportUrlRequest.addKind(b, k);
|
||||
fb.ExportUrlRequest.addDateLocale(b, dl);
|
||||
fb.ExportUrlRequest.addActionLabels(b, labels);
|
||||
fb.ExportUrlRequest.addTimeZone(b, tz);
|
||||
return finish(b, fb.ExportUrlRequest.endExportUrlRequest(b));
|
||||
}
|
||||
|
||||
export function decodeExportUrl(buf: Uint8Array): ExportUrl {
|
||||
const e = fb.ExportUrl.getRootAsExportUrl(new ByteBuffer(buf));
|
||||
return { path: s(e.path()), filename: s(e.filename()) };
|
||||
}
|
||||
|
||||
function emptyGame(): GameView {
|
||||
return {
|
||||
id: '',
|
||||
|
||||
+38
-26
@@ -1,12 +1,12 @@
|
||||
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
|
||||
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
|
||||
// Canvas 2D — no dependencies — and the module is reached only through a dynamic import, so
|
||||
// it stays out of the startup bundle. The scoresheet typography is fixed; the board instead
|
||||
// stretches to the scoresheet's height (never below MIN_SIDE), so a long game grows the
|
||||
// board rather than leaving a hole under it.
|
||||
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
|
||||
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
|
||||
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
|
||||
// itself no longer imports it — delivery is the server's signed URL).
|
||||
//
|
||||
// buildScoresheet and layoutImage are pure (vitest-covered); renderGameImage is the thin
|
||||
// canvas pass exercised by the Playwright e2e.
|
||||
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
|
||||
// canvas pass exercised by the sidecar's node test.
|
||||
|
||||
import type { GameView, MoveRecord, Variant } from './model';
|
||||
import { replay } from './board';
|
||||
@@ -56,16 +56,22 @@ export interface Scoresheet {
|
||||
}
|
||||
|
||||
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
|
||||
* (regional date conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||||
* dateLocale is for tests; production passes undefined = the system default. */
|
||||
export function formatFinishedDate(unixSec: number, dateLocale?: string): string {
|
||||
return new Intl.DateTimeFormat(dateLocale, {
|
||||
* and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||||
* Both ride in from the client on a server render; an invalid zone falls back to the
|
||||
* process default rather than failing the whole image. */
|
||||
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(unixSec * 1000));
|
||||
};
|
||||
try {
|
||||
return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +84,7 @@ export function buildScoresheet(
|
||||
game: GameView,
|
||||
moves: MoveRecord[],
|
||||
actionLabel: (action: string) => string,
|
||||
opts?: { hostname?: string; dateLocale?: string },
|
||||
opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
|
||||
): Scoresheet {
|
||||
const seats = game.seats;
|
||||
const grid = historyGrid(moves, seats.length, null);
|
||||
@@ -106,7 +112,7 @@ export function buildScoresheet(
|
||||
rows,
|
||||
adjustments,
|
||||
hasAdjustments: adjustments.some((a) => a !== 0),
|
||||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale)}`,
|
||||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,27 +196,36 @@ export interface RenderOptions {
|
||||
hostname?: string;
|
||||
/** Footer date locale override (defaults to the device locale) — for tests. */
|
||||
dateLocale?: string;
|
||||
/** Footer IANA time zone (the device zone on a server render; defaults to local). */
|
||||
timeZone?: string;
|
||||
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
|
||||
* browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
|
||||
* code is shared verbatim between the two runtimes. */
|
||||
export interface CanvasLike {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(contextId: '2d'): CanvasRenderingContext2D | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGameImage draws the finished game as a PNG blob: final board with classic axes on
|
||||
* the left, the scoresheet on the right, the site + finish date at the bottom. Light theme
|
||||
* always; no last-move highlight (an archival artifact, not a live frame).
|
||||
* drawGameImage sizes the canvas and draws the finished game onto it: final board with
|
||||
* classic axes on the left, the scoresheet on the right, the site + finish date at the
|
||||
* bottom. Light theme always; no last-move highlight (an archival artifact, not a live
|
||||
* frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
|
||||
* toBuffer in the sidecar).
|
||||
*/
|
||||
export async function renderGameImage(
|
||||
game: GameView,
|
||||
moves: MoveRecord[],
|
||||
opts: RenderOptions,
|
||||
): Promise<Blob> {
|
||||
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
|
||||
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
|
||||
hostname: opts.hostname,
|
||||
dateLocale: opts.dateLocale,
|
||||
timeZone: opts.timeZone,
|
||||
});
|
||||
const layout = layoutImage(sheet);
|
||||
const scale = opts.scale ?? 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(layout.w * scale);
|
||||
canvas.height = Math.round(layout.h * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -228,12 +243,9 @@ export async function renderGameImage(
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('gameimage: toBlob failed'))), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
|
||||
const { x, y, cell } = l.board;
|
||||
ctx.fillStyle = C.muted;
|
||||
|
||||
@@ -311,6 +311,7 @@ export const en = {
|
||||
'stats.guestHint': 'Sign in to track your statistics.',
|
||||
|
||||
'game.exportGcg': 'Export game',
|
||||
'game.exportChoice': 'Export the finished game as:',
|
||||
'game.exportImageOpt': 'Image (PNG)',
|
||||
'game.exportGcgOpt': 'GCG file',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
|
||||
@@ -311,6 +311,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'stats.guestHint': 'Войдите, чтобы вести статистику.',
|
||||
|
||||
'game.exportGcg': 'Экспорт партии',
|
||||
'game.exportChoice': 'Экспортировать завершённую партию как:',
|
||||
'game.exportImageOpt': 'Картинка (PNG)',
|
||||
'game.exportGcgOpt': 'Файл GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
FeedbackState,
|
||||
FriendCode,
|
||||
GameList,
|
||||
ExportUrl,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
@@ -677,6 +678,12 @@ export class MockGateway implements GatewayClient {
|
||||
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
|
||||
};
|
||||
}
|
||||
async exportUrl(gameId: string, kind: 'png' | 'gcg'): Promise<ExportUrl> {
|
||||
const g = this.game(gameId);
|
||||
if (g.view.status !== 'finished') throw new GatewayError('game_active');
|
||||
// A shape-faithful signed path; the e2e intercepts the route and serves bytes.
|
||||
return { path: `/dl/${gameId}/${kind}?e=9999999999&s=mock`, filename: `game-${gameId}.${kind}` };
|
||||
}
|
||||
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
|
||||
|
||||
@@ -326,6 +326,14 @@ export interface GcgExport {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A minted signed download link for a finished game's export artifact. path is
|
||||
* relative — the client resolves it against its own origin (the SPA and the
|
||||
* gateway edge share it), so the server never needs to know the public host. */
|
||||
export interface ExportUrl {
|
||||
path: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
userId: string;
|
||||
|
||||
@@ -47,6 +47,7 @@ export const READ_OPS: ReadonlySet<string> = new Set([
|
||||
'game.state',
|
||||
'game.history',
|
||||
'game.gcg',
|
||||
'game.export_url',
|
||||
'game.evaluate',
|
||||
'game.check_word',
|
||||
'stats.get',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { pickGcgDelivery, pickImageDelivery, pickTextShare, shareOrDownloadGcg, shareOrDownloadImage, shareText } from './share';
|
||||
import { downloadUrl, pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
|
||||
import type { GcgExport } from './model';
|
||||
|
||||
const file = {} as File;
|
||||
@@ -83,52 +83,21 @@ describe('shareOrDownloadGcg', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickImageDelivery', () => {
|
||||
const canShareNav = { canShare: () => true, share: async () => {} };
|
||||
const noShareNav = { canShare: () => false, share: async () => {} };
|
||||
|
||||
it('shares when the platform can share files', () => {
|
||||
expect(pickImageDelivery(canShareNav, file)).toBe('share');
|
||||
});
|
||||
|
||||
it('downloads on a plain browser without Web Share (desktop)', () => {
|
||||
expect(pickImageDelivery(noShareNav, file)).toBe('download');
|
||||
expect(pickImageDelivery(undefined, file)).toBe('download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareOrDownloadImage', () => {
|
||||
describe('downloadUrl', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubEnv(canShare: boolean, share: () => Promise<void>) {
|
||||
it('clicks a temporary anchor carrying the URL and the filename', () => {
|
||||
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||
const createElement = vi.fn(() => anchor);
|
||||
vi.stubGlobal('navigator', { canShare: () => canShare, share });
|
||||
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() });
|
||||
return { anchor, createElement };
|
||||
}
|
||||
|
||||
const png = () => ({ name: 'game-1.png', type: 'image/png' }) as File;
|
||||
|
||||
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
|
||||
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
||||
const { createElement } = stubEnv(true, share);
|
||||
|
||||
expect(await shareOrDownloadImage(png())).toBe('shared');
|
||||
|
||||
expect(share).toHaveBeenCalledOnce();
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads via an anchor on a desktop browser that cannot share files', async () => {
|
||||
const { anchor, createElement } = stubEnv(false, vi.fn());
|
||||
|
||||
expect(await shareOrDownloadImage(png())).toBe('downloaded');
|
||||
downloadUrl('https://example.test/dl/g-1/png?e=1&s=x', 'game-1.png');
|
||||
|
||||
expect(createElement).toHaveBeenCalledWith('a');
|
||||
expect(anchor.click).toHaveBeenCalledOnce();
|
||||
expect(anchor.href).toBe('https://example.test/dl/g-1/png?e=1&s=x');
|
||||
expect(anchor.download).toBe('game-1.png');
|
||||
expect(anchor.click).toHaveBeenCalledOnce();
|
||||
expect(anchor.remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+36
-26
@@ -81,42 +81,52 @@ function downloadFile(content: Blob | string, filename: string, type = 'applicat
|
||||
}
|
||||
|
||||
/**
|
||||
* pickImageDelivery decides how to deliver the PNG game image: Web Share (with the file)
|
||||
* wherever it exists — mobile browsers and the iOS Telegram Mini App — else a Blob download.
|
||||
* The image option is not offered inside the in-app WebViews at all (Android Telegram/VK, the
|
||||
* desktop VK iframe): a binary PNG has no working route there until the server-rendered
|
||||
* signed-URL delivery lands, so this decision never sees them. Pure, unit-tested with a mock
|
||||
* navigator.
|
||||
* downloadUrl saves a same-origin signed export URL as a file through a temporary
|
||||
* anchor: the server names it via Content-Disposition, the download attribute is the
|
||||
* same-origin hint. This is the browser leg of the unified export delivery; the in-app
|
||||
* WebViews never reach it (they use the platform's native download call instead), so
|
||||
* the iOS blob-URL navigation trap does not apply — this is a real https URL.
|
||||
*/
|
||||
export function pickImageDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
|
||||
if (
|
||||
nav &&
|
||||
typeof nav.canShare === 'function' &&
|
||||
typeof nav.share === 'function' &&
|
||||
nav.canShare({ files: [file] })
|
||||
) {
|
||||
return 'share';
|
||||
}
|
||||
return 'download';
|
||||
export function downloadUrl(url: string, filename: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* shareOrDownloadImage delivers the rendered game image and reports the route taken. The Web
|
||||
* Share invariant matches shareOrDownloadGcg: a cancelled or failed share is a deliberate
|
||||
* no-op, never a Blob-download fallback (an <a download> strands the iOS WKWebView on the
|
||||
* blob: URL).
|
||||
* shareUrlAsFile delivers a signed export URL through the OS share sheet where files can
|
||||
* be shared (mobile browsers): it fetches the same-origin bytes, wraps them in a File and
|
||||
* calls navigator.share — the sheet opens directly, nothing lands in Downloads first. The
|
||||
* fetch-then-share chain keeps the click's user activation (the proven GCG pattern).
|
||||
* Where file sharing is absent (desktop) it falls back to the plain anchor download. A
|
||||
* cancelled share is a no-op by the shareOrDownloadGcg invariant.
|
||||
*/
|
||||
export async function shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
|
||||
export async function shareUrlAsFile(url: string, filename: string, mime: string): Promise<'shared' | 'downloaded' | 'failed'> {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
||||
if (pickImageDelivery(nav, file) === 'share' && nav) {
|
||||
if (nav && typeof nav.share === 'function' && typeof nav.canShare === 'function') {
|
||||
try {
|
||||
await nav.share({ files: [file], title: file.name });
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return 'failed';
|
||||
const file = new File([await resp.blob()], filename, { type: mime });
|
||||
if (nav.canShare({ files: [file] })) {
|
||||
try {
|
||||
// No title: iOS pastes it as accompanying text next to the image; the named
|
||||
// file speaks for itself.
|
||||
await nav.share({ files: [file] });
|
||||
} catch {
|
||||
/* cancelled — intentionally a no-op */
|
||||
}
|
||||
return 'shared';
|
||||
}
|
||||
} catch {
|
||||
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
|
||||
return 'failed';
|
||||
}
|
||||
return 'shared';
|
||||
}
|
||||
downloadFile(file, file.name, file.type);
|
||||
downloadUrl(url, filename);
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
|
||||
+26
-1
@@ -67,6 +67,7 @@ interface TelegramWebApp {
|
||||
};
|
||||
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
||||
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
||||
downloadFile?: (params: { url: string; file_name: string }, cb?: (accepted: boolean) => void) => void;
|
||||
}
|
||||
|
||||
function webApp(): TelegramWebApp | undefined {
|
||||
@@ -372,7 +373,8 @@ export function telegramShowConfirm(message: string): Promise<boolean> {
|
||||
/**
|
||||
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
||||
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
||||
* predating the popup, so callers can fall back to their own modal.
|
||||
* predating the popup, so callers can fall back to their own modal. NOTE: the callback runs with
|
||||
* no user activation — never lead from it into navigator.share or a clipboard write.
|
||||
*/
|
||||
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
||||
const w = webApp();
|
||||
@@ -380,6 +382,29 @@ export function telegramShowPopup(params: TelegramPopupParams): Promise<string |
|
||||
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
||||
}
|
||||
|
||||
/** telegramCanDownloadFile reports whether the native download dialog exists (Bot API 8.0). */
|
||||
export function telegramCanDownloadFile(): boolean {
|
||||
return !!webApp()?.downloadFile;
|
||||
}
|
||||
|
||||
/** telegramPlatform returns the SDK's platform tag ('ios', 'android', 'tdesktop', …) or ''
|
||||
* outside Telegram — the export delivery branches on it (iOS gets the OS share sheet). */
|
||||
export function telegramPlatform(): string {
|
||||
return webApp()?.platform ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramDownloadFile asks Telegram to download url as fileName through its native
|
||||
* dialog — the Mini App file delivery that works on every Telegram platform (a webview
|
||||
* <a download> does not). Returns false outside Telegram or on a client predating it.
|
||||
*/
|
||||
export function telegramDownloadFile(url: string, fileName: string): boolean {
|
||||
const w = webApp();
|
||||
if (!w?.downloadFile) return false;
|
||||
w.downloadFile({ url, file_name: fileName });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Haptic is the set of feedbacks the app triggers. */
|
||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||
|
||||
|
||||
@@ -271,6 +271,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async exportGcg(gameId) {
|
||||
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
|
||||
},
|
||||
async exportUrl(gameId, kind, dateLocale, actionLabels, timeZone) {
|
||||
return codec.decodeExportUrl(
|
||||
await exec('game.export_url', codec.encodeExportUrlRequest(gameId, kind, dateLocale, actionLabels, timeZone)),
|
||||
);
|
||||
},
|
||||
|
||||
subscribe(onEvent, onError) {
|
||||
const ctrl = new AbortController();
|
||||
|
||||
@@ -155,6 +155,36 @@ export async function vkShare(link: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShowImages opens VK's native image viewer on url — on the Android client this is the
|
||||
* PNG delivery: the viewer's own "save" works there, while VKWebAppDownloadFile hangs
|
||||
* indefinitely (on-device finding). Resolves false on any failure, so the caller can
|
||||
* fall back.
|
||||
*/
|
||||
export async function vkShowImages(url: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppShowImages', { images: [url] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||
* on any failure or where the method is unavailable (notably the desktop iframe), so the
|
||||
* caller falls back to a plain browser download of the same URL.
|
||||
*/
|
||||
export async function vkDownloadFile(url: string, filename: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppDownloadFile', { url, filename });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
||||
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
||||
|
||||
Reference in New Issue
Block a user