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

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:
Ilia Denisov
2026-07-02 18:44:07 +02:00
parent 16a4431158
commit 3471d40576
60 changed files with 2216 additions and 236 deletions
+77 -32
View File
@@ -1,18 +1,31 @@
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.
// chooser (never Telegram's native popup — its callback carries no user activation),
// and BOTH formats travel the same unified route on every platform: the client mints a
// signed relative URL (game.export_url), resolves it against its own origin and hands
// it to the platform's native download — Telegram downloadFile / VKWebAppDownloadFile
// or a plain anchor download in a browser. 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';
const PNG_BYTES = Uint8Array.from({ length: PNG_HEX.length / 2 }, (_, i) =>
parseInt(PNG_HEX.slice(i * 2, i * 2 + 2), 16),
);
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,13 +58,9 @@ 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 ({
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).
test('inside Telegram both formats go through the native downloadFile dialog', async ({ page }) => {
// A Telegram stub with downloadFile present (Bot API 8.0): the image option is offered
// and the artifact is handed to the native download — never an in-webview anchor.
await page.addInitScript(() => {
Object.assign(window, {
Telegram: {
@@ -62,9 +69,9 @@ test('inside Telegram the chooser is the app modal and withholds the image optio
initDataUnsafe: {},
ready() {},
expand() {},
showPopup(_params: unknown, cb: (id: string) => void) {
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
setTimeout(() => cb(''), 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 +84,49 @@ 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…
await page.getByRole('button', { name: 'Image (PNG)' }).click();
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 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();
});
+42 -27
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 } from '../lib/share';
import { insideVK, vkCopyText, vkDownloadFile } 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, telegramShowConfirm } from '../lib/telegram';
import { haptic } from '../lib/haptics';
import {
BLANK,
@@ -931,26 +931,30 @@
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, always through the app's own modal. Never Telegram's
// native popup here: its callback runs with NO user activation, which breaks the
// gesture-gated delivery APIs (verified on-device). Both formats travel the SAME route on
// every platform: mint a signed relative URL, resolve it against our origin, then hand it
// to the platform's native download call — Telegram downloadFile / VKWebAppDownloadFile —
// or a plain browser anchor download elsewhere (including the desktop VK iframe, where
// the bridge method is unavailable and vkDownloadFile falls through).
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()));
// The only platform without the URL delivery is a legacy Telegram client predating
// Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and
// the image option is not offered.
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile());
function onExportClick() {
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 +962,24 @@
}
}
// 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 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() && !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;
if (insideTelegram() && telegramDownloadFile(url, filename)) return;
if (insideVK() && (await vkDownloadFile(url, filename))) return;
downloadUrl(url, filename);
} catch (e) {
handleError(e);
}
@@ -1492,13 +1503,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;
+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();
});
});
+13 -36
View File
@@ -81,43 +81,20 @@ 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';
}
/**
* 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).
*/
export async function shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (pickImageDelivery(nav, file) === 'share' && nav) {
try {
await nav.share({ files: [file], title: file.name });
} catch {
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
}
return 'shared';
}
downloadFile(file, file.name, file.type);
return 'downloaded';
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();
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
+20 -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,23 @@ 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;
}
/**
* 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();
+15
View File
@@ -155,6 +155,21 @@ export async function vkShare(link: string): Promise<boolean> {
}
}
/**
* 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.