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
+55
View File
@@ -70,6 +70,7 @@ test('inside Telegram the chooser is the native popup and delivery the native do
WebApp: {
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
initDataUnsafe: {},
platform: 'android',
ready() {},
expand() {},
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');
});
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 }) => {
await page.addInitScript(() => {
// 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 { hintsLeft } from '../lib/hints';
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 { patchLobbyGame } from '../lib/lobbycache';
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 {
BLANK,
@@ -933,25 +933,26 @@
// 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
// the most platform-native affordance:
// Telegram native showPopup chooser → native downloadFile dialog. All-native and
// activation-safe: a popup callback carries no user activation, but the
// whole TG chain is bridge calls, never gesture-gated Web APIs.
// VK our modal chooser (VK has no native one) → the PNG opens in VK's native
// image viewer (VKWebAppShowImages — on screen at once, save/share from the
// viewer's own controls); the GCG goes through VKWebAppDownloadFile; either
// falls back to a plain anchor download when the bridge call fails (the
// desktop iframe is an ordinary browser).
// browsers our modal → the OS share sheet with the fetched file where files can be
// shared (mobile), else a plain anchor download (desktop).
// the best affordance each platform actually has (every branch owner-verified on-device):
// TG Android/desktop native showPopup chooser → native downloadFile dialog (whose own
// preview shares onwards). All bridge calls — activation-safe.
// 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
// both formats — the client lands in its native share/preview flow.
// 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.
let exportOpen = $state(false);
// The only platform without the URL delivery is a legacy Telegram client predating
// Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and
// the image option is not offered (and the chooser stays our modal).
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile());
// TG iOS shares via the OS sheet regardless of Bot API 8.0; elsewhere in Telegram a
// legacy client without downloadFile keeps the old GCG clipboard copy and hides the
// image option (and the chooser stays our modal).
const tgShareSheet = $derived(insideTelegram() && telegramPlatform() === 'ios');
const exportImageAvailable = $derived(!insideTelegram() || tgShareSheet || telegramCanDownloadFile());
async function onExportClick() {
if (insideTelegram() && telegramCanDownloadFile() && telegramDialogsAvailable()) {
if (insideTelegram() && !tgShareSheet && telegramCanDownloadFile() && telegramDialogsAvailable()) {
const choice = await telegramShowPopup({
message: t('game.exportChoice'),
buttons: [
@@ -984,7 +985,7 @@
// the URL so the server-rendered image matches what the player would have seen locally.
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
async function exportArtifact(kind: 'png' | 'gcg') {
if (insideTelegram() && !telegramCanDownloadFile()) {
if (insideTelegram() && !tgShareSheet && !telegramCanDownloadFile()) {
void exportGcgLegacy();
return;
}
@@ -994,14 +995,19 @@
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
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 (kind === 'png' && (await vkShowImages(url))) return;
if (kind === 'gcg' && (await vkDownloadFile(url, filename))) return;
// 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;
downloadUrl(url, filename);
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');
} catch (e) {
handleError(e);
+6
View File
@@ -387,6 +387,12 @@ export function telegramCanDownloadFile(): boolean {
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
* 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) —
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false