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:
+85
-28
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user