diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9c0fbdd..153de12 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -776,7 +776,9 @@ pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchanges), plus `#note` lines for resignations and timeouts, which the standard does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive` otherwise), so an in-progress journal is never leaked mid-play; the client -shares the `.gcg` file via the Web Share API where available, else downloads it. +shares the `.gcg` file via the Web Share API where available; an Android in-app WebView +(Telegram / VK) has no Web Share and silently ignores an ``, so there it copies the GCG +text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file. The alphabet-on-the-wire transport does **not** touch this invariant: the live edge exchanges alphabet indices, but the persisted journal (and everything derived from it — diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 6d3d363..39c24b0 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -313,7 +313,8 @@ Finished games are archived in a dictionary-independent form and exportable to GCG; 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 client shares the `.gcg` file where the platform supports -it, otherwise downloads it. Statistics (durable accounts only): +it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file +download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. 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 36ed240..f94746d 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -321,8 +321,9 @@ Telegram. Завершённые партии архивируются в независимом от словаря виде и экспортируются в GCG; экспорт доступен **только после завершения партии** и никогда — для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия -с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает, -иначе скачивает его. Статистика (только у постоянных аккаунтов): +с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает; +в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG +в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). Также показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и diff --git a/docs/TESTING.md b/docs/TESTING.md index 71e1b78..8d08b35 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -17,7 +17,7 @@ tests or touching CI. - **UI** — Vitest (unit) + Playwright (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers the FlatBuffers codecs (friend list, invitation, stats), the win-rate - derivation and the GCG share/download choice, plus Playwright specs against the + derivation and the GCG share/copy/download choice, plus Playwright specs against the mock for the friends screen (code issue/redeem, accept a request), the lobby invitations section, the stats screen, profile editing, and the GCG export's finished-only visibility. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 2aabb33..d2afb30 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -402,7 +402,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t Safari. - **History / GCG**: 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); *Export GCG* (the 📤 in the history - header) shares or downloads the `.gcg` file and appears only once the game is finished — and + header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app + WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download — + and appears only once the game is finished — and never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: it closes the history drawer (portrait) and zooms the board out. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f413664..4b03091 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -21,6 +21,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft } from '../lib/hints'; import { shareOrDownloadGcg } from '../lib/share'; + import { insideVK, vkCopyText } 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'; @@ -838,12 +839,28 @@ async function exportGcg() { try { - await shareOrDownloadGcg(await gateway.exportGcg(id)); + const gcg = await gateway.exportGcg(id); + const outcome = await shareOrDownloadGcg(gcg, insideTelegram() || insideVK(), copyGcgText); + if (outcome === 'copied') showToast(t('game.gcgCopied'), 'info'); + else if (outcome === 'failed') showToast(t('error.generic'), 'error'); } catch (e) { handleError(e); } } + // The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead + // ): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where + // navigator.clipboard is blocked — and navigator.clipboard elsewhere. + async function copyGcgText(text: string): Promise { + if (insideVK()) return vkCopyText(text); + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } + } + // --- move history: open by tapping the score bar, close by tapping or swiping up the board --- // The boardwrap surface drives two gestures, selected by `historyOpen`: // - open: the slid board is inert (CSS pointer-events), so the whole board reads as a diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9e36144..5c8ff2e 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -310,6 +310,7 @@ export const en = { 'game.exportGcg': 'Export GCG', 'game.gcgActiveOnly': 'Available once the game is finished.', + 'game.gcgCopied': 'GCG copied to the clipboard.', 'game.addFriendShort': 'Add friend?', 'game.blockShort': 'Block?', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 0ce61b4..b2aceca 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -310,6 +310,7 @@ export const ru: Record = { 'game.exportGcg': 'Экспорт GCG', 'game.gcgActiveOnly': 'Доступно после завершения игры.', + 'game.gcgCopied': 'GCG скопирован в буфер обмена.', 'game.addFriendShort': 'В друзья?', 'game.blockShort': 'В бан?', diff --git a/ui/src/lib/share.test.ts b/ui/src/lib/share.test.ts index 34fae16..3657257 100644 --- a/ui/src/lib/share.test.ts +++ b/ui/src/lib/share.test.ts @@ -7,26 +7,31 @@ const file = {} as File; const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' }; describe('pickGcgDelivery', () => { - it('shares when the platform can share files', () => { - expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share'); + const canShareNav = { canShare: () => true, share: async () => {} }; + const noShareNav = { canShare: () => false, share: async () => {} }; + + it('shares when the platform can share files, even in an in-app webview (iOS)', () => { + expect(pickGcgDelivery(canShareNav, file, false)).toBe('share'); + expect(pickGcgDelivery(canShareNav, file, true)).toBe('share'); }); - it('downloads when the platform cannot share files', () => { - expect(pickGcgDelivery({ canShare: () => false, share: async () => {} }, file)).toBe('download'); + it('copies in an in-app webview that cannot share files (Android Telegram/VK: no share, dead download)', () => { + expect(pickGcgDelivery(noShareNav, file, true)).toBe('copy'); + expect(pickGcgDelivery(undefined, file, true)).toBe('copy'); + expect(pickGcgDelivery({ canShare: () => true } as never, file, true)).toBe('copy'); }); - it('downloads when there is no navigator', () => { - expect(pickGcgDelivery(undefined, file)).toBe('download'); - }); - - it('downloads when the Web Share API is incomplete', () => { - expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download'); + it('downloads on a plain browser without Web Share (desktop)', () => { + expect(pickGcgDelivery(noShareNav, file, false)).toBe('download'); + expect(pickGcgDelivery(undefined, file, false)).toBe('download'); }); }); describe('shareOrDownloadGcg', () => { afterEach(() => vi.unstubAllGlobals()); + const noCopy = async () => false; + function stubDownloadEnv(canShare: boolean, share: () => Promise) { const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() }; const createElement = vi.fn(() => anchor); @@ -45,20 +50,37 @@ describe('shareOrDownloadGcg', () => { const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError')); const { createElement } = stubDownloadEnv(true, share); - await shareOrDownloadGcg(gcg); + expect(await shareOrDownloadGcg(gcg, false, noCopy)).toBe('shared'); expect(share).toHaveBeenCalledOnce(); expect(createElement).not.toHaveBeenCalled(); // no download anchor → no webview navigation }); - it('downloads via an anchor when the platform cannot share files', async () => { + it('downloads via an anchor on a desktop browser that cannot share files', async () => { const { anchor, createElement } = stubDownloadEnv(false, vi.fn()); - await shareOrDownloadGcg(gcg); + expect(await shareOrDownloadGcg(gcg, false, noCopy)).toBe('downloaded'); expect(createElement).toHaveBeenCalledWith('a'); expect(anchor.click).toHaveBeenCalledOnce(); }); + + it('copies to the clipboard in an in-app webview that cannot share files (no dead Blob download)', async () => { + // Android Telegram/VK expose no Web Share AND ignore , so the export must copy the + // GCG text instead of silently issuing an anchor click that does nothing. + const copy = vi.fn().mockResolvedValue(true); + const { createElement } = stubDownloadEnv(false, vi.fn()); + + expect(await shareOrDownloadGcg(gcg, true, copy)).toBe('copied'); + + expect(copy).toHaveBeenCalledWith(gcg.content); + expect(createElement).not.toHaveBeenCalled(); + }); + + it('reports failure when the in-app clipboard copy fails', async () => { + stubDownloadEnv(false, vi.fn()); + expect(await shareOrDownloadGcg(gcg, true, async () => false)).toBe('failed'); + }); }); describe('pickTextShare', () => { diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts index 4b4528d..6399444 100644 --- a/ui/src/lib/share.ts +++ b/ui/src/lib/share.ts @@ -1,14 +1,26 @@ -// GCG export delivery: share on mobile (Web Share API with a file) where supported, -// otherwise download via a Blob + on desktop. The Capacitor-native file -// save lands with the native wrapper; the Web Share path already covers mobile -// browsers. pickGcgDelivery is the pure decision, unit-tested with a mock navigator. +// GCG export delivery: Web Share (with the file) on mobile where supported — including the iOS +// Telegram Mini App. An Android in-app WebView (Telegram / VK) has NO Web Share and silently ignores +// an , so there the tiny GCG text is copied to the clipboard instead; a plain desktop +// browser, where the anchor download works, still downloads a Blob. The Capacitor-native file save +// lands with the native wrapper. pickGcgDelivery is the pure decision, unit-tested with a mock +// navigator; the caller supplies the platform-aware clipboard copy. import type { GcgExport } from './model'; type ShareNav = Pick; -/** pickGcgDelivery decides between the Web Share API and a Blob download for a file. */ -export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' { +/** + * pickGcgDelivery decides how to deliver the GCG file. Web Share (with the file) wins wherever it is + * available — the iOS Telegram Mini App and mobile browsers. Failing that, an in-app WebView (Android + * Telegram / VK) has no Web Share and silently ignores an , so the tiny GCG text is copied + * to the clipboard ('copy'); only a plain desktop browser, where the anchor download works, falls + * through to 'download'. Pure, so it is unit-tested with a mock navigator. + */ +export function pickGcgDelivery( + nav: ShareNav | undefined, + file: File, + inAppWebView: boolean, +): 'share' | 'copy' | 'download' { if ( nav && typeof nav.canShare === 'function' && @@ -17,27 +29,42 @@ export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' ) { return 'share'; } - return 'download'; + return inAppWebView ? 'copy' : 'download'; } -/** shareOrDownloadGcg shares the GCG file where supported, else triggers a download. */ -export async function shareOrDownloadGcg(gcg: GcgExport): Promise { +/** + * shareOrDownloadGcg delivers the GCG export by the best available route and reports which it took — + * 'shared', 'copied', 'downloaded' or 'failed' — so the caller can confirm a silent copy to the user. + * inAppWebView marks an Android Telegram/VK WebView (no Web Share, a dead ); copyText is + * the platform-aware clipboard write (VKWebAppCopyText inside VK, navigator.clipboard otherwise). + */ +export async function shareOrDownloadGcg( + gcg: GcgExport, + inAppWebView: boolean, + copyText: (text: string) => Promise, +): Promise<'shared' | 'copied' | 'downloaded' | 'failed'> { const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' }); const nav = typeof navigator !== 'undefined' ? navigator : undefined; - if (pickGcgDelivery(nav, file) === 'share' && nav) { + const decision = pickGcgDelivery(nav, file, inAppWebView); + if (decision === 'share' && nav) { // Web Share is available (mobile, including the iOS Telegram Mini App): use it and stop here - // whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an - // navigates the webview to the blob: URL, replacing the SPA with the raw file - // and stranding the app, so a cancelled or failed share must simply do nothing (the user can - // retry). The download path is only for desktop browsers without Web Share. + // whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an + // navigates the webview to the blob: URL, replacing the SPA with the raw file and stranding the + // app, so a cancelled or failed share must simply do nothing (the user can retry). try { await nav.share({ files: [file], title: gcg.filename }); } catch { /* cancelled or failed — intentionally a no-op (see above) */ } - return; + return 'shared'; + } + if (decision === 'copy') { + // Android Telegram/VK: no Web Share and a dead , so copy the GCG text instead of + // silently issuing an anchor click that saves nothing. + return (await copyText(gcg.content)) ? 'copied' : 'failed'; } downloadFile(gcg.content, gcg.filename); + return 'downloaded'; } function downloadFile(content: string, filename: string): void {