feat(export): server-rendered artifacts behind one signed download URL #160

Merged
developer merged 7 commits from feature/export-server-render into development 2026-07-02 21:58:07 +00:00
62 changed files with 2449 additions and 234 deletions
+13
View File
@@ -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 }}
+5 -2
View File
@@ -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
+3 -1
View File
@@ -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 .
+10
View File
@@ -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)
+8
View File
@@ -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
+31 -8
View File
@@ -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
}
+61
View File
@@ -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
}
+310
View File
@@ -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
}
+148
View File
@@ -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)
}
}
}
+5
View File
@@ -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)
+13
View File
@@ -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()
+5
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
+7
View File
@@ -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:
+36
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+36
View File
@@ -39,10 +39,18 @@ const backendMaxIdleConns = 512
type Client struct {
baseURL string
http *http.Client
// 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() }
+59
View File
@@ -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) {
+21
View File
@@ -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
+71
View File
@@ -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()
}
+116
View File
@@ -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()
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+29
View File
@@ -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"]
+34
View File
@@ -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.
+13
View File
@@ -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',
});
+23
View File
@@ -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"
]
}
}
+366
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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';
+24
View File
@@ -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');
}
+55
View File
@@ -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}`));
+37
View File
@@ -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);
});
File diff suppressed because one or more lines are too long
+175 -31
View File
@@ -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
View File
@@ -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')}
+2
View File
@@ -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);
}
}
+60
View File
@@ -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);
}
}
+5
View File
@@ -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>;
+26
View File
@@ -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(
+31
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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.',
+1
View File
@@ -311,6 +311,7 @@ export const ru: Record<MessageKey, string> = {
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
+7
View File
@@ -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 {
+8
View File
@@ -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;
+1
View File
@@ -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',
+7 -38
View File
@@ -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();
});
});
+35 -25
View File
@@ -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 or failed — intentionally a no-op (see shareOrDownloadGcg) */
/* cancelled — intentionally a no-op */
}
return 'shared';
}
downloadFile(file, file.name, file.type);
} catch {
return 'failed';
}
}
downloadUrl(url, filename);
return 'downloaded';
}
+26 -1
View File
@@ -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';
+5
View File
@@ -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();
+30
View File
@@ -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.