fix(ui): copy GCG to clipboard on Android in-app WebViews
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s

On-device diagnostics from Android Telegram and VK confirmed both expose
no navigator.share AND no navigator.canShare, so the export fell to a Blob
<a download> that those WebViews silently ignore — nothing happened.

pickGcgDelivery is now a 3-way decision: Web Share where available (iOS),
a clipboard copy in an Android in-app WebView (Telegram/VK: no share, dead
download), else a desktop Blob download. shareOrDownloadGcg reports the
outcome so the game shows a "GCG copied" toast; the copy is VKWebAppCopyText
inside VK (which also covers the desktop VK iframe, where navigator.clipboard
is blocked) and navigator.clipboard otherwise.

Unit tests cover the 3-way choice and the copy/failed outcomes; new i18n key
game.gcgCopied (en+ru); docs ARCHITECTURE/FUNCTIONAL(+ru)/UI_DESIGN/TESTING.
This commit is contained in:
Ilia Denisov
2026-07-01 13:17:00 +02:00
parent adf7c55695
commit 88b6761e28
10 changed files with 109 additions and 35 deletions
+42 -15
View File
@@ -1,14 +1,26 @@
// GCG export delivery: share on mobile (Web Share API with a file) where supported,
// otherwise download via a Blob + <a download> 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 <a download>, 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<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 (
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<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 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
// <a download> 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 <a download>
// 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 <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);
return 'downloaded';
}
function downloadFile(content: string, filename: string): void {