fix(export): VK Android viewer+clipboard; no share title on iOS
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s

Round-5 on-device findings:

- VK Android's DownloadFile hangs regardless of Content-Length and
  Range/206 (PNG stalls ~80%, GCG at 0%) — the download route is
  abandoned there: the PNG opens in VK's native image viewer
  (VKWebAppShowImages — its save works on-device) and the GCG returns
  to the clipboard copy (the pre-URL route, always solid). VK iOS keeps
  VKWebAppDownloadFile (verified perfect); the desktop iframe keeps
  plain anchor downloads.
- The iOS share sheet no longer carries a title: iOS pasted the
  'game-<hash>.png' filename as accompanying text next to the image.
This commit is contained in:
Ilia Denisov
2026-07-02 23:32:52 +02:00
parent 8db18f2a2a
commit 77f222c209
7 changed files with 59 additions and 21 deletions
+5 -3
View File
@@ -798,9 +798,11 @@ 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
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
DownloadFile hangs regardless of Content-Length/Range — 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
+5 -3
View File
@@ -345,9 +345,11 @@ 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 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
onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
takes both formats through VK's download into its native share flow, while on the
**VK Android client** — whose downloader hangs — the image opens in VK's native
photo viewer (saving from its controls) and the GCG copies to the clipboard (the
desktop VK iframe, an ordinary browser, downloads both); 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
+6 -4
View File
@@ -355,10 +355,12 @@ Telegram Android/desktop (там это безопасно: цепочка це
платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
скачанным файлом; в VK оба формата идут через загрузку VK в её нативный
share/preview-поток (десктопный iframe VK — обычный браузерскачивает файлы);
**мобильный браузер получает системную share-шторку** (ничего не оседает в
Загрузках); десктопный браузер скачивает файл. Ссылка не требует логина при
скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
(десктопный iframe VK — обычный браузер скачивает оба); **мобильный браузер
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
браузер скачивает файл. Ссылка не требует логина при
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
+6 -4
View File
@@ -414,10 +414,12 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
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 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
shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
`VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
works) and the GCG to the clipboard; 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.
+19 -6
View File
@@ -23,7 +23,7 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkCopyText, vkDownloadFile, vkPlatform } from '../lib/vk';
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';
@@ -939,8 +939,11 @@
// 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 mobile our modal (VK has no native chooser) → VKWebAppDownloadFile for
// 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.
@@ -998,10 +1001,20 @@
const mime = kind === 'png' ? 'image/png' : 'text/plain';
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
if (insideVK()) {
// Desktop iframe: an ordinary browser, both formats download by anchor. Mobile
// clients take VKWebAppDownloadFile into VK's native share/preview flow; a failed
// bridge call falls back to the anchor.
if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) return;
// 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;
}
+3 -1
View File
@@ -114,7 +114,9 @@ export async function shareUrlAsFile(url: string, filename: string, mime: string
const file = new File([await resp.blob()], filename, { type: mime });
if (nav.canShare({ files: [file] })) {
try {
await nav.share({ files: [file], title: filename });
// No title: iOS pastes it as accompanying text next to the image; the named
// file speaks for itself.
await nav.share({ files: [file] });
} catch {
/* cancelled — intentionally a no-op */
}
+15
View File
@@ -155,6 +155,21 @@ export async function vkShare(link: string): Promise<boolean> {
}
}
/**
* vkShowImages opens VK's native image viewer on url — on the Android client this is the
* PNG delivery: the viewer's own "save" works there, while VKWebAppDownloadFile hangs
* indefinitely (on-device finding). Resolves false on any failure, 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