feat(export): platform-native delivery — TG popup+downloadFile, VK viewer, mobile share sheet
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Failing after 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped

Per the on-device review, the last hop is now the most native affordance
per platform, all fed by the same signed URL:

- Telegram: the native showPopup chooser returns (safe here — the whole
  TG chain is bridge calls, which need no user activation) and both
  formats go to the native downloadFile dialog on iOS and Android alike.
- VK: the PNG opens in VK's native photo viewer (VKWebAppShowImages) —
  on screen at once, saved/shared from the viewer's own controls; the
  GCG keeps VKWebAppDownloadFile; either falls back to a plain anchor
  download (the desktop iframe). The gateway now serves /dl/* via
  http.ServeContent (Content-Length + Range/206) — another swing at the
  VK Android DownloadManager hang; if it persists, the next step is the
  clipboard fallback for the VK-Android GCG.
- Mobile browsers: the OS share sheet with the fetched file (the proven
  fetch-then-share pattern) — nothing lands in Downloads first; desktop
  keeps the anchor download.

Legacy Telegram (< 8.0, no downloadFile) keeps the app modal + GCG
clipboard copy and hides the image option.
This commit is contained in:
Ilia Denisov
2026-07-02 22:19:46 +02:00
parent c3c27bba5e
commit c3c26bbbc0
11 changed files with 189 additions and 60 deletions
+8 -3
View File
@@ -794,9 +794,14 @@ platform: the client calls the authenticated `game.export_url` op, the backend m
**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 platform's native
download: Telegram `downloadFile` (Bot API 8.0), `VKWebAppDownloadFile`, or a plain
browser anchor elsewhere (including the desktop VK iframe). The GET is the gateway's
service ever needs to know the public host) and hands it to the platform's most
native affordance: Telegram `downloadFile` (Bot API 8.0; the chooser there is the
native showPopup — the whole TG chain is activation-free bridge calls), VK's native
photo viewer for the PNG (`VKWebAppShowImages`) + `VKWebAppDownloadFile` for the
GCG, the OS share sheet with the fetched file on a mobile browser, or a plain
anchor download (desktop, and the VK desktop iframe fallback). 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
+14 -12
View File
@@ -322,9 +322,9 @@ 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**
inside Telegram (safe there: the whole Telegram chain is bridge calls, which need no
user activation) and the app's own modal elsewhere (VK has no native chooser).
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
@@ -340,15 +340,17 @@ 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 is **one route for both formats on every platform**: the app requests a
signed, short-lived download link and hands it to the platform's native download —
Telegram's download dialog, VK's `VKWebAppDownloadFile`, or an ordinary browser
file download elsewhere (including the desktop VK iframe). The link needs no login
to fetch (the platforms' download calls carry none), is valid for minutes, and
serves the artifact as a named file attachment. 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):
Delivery is **one signed, short-lived link for both formats on every platform**,
handed to the most native affordance: Telegram's download dialog (both TG
platforms; sharing onwards from Telegram's own file preview); on VK the image opens
in **VK's native photo viewer** (on screen at once — saving/sharing are the
viewer's own controls) and the GCG goes through VK's download; a **mobile browser
gets the OS share sheet** with the file (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
+14 -11
View File
@@ -331,9 +331,10 @@ Telegram.
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения,
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
молча ломает доставку через share/буфер, к которой ведёт выбор.
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** внутри
Telegram (там это безопасно: вся телеграмовская цепочка — bridge-вызовы, которым
user activation не нужна) и собственный модал приложения в остальных случаях (у VK
нативного чузера нет).
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
@@ -349,14 +350,16 @@ Telegram.
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка — **один путь для обоих форматов на любой платформе**: приложение
запрашивает подписанную короткоживущую ссылку на скачивание и передаёт её родному
механизму платформы — диалогу загрузки Telegram, `VKWebAppDownloadFile` у VK или
обычному браузерному скачиванию файла в остальных случаях (включая десктопный
iframe VK). Ссылка не требует логина при скачивании (родные загрузчики платформ его
не несут), живёт минуты и отдаёт артефакт именованным файлом. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
платформе**, отданная самому родному механизму: диалог загрузки Telegram (обе
платформы TG; поделиться дальше — из телеграмовского превью файла); в VK картинка
открывается в **нативном просмотрщике фото VK** (сразу на экране — сохранение и
шаринг штатными кнопками просмотрщика), а GCG идёт через загрузку VK; **мобильный
браузер получает системную share-шторку** с файлом (ничего не оседает в Загрузках);
десктопный браузер скачивает файл. Ссылка не требует логина при скачивании (родные
загрузчики платформ его не несут) и живёт минуты. Единственное исключение —
устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там GCG
откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
+13 -9
View File
@@ -406,15 +406,19 @@ 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 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
opens the **format chooser**: Telegram's native popup inside Telegram — safe there,
since the whole TG chain is 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 (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 natively:
Telegram `downloadFile` (both TG platforms); on VK the PNG opens in the native photo
viewer (`VKWebAppShowImages`, save/share from its controls) and the GCG goes through
`VKWebAppDownloadFile`, both falling back to a plain anchor download (the desktop
iframe); a mobile browser fetches the bytes and opens the OS share sheet
(`navigator.share` with the file — the proven fetch-then-share pattern), a desktop
browser downloads by anchor. 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
+6 -5
View File
@@ -7,6 +7,7 @@
package connectsrv
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
@@ -14,7 +15,6 @@ import (
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -466,10 +466,11 @@ func (s *Server) exportDownloadHandler() http.Handler {
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, max-age=0")
// Explicit length, or the body goes out chunked and Android's system
// DownloadManager (the VKWebAppDownloadFile executor) hangs on it.
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
// ServeContent (not a bare Write): the platforms' native downloaders are picky —
// Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a
// chunked body of unknown length and may probe with Range. ServeContent emits
// Content-Length, honours Range/If-* and answers 206s from the buffered artifact.
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
})
}
+48 -4
View File
@@ -58,9 +58,12 @@ test('the chooser downloads the GCG through the same signed-URL route', async ({
expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/);
});
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.
test('inside Telegram the chooser is the native popup and delivery the native downloadFile', async ({
page,
}) => {
// A Telegram stub with showPopup AND downloadFile (Bot API 8.0): the whole chain stays
// in bridge calls — the popup picks the image, the artifact goes to the native download
// dialog, never an in-webview anchor. (Activation-safe: no gesture-gated Web APIs.)
await page.addInitScript(() => {
Object.assign(window, {
Telegram: {
@@ -69,6 +72,11 @@ test('inside Telegram both formats go through the native downloadFile dialog', a
initDataUnsafe: {},
ready() {},
expand() {},
showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) {
const w = window as unknown as { __popup?: unknown };
w.__popup = params;
setTimeout(() => cb('png'), 0);
},
downloadFile(params: { url: string; file_name: string }) {
const w = window as unknown as { __downloads?: { url: string; file_name: string }[] };
(w.__downloads ??= []).push(params);
@@ -84,7 +92,13 @@ test('inside Telegram both formats go through the native downloadFile dialog', a
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Export game' }).click();
await page.getByRole('button', { name: 'Image (PNG)' }).click();
// The native popup carried both formats plus cancel, and no in-app modal was mounted.
const popup = await page.evaluate(
() => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup,
);
expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['png', 'gcg', 'cancel']);
await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads),
@@ -98,6 +112,36 @@ test('inside Telegram both formats go through the native downloadFile dialog', a
expect(dl.file_name).toMatch(/^game-.+\.png$/);
});
test('a mobile browser gets the OS share sheet with the fetched file', async ({ page }) => {
// Web Share with files present (a mobile browser): the artifact is fetched from the
// signed URL and handed to navigator.share as a File — the sheet opens directly,
// nothing lands in Downloads first.
await page.addInitScript(() => {
const shared: { name: string; type: string }[] = [];
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true });
Object.defineProperty(navigator, 'share', {
value: async (data: { files?: File[] }) => {
for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type });
},
configurable: true,
});
(window as unknown as { __shared: typeof shared }).__shared = shared;
});
await routeDl(page);
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
await page.getByRole('button', { name: 'Image (PNG)' }).click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
.toHaveLength(1);
const shared = await page.evaluate(
() => (window as unknown as { __shared: { name: string; type: string }[] }).__shared[0],
);
expect(shared.name).toMatch(/^game-.+\.png$/);
expect(shared.type).toBe('image/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
+39 -16
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 { downloadUrl, shareOrDownloadGcg } from '../lib/share';
import { insideVK, vkCopyText, vkDownloadFile } from '../lib/vk';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkCopyText, vkDownloadFile, 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, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm } from '../lib/telegram';
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
import { haptic } from '../lib/haptics';
import {
BLANK,
@@ -932,20 +932,37 @@
}
// 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).
// image — behind one 📤 button, both minted as one signed relative URL and delivered by
// the most platform-native affordance:
// Telegram native showPopup chooser → native downloadFile dialog. All-native and
// activation-safe: a popup callback carries no user activation, but the
// whole TG chain is bridge calls, never gesture-gated Web APIs.
// VK our modal chooser (VK has no native one) → the PNG opens in VK's native
// image viewer (VKWebAppShowImages — on screen at once, save/share from the
// viewer's own controls); the GCG goes through VKWebAppDownloadFile; either
// falls back to a plain anchor download when the bridge call fails (the
// desktop iframe is an ordinary browser).
// browsers our modal → the OS share sheet with the fetched file where files can be
// shared (mobile), else a plain anchor download (desktop).
let exportOpen = $state(false);
// 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.
// the image option is not offered (and the chooser stays our modal).
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile());
function onExportClick() {
async function onExportClick() {
if (insideTelegram() && 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;
}
@@ -963,8 +980,8 @@
}
// 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.
// 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() && !telegramCanDownloadFile()) {
@@ -978,8 +995,14 @@
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);
if (insideVK()) {
if (kind === 'png' && (await vkShowImages(url))) return;
if (kind === 'gcg' && (await vkDownloadFile(url, filename))) return;
downloadUrl(url, filename);
return;
}
const outcome = await shareUrlAsFile(url, filename, kind === 'png' ? 'image/png' : 'text/plain');
if (outcome === 'failed') showToast(t('error.generic'), 'error');
} catch (e) {
handleError(e);
}
+1
View File
@@ -311,6 +311,7 @@ export const en = {
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file',
'game.gcgActiveOnly': 'Available once the game is finished.',
+1
View File
@@ -311,6 +311,7 @@ export const ru: Record<MessageKey, string> = {
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
+31
View File
@@ -97,6 +97,37 @@ export function downloadUrl(url: string, filename: string): void {
a.remove();
}
/**
* shareUrlAsFile delivers a signed export URL through the OS share sheet where files can
* be shared (mobile browsers): it fetches the same-origin bytes, wraps them in a File and
* calls navigator.share — the sheet opens directly, nothing lands in Downloads first. The
* fetch-then-share chain keeps the click's user activation (the proven GCG pattern).
* Where file sharing is absent (desktop) it falls back to the plain anchor download. A
* cancelled share is a no-op by the shareOrDownloadGcg invariant.
*/
export async function shareUrlAsFile(url: string, filename: string, mime: string): Promise<'shared' | 'downloaded' | 'failed'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (nav && typeof nav.share === 'function' && typeof nav.canShare === 'function') {
try {
const resp = await fetch(url);
if (!resp.ok) return 'failed';
const file = new File([await resp.blob()], filename, { type: mime });
if (nav.canShare({ files: [file] })) {
try {
await nav.share({ files: [file], title: filename });
} catch {
/* cancelled — intentionally a no-op */
}
return 'shared';
}
} catch {
return 'failed';
}
}
downloadUrl(url, filename);
return 'downloaded';
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**
+14
View File
@@ -155,6 +155,20 @@ export async function vkShare(link: string): Promise<boolean> {
}
}
/**
* vkShowImages opens VK's native image viewer on url — the image is on screen at once and
* the viewer's own controls save/share it, which beats a blind background download for a
* picture. Resolves false on any failure or outside VK, so the caller can fall back.
*/
export async function vkShowImages(url: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShowImages', { images: [url] });
return true;
} catch {
return false;
}
}
/**
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false