diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7ead448..a212635 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 76e006b..2ab09fa 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c38bce7..2857b63 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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 откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index a48c3b4..5078d9f 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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. diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 6b90ab6..2268457 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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; } diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts index 0622119..a74f49f 100644 --- a/ui/src/lib/share.ts +++ b/ui/src/lib/share.ts @@ -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 */ } diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 05f9053..aa236ff 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -155,6 +155,21 @@ export async function vkShare(link: string): Promise { } } +/** + * 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 { + 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 . Resolves false