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 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), 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 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 the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
both formats, the OS share sheet on a mobile browser, or a plain anchor download formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
(desktop browsers and the VK desktop iframe). The gateway serves the bytes via 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 `http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
hangs on chunked bodies of unknown length). The GET is the gateway's 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 **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**, 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): 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 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 onwards); **Telegram iOS opens the OS share sheet** with the fetched file; VK iOS
both formats go through VK's download into its native share/preview flow (the takes both formats through VK's download into its native share flow, while on the
desktop VK iframe, an ordinary browser, downloads them); a **mobile browser gets **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 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 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 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 Android/desktop — диалог загрузки Telegram
(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со (из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
скачанным файлом; в VK оба формата идут через загрузку VK в её нативный скачанным файлом; VK iOS ведёт оба формата через загрузку VK в её нативный
share/preview-поток (десктопный iframe VK — обычный браузерскачивает файлы); share-поток, а на **VK Android** — чей загрузчик виснет — картинка открывается в
**мобильный браузер получает системную share-шторку** (ничего не оседает в нативном просмотрщике фото VK (сохранение его кнопками), GCG копируется в буфер
Загрузках); десктопный браузер скачивает файл. Ссылка не требует логина при (десктопный iframe VK — обычный браузер скачивает оба); **мобильный браузер
получает системную share-шторку** (ничего не оседает в Загрузках); десктопный
браузер скачивает файл. Ссылка не требует логина при
скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт 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 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 (`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 (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 shares onwards); TG iOS the OS share sheet with the fetched file; VK iOS
`VKWebAppDownloadFile` for both formats (VK's native share/preview flow), the VK desktop `VKWebAppDownloadFile` for both formats (VK's native share flow); VK Android — whose
iframe plain anchor downloads; a mobile browser the OS share sheet (the proven DownloadFile hangs — the PNG in VK's native image viewer (`VKWebAppShowImages`, its save
fetch-then-share pattern); a desktop browser an anchor download. A legacy Telegram 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 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 and stays on the app modal. Confirming a resign reveals the full board: it closes the
history drawer (portrait) and zooms the board out. history drawer (portrait) and zooms the board out.
+19 -6
View File
@@ -23,7 +23,7 @@
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints'; import { hintsLeft } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; 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 { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
@@ -939,8 +939,11 @@
// TG iOS our modal chooser → the OS share sheet (WKWebView has // TG iOS our modal chooser → the OS share sheet (WKWebView has
// navigator.share; a native-popup callback could not open it — no // navigator.share; a native-popup callback could not open it — no
// user activation — so the chooser stays the app modal here). // 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. // 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). // VK desktop iframe our modal → plain anchor downloads (an ordinary browser).
// mobile browsers our modal → the OS share sheet with the fetched file. // mobile browsers our modal → the OS share sheet with the fetched file.
// desktop browsers our modal → plain anchor download. // desktop browsers our modal → plain anchor download.
@@ -998,10 +1001,20 @@
const mime = kind === 'png' ? 'image/png' : 'text/plain'; const mime = kind === 'png' ? 'image/png' : 'text/plain';
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return; if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
if (insideVK()) { if (insideVK()) {
// Desktop iframe: an ordinary browser, both formats download by anchor. Mobile // Per-VK-client delivery (each branch owner-verified): the iOS client's
// clients take VKWebAppDownloadFile into VK's native share/preview flow; a failed // VKWebAppDownloadFile lands in a native share flow and is perfect; the ANDROID
// bridge call falls back to the anchor. // client's download hangs indefinitely, so the PNG opens in VK's native image
if (!vkPlatform().startsWith('desktop') && (await vkDownloadFile(url, filename))) return; // 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); downloadUrl(url, filename);
return; 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 }); const file = new File([await resp.blob()], filename, { type: mime });
if (nav.canShare({ files: [file] })) { if (nav.canShare({ files: [file] })) {
try { 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 { } catch {
/* cancelled — intentionally a no-op */ /* 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) — * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false * the mobile in-app file delivery, where the webview ignores <a download>. Resolves false