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** **relative, HMAC-signed, short-lived path**
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL, (`/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 `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 service ever needs to know the public host) and hands it to the best affordance
native affordance: Telegram `downloadFile` (Bot API 8.0; the chooser there is the each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
native showPopup — the whole TG chain is activation-free bridge calls), VK's native chooser there is the native showPopup — activation-free bridge calls end to end),
photo viewer for the PNG (`VKWebAppShowImages`) + `VKWebAppDownloadFile` for the Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
GCG, the OS share sheet with the fetched file on a mobile browser, or a plain the user activation a popup callback lacks), VK mobile `VKWebAppDownloadFile` for
anchor download (desktop, and the VK desktop iframe fallback). The gateway serves both formats, the OS share sheet on a mobile browser, or a plain anchor download
the bytes via `http.ServeContent` (Content-Length + Range/206 — Android's system (desktop browsers and the VK desktop iframe). The gateway serves the bytes via
DownloadManager hangs on chunked bodies of unknown length). The GET is the gateway's `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 **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 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 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 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 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** 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 on Telegram Android/desktop (safe there: that chain is all bridge calls, which need
user activation) and the app's own modal elsewhere (VK has no native chooser). 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 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 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. 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 most native affordance: Telegram's download dialog (both TG handed to the best affordance each platform has (each branch verified on-device):
platforms; sharing onwards from Telegram's own file preview); on VK the image opens Telegram Android/desktop use Telegram's download dialog (whose own preview shares
in **VK's native photo viewer** (on screen at once — saving/sharing are the onwards); **Telegram iOS opens the OS share sheet** with the fetched file; on VK
viewer's own controls) and the GCG goes through VK's download; a **mobile browser both formats go through VK's download into its native share/preview flow (the
gets the OS share sheet** with the file (nothing lands in Downloads first); a desktop VK iframe, an ordinary browser, downloads them); a **mobile browser gets
desktop browser downloads the file. The link needs no login to fetch (the the OS share sheet** (nothing lands in Downloads first); a desktop browser
platforms' downloaders carry none) and is valid for minutes. The single exception downloads the file. The link needs no login to fetch (the platforms' downloaders
is a legacy Telegram client without the download dialog (pre-Bot API 8.0): there carry none) and is valid for minutes. The single exception is a legacy Telegram
the GCG falls back to the old clipboard copy (with the confirming toast) and the client without the download dialog (pre-Bot API 8.0): there the GCG falls back to
image option is not offered. Statistics (durable accounts only): 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 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 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 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-картинка** **двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда — финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** внутри партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на
Telegram (там это безопасно: вся телеграмовская цепочка — bridge-вызовы, которым Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов,
user activation не нужна) и собственный модал приложения в остальных случаях (у VK которым user activation не нужна) и собственный модал приложения в остальных
нативного чузера нет). случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из
коллбека нативного попапа не открыть, а у VK нативного чузера нет.
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же **Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
@@ -351,15 +352,16 @@ user activation не нужна) и собственный модал прило
партия растягивает доску (не ниже минимума), так что пустот на картинке нет. партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
платформе**, отданная самому родному механизму: диалог загрузки Telegram (обе платформе**, отданная лучшему механизму, который у платформы реально есть (каждая
платформы TG; поделиться дальше — из телеграмовского превью файла); в VK картинка ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram
открывается в **нативном просмотрщике фото VK** (сразу на экране — сохранение и (из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со
шаринг штатными кнопками просмотрщика), а GCG идёт через загрузку VK; **мобильный скачанным файлом; в VK оба формата идут через загрузку VK в её нативный
браузер получает системную share-шторку** с файлом (ничего не оседает в Загрузках); share/preview-поток (десктопный iframe VK — обычный браузер — скачивает файлы);
десктопный браузер скачивает файл. Ссылка не требует логина при скачивании (родные **мобильный браузер получает системную share-шторку** (ничего не оседает в
загрузчики платформ его не несут) и живёт минуты. Единственное исключение — Загрузках); десктопный браузер скачивает файл. Ссылка не требует логина при
устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там GCG скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное
откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт исключение — устаревший клиент 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 - **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 (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 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, opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe
since the whole TG chain is bridge calls needing no user activation (a popup callback 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 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 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: (`game.export_url`), resolved against the app's own origin, then delivered per platform
Telegram `downloadFile` (both TG platforms); on VK the PNG opens in the native photo (each branch owner-verified on-device): TG Android/desktop `downloadFile` (its preview
viewer (`VKWebAppShowImages`, save/share from its controls) and the GCG goes through shares onwards); TG iOS the OS share sheet with the fetched file; VK mobile
`VKWebAppDownloadFile`, both falling back to a plain anchor download (the desktop `VKWebAppDownloadFile` for both formats (VK's native share/preview flow), the VK desktop
iframe); a mobile browser fetches the bytes and opens the OS share sheet iframe plain anchor downloads; a mobile browser the OS share sheet (the proven
(`navigator.share` with the file — the proven fetch-then-share pattern), a desktop fetch-then-share pattern); a desktop browser an anchor download. A legacy Telegram
browser downloads by anchor. A legacy Telegram client without `downloadFile` keeps the client without `downloadFile` keeps the old GCG clipboard copy, hides the image option
old GCG clipboard copy, hides the image option and stays on the app modal. Confirming a and stays on the app modal. Confirming a resign reveals the full board: it closes the
resign reveals the full board: it closes the history drawer (portrait) and zooms the history drawer (portrait) and zooms the board out.
board out.
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar - **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 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 mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as
+55
View File
@@ -70,6 +70,7 @@ test('inside Telegram the chooser is the native popup and delivery the native do
WebApp: { WebApp: {
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef', initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
initDataUnsafe: {}, initDataUnsafe: {},
platform: 'android',
ready() {}, ready() {},
expand() {}, expand() {},
showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) { showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) {
@@ -142,6 +143,60 @@ test('a mobile browser gets the OS share sheet with the fetched file', async ({
expect(shared.type).toBe('image/png'); expect(shared.type).toBe('image/png');
}); });
test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async ({ page }) => {
// TG iOS: WKWebView has navigator.share, and the sheet is the preferred delivery — but
// it needs the user activation a native-popup callback cannot provide, so the chooser
// stays the app modal on this one platform.
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;
Object.assign(window, {
Telegram: {
WebApp: {
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
initDataUnsafe: {},
platform: 'ios',
ready() {},
expand() {},
showPopup() {
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
},
downloadFile() {
(window as unknown as { __downloadCalled?: boolean }).__downloadCalled = true;
},
},
},
});
});
await routeDl(page);
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible();
await page.getByRole('button', { name: /Rick/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Export game' }).click();
// The app modal opened (both formats) — the native popup was not used…
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toBeVisible();
await page.getByRole('button', { name: 'Image (PNG)' }).click();
// …and the artifact went to the OS share sheet, not TG's download dialog.
await expect
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
.toHaveLength(1);
const flags = await page.evaluate(() => {
const w = window as unknown as { __popupCalled?: boolean; __downloadCalled?: boolean };
return { popup: !!w.__popupCalled, download: !!w.__downloadCalled };
});
expect(flags).toEqual({ popup: false, download: false });
});
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => { test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
// Headless engines deny the real clipboard; a permissive stub keeps the legacy // Headless engines deny the real clipboard; a permissive stub keeps the legacy
+29 -23
View File
@@ -23,11 +23,11 @@
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, vkShowImages } from '../lib/vk'; import { insideVK, vkCopyText, vkDownloadFile, vkPlatform } 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';
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm, telegramShowPopup } from '../lib/telegram'; import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramPlatform, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
import { haptic } from '../lib/haptics'; import { haptic } from '../lib/haptics';
import { import {
BLANK, BLANK,
@@ -933,25 +933,26 @@
// The finished-game export offers two formats — the GCG file and the server-rendered PNG // The finished-game export offers two formats — the GCG file and the server-rendered PNG
// image — behind one 📤 button, both minted as one signed relative URL and delivered by // image — behind one 📤 button, both minted as one signed relative URL and delivered by
// the most platform-native affordance: // the best affordance each platform actually has (every branch owner-verified on-device):
// Telegram native showPopup chooser → native downloadFile dialog. All-native and // TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own
// activation-safe: a popup callback carries no user activation, but the // preview shares onwards). All bridge calls — activation-safe.
// whole TG chain is bridge calls, never gesture-gated Web APIs. // TG iOS our modal chooser → the OS share sheet (WKWebView has
// VK our modal chooser (VK has no native one) → the PNG opens in VK's native // navigator.share; a native-popup callback could not open it — no
// image viewer (VKWebAppShowImages — on screen at once, save/share from the // user activation — so the chooser stays the app modal here).
// viewer's own controls); the GCG goes through VKWebAppDownloadFile; either // VK mobile our modal (VK has no native chooser) → VKWebAppDownloadFile for
// falls back to a plain anchor download when the bridge call fails (the // both formats — the client lands in its native share/preview flow.
// desktop iframe is an ordinary browser). // VK desktop iframe our modal → plain anchor downloads (an ordinary browser).
// browsers our modal → the OS share sheet with the fetched file where files can be // mobile browsers our modal → the OS share sheet with the fetched file.
// shared (mobile), else a plain anchor download (desktop). // desktop browsers our modal → plain anchor download.
let exportOpen = $state(false); let exportOpen = $state(false);
// The only platform without the URL delivery is a legacy Telegram client predating // TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a
// Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and // legacy client without downloadFile keeps the old GCG clipboard copy and hides the
// the image option is not offered (and the chooser stays our modal). // image option (and the chooser stays our modal).
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile()); const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios');
const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile());
async function onExportClick() { async function onExportClick() {
if (insideTelegram() && telegramCanDownloadFile() && telegramDialogsAvailable()) { if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) {
const choice = await telegramShowPopup({ const choice = await telegramShowPopup({
message: t('game.exportChoice'), message: t('game.exportChoice'),
buttons: [ buttons: [
@@ -984,7 +985,7 @@
// the URL so the server-rendered image matches what the player would have seen locally. // the URL so the server-rendered image matches what the player would have seen locally.
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const; const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
async function exportArtifact(kind: 'png' | 'gcg') { async function exportArtifact(kind: 'png' | 'gcg') {
if (insideTelegram() && !telegramCanDownloadFile()) { if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) {
void exportGcgLegacy(); void exportGcgLegacy();
return; return;
} }
@@ -994,14 +995,19 @@
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone); const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
const url = new URL(path, location.origin).href; const url = new URL(path, location.origin).href;
if (insideTelegram() && telegramDownloadFile(url, filename)) return; const mime = kind === 'png' ? 'image/png' : 'text/plain';
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
if (insideVK()) { if (insideVK()) {
if (kind === 'png' && (await vkShowImages(url))) return; // Desktop iframe: an ordinary browser, both formats download by anchor. Mobile
if (kind === 'gcg' && (await vkDownloadFile(url, filename))) return; // 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;
downloadUrl(url, filename); downloadUrl(url, filename);
return; return;
} }
const outcome = await shareUrlAsFile(url, filename, kind === 'png' ? 'image/png' : 'text/plain'); // TG iOS and plain browsers: the OS share sheet with the fetched file (falls back to
// a download where files cannot be shared — the desktop browser).
const outcome = await shareUrlAsFile(url, filename, mime);
if (outcome === 'failed') showToast(t('error.generic'), 'error'); if (outcome === 'failed') showToast(t('error.generic'), 'error');
} catch (e) { } catch (e) {
handleError(e); handleError(e);
+6
View File
@@ -387,6 +387,12 @@ export function telegramCanDownloadFile(): boolean {
return !!webApp()?.downloadFile; return !!webApp()?.downloadFile;
} }
/** telegramPlatform returns the SDK's platform tag ('ios', 'android', 'tdesktop', …) or ''
* outside Telegram — the export delivery branches on it (iOS gets the OS share sheet). */
export function telegramPlatform(): string {
return webApp()?.platform ?? '';
}
/** /**
* telegramDownloadFile asks Telegram to download url as fileName through its native * telegramDownloadFile asks Telegram to download url as fileName through its native
* dialog — the Mini App file delivery that works on every Telegram platform (a webview * dialog — the Mini App file delivery that works on every Telegram platform (a webview
-14
View File
@@ -155,20 +155,6 @@ 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) * 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