feat(export): server-rendered artifacts behind one signed download URL
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The finished-game export now works identically on every platform: the client mints a signed relative URL (game.export_url) carrying its date locale, IANA time zone and localized non-play labels, resolves it against its own origin and hands it to the platform's native download — Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor (also the desktop VK iframe). Both artifacts ride the route: the .gcg text (no more clipboard mode, except the legacy pre-8.0 Telegram fallback) and the PNG of the final position. The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at image build time — one renderer, no drift; the browser no longer draws or delivers bytes itself. The backend rebuilds the render payload from the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL, BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public group, and streams the artifact as a named attachment; the gateway forwards /dl/* behind the per-IP public rate limiter (caddy @gateway matcher extended — the landing catch-all trap). Deploy: renderer service (compose + prod overlay + roll before backend + prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
This commit is contained in:
+23
-22
@@ -407,28 +407,29 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
||||
(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.
|
||||
(a `showPopup` callback carries no user activation, which silently breaks gesture-gated
|
||||
delivery APIs — on-device finding). The accent button leads with the image, the GCG file
|
||||
is the quiet alternative; both need the connection. Both formats travel the same
|
||||
signed-URL route: the app mints a relative link (`game.export_url`), resolves it against
|
||||
its own origin and hands it to the platform's native download — Telegram `downloadFile`,
|
||||
`VKWebAppDownloadFile`, else a plain anchor download (also the desktop VK iframe, where
|
||||
the bridge method is absent and the anchor works). A legacy Telegram client without
|
||||
`downloadFile` keeps the old GCG clipboard copy and hides the image option. 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