diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5ca4a7..7ead448 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -794,14 +794,15 @@ platform: the client calls the authenticated `game.export_url` op, the backend m **relative, HMAC-signed, short-lived path** (`/dl/{game}/{kind}?e=&…&s=`, 10-minute TTL, `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 -native affordance: Telegram `downloadFile` (Bot API 8.0; the chooser there is the -native showPopup — the whole TG chain is activation-free bridge calls), VK's native -photo viewer for the PNG (`VKWebAppShowImages`) + `VKWebAppDownloadFile` for the -GCG, the OS share sheet with the fetched file on a mobile browser, or a plain -anchor download (desktop, and the VK desktop iframe fallback). 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 +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 +`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 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 3ea16ba..76e006b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 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** -inside Telegram (safe there: the whole Telegram chain is bridge calls, which need no -user activation) and the app's own modal elsewhere (VK has no native chooser). +on Telegram Android/desktop (safe there: that chain is all bridge calls, which need +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 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. 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 -platforms; sharing onwards from Telegram's own file preview); on VK the image opens -in **VK's native photo viewer** (on screen at once — saving/sharing are the -viewer's own controls) and the GCG goes through VK's download; a **mobile browser -gets the OS share sheet** with the file (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 client without the download dialog (pre-Bot API 8.0): there -the GCG falls back to the old clipboard copy (with the confirming toast) and the -image option is not offered. Statistics (durable accounts only): +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 +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 +client without the download dialog (pre-Bot API 8.0): there the GCG falls back to +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 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 3b392a3..c38bce7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -331,10 +331,11 @@ Telegram. **двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** финальной позиции; экспорт доступен **только после завершения партии** и никогда — для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а -партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** внутри -Telegram (там это безопасно: вся телеграмовская цепочка — bridge-вызовы, которым -user activation не нужна) и собственный модал приложения в остальных случаях (у VK -нативного чузера нет). +партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** на +Telegram Android/desktop (там это безопасно: цепочка целиком из bridge-вызовов, +которым user activation не нужна) и собственный модал приложения в остальных +случаях — на Telegram iOS доставка идёт через системную share-шторку, которую из +коллбека нативного попапа не открыть, а у VK нативного чузера нет. **Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная @@ -351,15 +352,16 @@ user activation не нужна) и собственный модал прило партия растягивает доску (не ниже минимума), так что пустот на картинке нет. Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой -платформе**, отданная самому родному механизму: диалог загрузки Telegram (обе -платформы TG; поделиться дальше — из телеграмовского превью файла); в VK картинка -открывается в **нативном просмотрщике фото VK** (сразу на экране — сохранение и -шаринг штатными кнопками просмотрщика), а GCG идёт через загрузку VK; **мобильный -браузер получает системную share-шторку** с файлом (ничего не оседает в Загрузках); -десктопный браузер скачивает файл. Ссылка не требует логина при скачивании (родные -загрузчики платформ его не несут) и живёт минуты. Единственное исключение — -устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там GCG -откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт +платформе**, отданная лучшему механизму, который у платформы реально есть (каждая +ветка проверена на устройстве): Telegram Android/desktop — диалог загрузки Telegram +(из его превью файл шерится дальше); **Telegram iOS — системная share-шторка** со +скачанным файлом; в VK оба формата идут через загрузку VK в её нативный +share/preview-поток (десктопный iframe VK — обычный браузер — скачивает файлы); +**мобильный браузер получает системную share-шторку** (ничего не оседает в +Загрузках); десктопный браузер скачивает файл. Ссылка не требует логина при +скачивании (родные загрузчики платформ его не несут) и живёт минуты. Единственное +исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там +GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт «картинка» не предлагается. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d00b2d4..a48c3b4 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 (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 - opens the **format chooser**: Telegram's native popup inside Telegram — safe there, - since the whole TG chain is bridge calls needing no user activation (a popup callback + opens the **format chooser**: Telegram's native popup on TG Android/desktop — safe + 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 - 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 - (`game.export_url`), resolved against the app's own origin, then delivered natively: - Telegram `downloadFile` (both TG platforms); on VK the PNG opens in the native photo - viewer (`VKWebAppShowImages`, save/share from its controls) and the GCG goes through - `VKWebAppDownloadFile`, both falling back to a plain anchor download (the desktop - iframe); a mobile browser fetches the bytes and opens the OS share sheet - (`navigator.share` with the file — the proven fetch-then-share pattern), a desktop - browser downloads by anchor. 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. + (`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 + 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. - **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 mirroring `app.css`), the final board with classic axes A..O / 1..15, premium squares as diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts index 8d9946d..54eb524 100644 --- a/ui/e2e/export.spec.ts +++ b/ui/e2e/export.spec.ts @@ -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 diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index cc2fec7..6b90ab6 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 493f9e0..e9ad334 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -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 diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 4778275..05f9053 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -155,20 +155,6 @@ export async function vkShare(link: string): Promise { } } -/** - * 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 { - 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