Merge pull request 'fix(ui): copy GCG to clipboard on Android in-app WebViews' (#151) from feature/gcg-export-android-fix into development
This commit was merged in pull request #151.
This commit is contained in:
@@ -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
|
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`
|
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
|
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 `<a download>`, 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
|
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 —
|
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||||
|
|||||||
+2
-1
@@ -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
|
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
|
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
|
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
|
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
|
||||||
|
|||||||
@@ -321,8 +321,9 @@ Telegram.
|
|||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
||||||
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
||||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
|
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
|
||||||
иначе скачивает его. Статистика (только у постоянных аккаунтов):
|
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
|
||||||
|
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
|
||||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ tests or touching CI.
|
|||||||
- **UI** — Vitest (unit) + Playwright
|
- **UI** — Vitest (unit) + Playwright
|
||||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
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
|
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
|
invitations section, the stats screen, profile editing, and the GCG export's
|
||||||
finished-only visibility.
|
finished-only visibility.
|
||||||
|
|||||||
+3
-1
@@ -402,7 +402,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
Safari.
|
Safari.
|
||||||
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
|
- **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
|
(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:
|
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.
|
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
|
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||||
|
|||||||
+18
-1
@@ -21,6 +21,7 @@
|
|||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft } from '../lib/hints';
|
import { hintsLeft } from '../lib/hints';
|
||||||
import { shareOrDownloadGcg } from '../lib/share';
|
import { shareOrDownloadGcg } from '../lib/share';
|
||||||
|
import { insideVK, vkCopyText } 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';
|
||||||
@@ -838,12 +839,28 @@
|
|||||||
|
|
||||||
async function exportGcg() {
|
async function exportGcg() {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead
|
||||||
|
// <a download>): 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<boolean> {
|
||||||
|
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 ---
|
// --- 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`:
|
// 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
|
// - open: the slid board is inert (CSS pointer-events), so the whole board reads as a
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export const en = {
|
|||||||
|
|
||||||
'game.exportGcg': 'Export GCG',
|
'game.exportGcg': 'Export GCG',
|
||||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||||
|
'game.gcgCopied': 'GCG copied to the clipboard.',
|
||||||
'game.addFriendShort': 'Add friend?',
|
'game.addFriendShort': 'Add friend?',
|
||||||
'game.blockShort': 'Block?',
|
'game.blockShort': 'Block?',
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
|
|
||||||
'game.exportGcg': 'Экспорт GCG',
|
'game.exportGcg': 'Экспорт GCG',
|
||||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||||
|
'game.gcgCopied': 'GCG скопирован в буфер обмена.',
|
||||||
'game.addFriendShort': 'В друзья?',
|
'game.addFriendShort': 'В друзья?',
|
||||||
'game.blockShort': 'В бан?',
|
'game.blockShort': 'В бан?',
|
||||||
|
|
||||||
|
|||||||
+35
-13
@@ -7,26 +7,31 @@ const file = {} as File;
|
|||||||
const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' };
|
const gcg: GcgExport = { gameId: 'g1', filename: 'game.gcg', content: '#title game' };
|
||||||
|
|
||||||
describe('pickGcgDelivery', () => {
|
describe('pickGcgDelivery', () => {
|
||||||
it('shares when the platform can share files', () => {
|
const canShareNav = { canShare: () => true, share: async () => {} };
|
||||||
expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share');
|
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', () => {
|
it('copies in an in-app webview that cannot share files (Android Telegram/VK: no share, dead download)', () => {
|
||||||
expect(pickGcgDelivery({ canShare: () => false, share: async () => {} }, file)).toBe('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', () => {
|
it('downloads on a plain browser without Web Share (desktop)', () => {
|
||||||
expect(pickGcgDelivery(undefined, file)).toBe('download');
|
expect(pickGcgDelivery(noShareNav, file, false)).toBe('download');
|
||||||
});
|
expect(pickGcgDelivery(undefined, file, false)).toBe('download');
|
||||||
|
|
||||||
it('downloads when the Web Share API is incomplete', () => {
|
|
||||||
expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shareOrDownloadGcg', () => {
|
describe('shareOrDownloadGcg', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
const noCopy = async () => false;
|
||||||
|
|
||||||
function stubDownloadEnv(canShare: boolean, share: () => Promise<void>) {
|
function stubDownloadEnv(canShare: boolean, share: () => Promise<void>) {
|
||||||
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||||
const createElement = vi.fn(() => anchor);
|
const createElement = vi.fn(() => anchor);
|
||||||
@@ -45,20 +50,37 @@ describe('shareOrDownloadGcg', () => {
|
|||||||
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
||||||
const { createElement } = stubDownloadEnv(true, share);
|
const { createElement } = stubDownloadEnv(true, share);
|
||||||
|
|
||||||
await shareOrDownloadGcg(gcg);
|
expect(await shareOrDownloadGcg(gcg, false, noCopy)).toBe('shared');
|
||||||
|
|
||||||
expect(share).toHaveBeenCalledOnce();
|
expect(share).toHaveBeenCalledOnce();
|
||||||
expect(createElement).not.toHaveBeenCalled(); // no download anchor → no webview navigation
|
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());
|
const { anchor, createElement } = stubDownloadEnv(false, vi.fn());
|
||||||
|
|
||||||
await shareOrDownloadGcg(gcg);
|
expect(await shareOrDownloadGcg(gcg, false, noCopy)).toBe('downloaded');
|
||||||
|
|
||||||
expect(createElement).toHaveBeenCalledWith('a');
|
expect(createElement).toHaveBeenCalledWith('a');
|
||||||
expect(anchor.click).toHaveBeenCalledOnce();
|
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 <a download>, 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', () => {
|
describe('pickTextShare', () => {
|
||||||
|
|||||||
+42
-15
@@ -1,14 +1,26 @@
|
|||||||
// GCG export delivery: share on mobile (Web Share API with a file) where supported,
|
// GCG export delivery: Web Share (with the file) on mobile where supported — including the iOS
|
||||||
// otherwise download via a Blob + <a download> on desktop. The Capacitor-native file
|
// Telegram Mini App. An Android in-app WebView (Telegram / VK) has NO Web Share and silently ignores
|
||||||
// save lands with the native wrapper; the Web Share path already covers mobile
|
// an <a download>, so there the tiny GCG text is copied to the clipboard instead; a plain desktop
|
||||||
// browsers. pickGcgDelivery is the pure decision, unit-tested with a mock navigator.
|
// 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';
|
import type { GcgExport } from './model';
|
||||||
|
|
||||||
type ShareNav = Pick<Navigator, 'canShare' | 'share'>;
|
type ShareNav = Pick<Navigator, 'canShare' | 'share'>;
|
||||||
|
|
||||||
/** 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 <a download>, 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 (
|
if (
|
||||||
nav &&
|
nav &&
|
||||||
typeof nav.canShare === 'function' &&
|
typeof nav.canShare === 'function' &&
|
||||||
@@ -17,27 +29,42 @@ export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share'
|
|||||||
) {
|
) {
|
||||||
return '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<void> {
|
* 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 <a download>); 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<boolean>,
|
||||||
|
): Promise<'shared' | 'copied' | 'downloaded' | 'failed'> {
|
||||||
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
|
const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' });
|
||||||
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
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
|
// 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
|
// whatever the outcome. Do NOT fall back to the Blob download — on iOS WKWebView an <a download>
|
||||||
// <a download> navigates the webview to the blob: URL, replacing the SPA with the raw file
|
// navigates the webview to the blob: URL, replacing the SPA with the raw file and stranding the
|
||||||
// and stranding the app, so a cancelled or failed share must simply do nothing (the user can
|
// app, so a cancelled or failed share must simply do nothing (the user can retry).
|
||||||
// retry). The download path is only for desktop browsers without Web Share.
|
|
||||||
try {
|
try {
|
||||||
await nav.share({ files: [file], title: gcg.filename });
|
await nav.share({ files: [file], title: gcg.filename });
|
||||||
} catch {
|
} catch {
|
||||||
/* cancelled or failed — intentionally a no-op (see above) */
|
/* cancelled or failed — intentionally a no-op (see above) */
|
||||||
}
|
}
|
||||||
return;
|
return 'shared';
|
||||||
|
}
|
||||||
|
if (decision === 'copy') {
|
||||||
|
// Android Telegram/VK: no Web Share and a dead <a download>, 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);
|
downloadFile(gcg.content, gcg.filename);
|
||||||
|
return 'downloaded';
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadFile(content: string, filename: string): void {
|
function downloadFile(content: string, filename: string): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user