fix(export): per-platform polish from the on-device review
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
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 1m41s

- TG iOS: the OS share sheet with the fetched file (WKWebView has
  navigator.share — the долгоживущий debug-overlay Share path proves
  it). The chooser stays the app modal on this one platform: a native
  popup callback cannot supply the user activation the sheet needs.
  TG Android/desktop keep the native popup + downloadFile (verified
  perfect, untouched).
- VK mobile: the PNG now rides VKWebAppDownloadFile like the GCG — the
  owner-verified path that lands in VK's native share flow; the
  ShowImages viewer is dropped (its preview offered save only, no
  share).
- VK desktop iframe: both formats are plain anchor downloads (it is an
  ordinary browser; the viewer/bridge path made no sense there).
This commit is contained in:
Ilia Denisov
2026-07-02 23:05:35 +02:00
parent a9376c20a2
commit 8db18f2a2a
8 changed files with 142 additions and 83 deletions
+9 -8
View File
@@ -794,14 +794,15 @@ 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 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
service ever needs to know the public host) and hands it to the best affordance
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
chooser there is the native showPopup — activation-free bridge calls end to end),
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
the user activation a popup callback lacks), VK mobile `VKWebAppDownloadFile` for
both formats, the OS share sheet on a mobile browser, or a plain anchor download
(desktop browsers and the VK desktop iframe). 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
+15 -12
View File
@@ -323,8 +323,10 @@ Finished games are archived in a dictionary-independent form and exportable in
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 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).
on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
no user activation) and the app's own modal elsewhere — on Telegram iOS the delivery
is the OS share sheet, which a native-popup callback cannot open, and 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
@@ -341,16 +343,17 @@ finish date in the device locale. The scoresheet typography is fixed; a long gam
stretches the board (never below its minimum) so the image carries no dead space.
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):
handed to the best affordance each platform has (each branch verified on-device):
Telegram Android/desktop use Telegram's download dialog (whose own preview shares
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; on VK
both formats go through VK's download into its native share/preview flow (the
desktop VK iframe, an ordinary browser, downloads them); a **mobile browser gets
the OS share sheet** (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
+15 -13
View File
@@ -331,10 +331,11 @@ Telegram.
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** внутри
Telegram (там это безопасно: вся телеграмовская цепочка — bridge-вызовы, которым
user activation не нужна) и собственный модал приложения в остальных случаях (у VK
нативного чузера нет).
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
которым user activation не нужна) и собственный модал приложения в остальных
случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
@@ -351,15 +352,16 @@ user activation не нужна) и собственный модал прило
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
платформе**, отданная самому родному механизму: диалог загрузки Telegram (обе
платформы TG; поделиться дальше — из телеграмовского превью файла); в VK картинка
открывается в **нативном просмотрщике фото VK** (сразу на экране — сохранение и
шаринг штатными кнопками просмотрщика), а GCG идёт через загрузку VK; **мобильный
браузер получает системную share-шторку** с файлом (ничего не оседает в Загрузках);
десктопный браузер скачивает файл. Ссылка не требует логина при скачивании (родные
загрузчики платформ его не несут) и живёт минуты. Единственное исключение —
устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там GCG
откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
скачанным файлом; в VK оба формата идут через загрузку VK в её нативный
share/preview-поток (десктопный iframe VK — обычный браузер — скачивает файлы);
**мобильный браузер получает системную share-шторку** (ничего не оседает в
Загрузках); десктопный браузер скачивает файл. Ссылка не требует логина при
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
+13 -13
View File
@@ -406,21 +406,21 @@ 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**: Telegram's native popup inside Telegram — safe there,
since the whole TG chain is bridge calls needing no user activation (a popup callback
opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
there, since that chain is all 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,
own modal elsewhere (TG iOS delivers via the OS share sheet, which needs the modal
click's activation; 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.
(`game.export_url`), resolved against the app's own origin, then delivered per platform
(each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
shares onwards); TG iOS the OS share sheet with the fetched file; VK mobile
`VKWebAppDownloadFile` for both formats (VK's native share/preview flow), the VK desktop
iframe plain anchor downloads; a mobile browser the OS share sheet (the proven
fetch-then-share pattern); a desktop browser an anchor download. 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
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