feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README.
This commit was merged in pull request #160.
This commit is contained in:
+34
-5
@@ -785,11 +785,40 @@ the same rows and is likewise self-contained — we ship our own writer (the sol
|
||||
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
|
||||
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
|
||||
exchanges), plus `#note` lines for resignations and timeouts, which the standard
|
||||
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
|
||||
otherwise), so an in-progress journal is never leaked mid-play; the client
|
||||
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
|
||||
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
|
||||
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
|
||||
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
|
||||
otherwise), so an in-progress journal is never leaked mid-play.
|
||||
|
||||
**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
|
||||
and the rendered **PNG of the final position** — travel one uniform route on every
|
||||
platform: the client calls the authenticated `game.export_url` op, the backend mints a
|
||||
**relative, HMAC-signed, short-lived path**
|
||||
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
|
||||
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
|
||||
service ever needs to know the public host) and hands it to the best affordance
|
||||
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
|
||||
chooser there is the native showPopup — activation-free bridge calls end to end),
|
||||
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
|
||||
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
|
||||
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
|
||||
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
|
||||
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
|
||||
iframe). The gateway serves the bytes via
|
||||
`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
|
||||
hangs on chunked bodies of unknown length). The GET is the gateway's
|
||||
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
|
||||
per-IP public rate limiter): the native download calls carry no cookies or headers, so
|
||||
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
|
||||
time, every failure a uniform 404 — is the whole grant, and minting requires an
|
||||
authenticated caller on a finished game. The PNG is rasterized on demand by the
|
||||
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
|
||||
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
|
||||
`renderer/README.md`); the backend rebuilds the render payload from the journal +
|
||||
`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized
|
||||
non-play labels ride the signed URL so the server render matches the player's
|
||||
presentation. Nothing is stored: the artifact is re-derived from the immutable
|
||||
finished journal on each GET. The only degraded platform is a legacy Telegram client
|
||||
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||
image option is not offered.
|
||||
|
||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||
|
||||
+32
-25
@@ -322,33 +322,40 @@ Finished games are archived in a dictionary-independent form and exportable in
|
||||
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
|
||||
the final position; the export is offered **only once a game is finished**, and
|
||||
never for an honest-AI practice game (a live game's export would leak the move
|
||||
journal; an AI game is throwaway). The format chooser is always the app's **own
|
||||
modal** — deliberately not Telegram's native popup, whose callback runs without
|
||||
user activation and silently breaks the share/clipboard delivery it leads to.
|
||||
journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
|
||||
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
|
||||
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
|
||||
is the OS share sheet, which a native-popup callback cannot open, and VK has no
|
||||
native chooser.
|
||||
|
||||
The **image** is rendered on the client (Canvas 2D, always the light theme): the
|
||||
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
|
||||
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
|
||||
right: each seat's name and final score in the header (🏆 by the winner), then one
|
||||
row per move carrying the main word's classic coordinate (an across play is
|
||||
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
|
||||
its points; extra words of a multi-word play ride a smaller second line, non-play
|
||||
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
|
||||
shows the endgame rack settlement when there was one (the final scores are
|
||||
authoritative — running totals alone do not include it). The footer stamps the site
|
||||
host and the finish date in the device locale. The scoresheet typography is fixed;
|
||||
a long game stretches the board (never below its minimum) so the image carries no
|
||||
dead space.
|
||||
The **image** is rendered on the server (the internal render sidecar runs the same
|
||||
drawing module the web client tests; always the light theme): the final board with
|
||||
classic coordinate axes A..O / 1..15 on the left — premium squares as plain colour
|
||||
fills, no text labels — and a compact per-seat scoresheet on the right: each seat's
|
||||
name and final score in the header (🏆 by the winner), then one row per move
|
||||
carrying the main word's classic coordinate (an across play is row-first, `8G`; a
|
||||
down play column-first, `H8` — the GCG convention), the word and its points; extra
|
||||
words of a multi-word play ride a smaller second line, non-play moves show as
|
||||
localized notes (pass/exchange/resign/timeout), and a closing ± row shows the
|
||||
endgame rack settlement when there was one (the final scores are authoritative —
|
||||
running totals alone do not include it). The footer stamps the site host and the
|
||||
finish date in the device locale. The scoresheet typography is fixed; a long game
|
||||
stretches the board (never below its minimum) so the image carries no dead space.
|
||||
|
||||
Delivery per format: the **GCG** file is Web-Shared where the platform supports it;
|
||||
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
|
||||
working file download, it copies the GCG text to the clipboard (with a confirming
|
||||
toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the
|
||||
same way, but a binary image has no clipboard-text fallback at all — so the image
|
||||
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
|
||||
offered on the plain web and mobile browsers only; it returns there with the
|
||||
server-rendered signed-URL delivery (Telegram `downloadFile` /
|
||||
`VKWebAppDownloadFile`). Statistics (durable accounts only):
|
||||
Delivery is **one signed, short-lived link for both formats on every platform**,
|
||||
handed to the best affordance each platform has (each branch verified on-device):
|
||||
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
|
||||
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
|
||||
takes both formats through VK's download into its native share flow, while on the
|
||||
**VK Android client** — whose downloader hangs — the image opens in VK's native
|
||||
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
|
||||
desktop VK iframe, an ordinary browser, downloads both); a **mobile browser gets
|
||||
the OS share sheet** (nothing lands in Downloads first); a desktop browser
|
||||
downloads the file. The link needs no login to fetch (the platforms' downloaders
|
||||
carry none) and is valid for minutes. The single exception is a legacy Telegram
|
||||
client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
|
||||
the old clipboard copy (with the confirming toast) and the image option is not
|
||||
offered. Statistics (durable accounts only):
|
||||
wins, losses, draws, max points in a game, and max points for a single move (the
|
||||
best play, which already includes every word it formed plus the all-tiles bonus). It
|
||||
also shows the player's **move count** (their plays — passes and exchanges do not
|
||||
|
||||
+21
-13
@@ -331,11 +331,14 @@ Telegram.
|
||||
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
|
||||
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
|
||||
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
|
||||
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения,
|
||||
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
|
||||
молча ломает доставку через share/буфер, к которой ведёт выбор.
|
||||
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
|
||||
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
|
||||
которым user activation не нужна) и собственный модал приложения в остальных
|
||||
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
|
||||
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
|
||||
|
||||
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
|
||||
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
|
||||
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
|
||||
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
||||
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
||||
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
||||
@@ -348,15 +351,20 @@ Telegram.
|
||||
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
||||
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
||||
|
||||
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
|
||||
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
|
||||
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
|
||||
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
|
||||
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
|
||||
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
|
||||
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
|
||||
и доставкой по подписанному URL (Telegram `downloadFile` /
|
||||
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
|
||||
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
|
||||
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
|
||||
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
|
||||
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
|
||||
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
|
||||
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
|
||||
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
|
||||
(десктопный iframe VK — обычный браузер — скачивает оба); **мобильный браузер
|
||||
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
|
||||
браузер скачивает файл. Ссылка не требует логина при
|
||||
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
|
||||
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
|
||||
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
|
||||
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||
|
||||
+9
-2
@@ -19,8 +19,15 @@ tests or touching CI.
|
||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||
invitations section, the stats screen, profile editing, and the GCG export's
|
||||
finished-only visibility.
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted).
|
||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||
real self-played 35-move game) must rasterize to a plausible PNG
|
||||
(`pnpm -C renderer test`). The drawing module's pure parts (scoresheet, layout,
|
||||
notation) stay unit-tested in `ui/` — the sidecar test guards only the
|
||||
skia/bundling seam. Pixel goldens are deliberately avoided (fonts differ across
|
||||
hosts).
|
||||
- **Local-eval conformance** — the client's on-device move preview (the ported
|
||||
dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the
|
||||
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
|
||||
|
||||
+30
-23
@@ -406,29 +406,36 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
||||
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
|
||||
(the word(s) and the move score, no running total); the 📤 in the history header appears
|
||||
only once the game is finished — never in an honest-AI game (throwaway practice) — and
|
||||
opens the **format chooser**: always the app's own modal, never Telegram's native popup
|
||||
(a `showPopup` callback carries no user activation, which silently breaks the
|
||||
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
|
||||
leads with the image where offered, the GCG file is the quiet alternative (GCG needs the
|
||||
connection, the image renders locally). The GCG file goes by Web Share where available, a
|
||||
clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor
|
||||
a working download), else a Blob download. The image option shows **only outside the in-app
|
||||
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
|
||||
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
|
||||
returns there with the server-rendered signed-URL delivery. Confirming a resign reveals
|
||||
the full board: it closes the history drawer (portrait) and zooms the board out.
|
||||
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
|
||||
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
|
||||
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
|
||||
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
|
||||
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
|
||||
the winner), one fixed-typography row per move — muted classic coordinate (across =
|
||||
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
|
||||
words of a multi-word play on a smaller second line; italic localized notes for
|
||||
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
|
||||
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
|
||||
height drives the board side (never below its minimum): a long game stretches the board,
|
||||
never the typography, so the image has no dead space.
|
||||
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
|
||||
there, since that chain is all bridge calls needing no user activation (a popup callback
|
||||
must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
|
||||
own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
|
||||
click's activation; VK has no native chooser; the accent button leads with the image,
|
||||
both options need the connection). Both formats mint the same signed relative link
|
||||
(`game.export_url`), resolved against the app's own origin, then delivered per platform
|
||||
(each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
|
||||
shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
|
||||
`VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
|
||||
DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
|
||||
works) and the GCG to the clipboard; the VK desktop iframe plain anchor downloads; a
|
||||
mobile browser the OS share sheet (the proven fetch-then-share pattern); a desktop
|
||||
browser an anchor download. A legacy Telegram
|
||||
client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
|
||||
and stays on the app modal. Confirming a resign reveals the full board: it closes the
|
||||
history drawer (portrait) and zooms the board out.
|
||||
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
|
||||
executes; the browser app no longer draws it): always the light palette (pinned constants
|
||||
mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
|
||||
plain colour fills (no text labels), tiles in the in-game style (bevel, letter top-left,
|
||||
value bottom-right, ✻ for an Erudit blank), no last-move highlight. The right column is
|
||||
the scoresheet: per-seat name over the final score (🏆 by the winner), one
|
||||
fixed-typography row per move — muted classic coordinate (across = row-first `8G`, down =
|
||||
column-first `H8`), the main word, points right-aligned; extra words of a multi-word play
|
||||
on a smaller second line; italic localized notes for pass/exchange/resign/timeout; a
|
||||
closing muted ± row for the endgame rack settlement when present. Footer:
|
||||
`hostname · finish date` in the **device** locale. The scoresheet's height drives the
|
||||
board side (never below its minimum): a long game stretches the board, never the
|
||||
typography, so the image has no dead space.
|
||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||
|
||||
Reference in New Issue
Block a user