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

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:
2026-07-02 21:58:07 +00:00
parent 16a4431158
commit d5fbaa3034
62 changed files with 2449 additions and 234 deletions
+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')}