From e850ecd83b4070ba76aaeafd5d47cf5e92d1d0c4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 17 Jun 2026 14:01:02 +0200 Subject: [PATCH] fix(ui): don't strand the Mini App on a cancelled GCG share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS WKWebView (the Telegram Mini App), cancelling the Web Share sheet fell through to the Blob fallback. iOS ignores the download attribute, so clicking the anchor navigated the webview to the blob: URL — replacing the SPA with the raw GCG file, with no way back (force-quit only). The share path no longer falls back to a download: Web Share is available on that platform, so a cancelled or failed share is a no-op and the user can retry. The Blob download stays the desktop-only path (no Web Share). --- ui/src/lib/share.test.ts | 44 ++++++++++++++++++++++++++++++++++++++-- ui/src/lib/share.ts | 9 ++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/share.test.ts b/ui/src/lib/share.test.ts index fbb98ff..4551802 100644 --- a/ui/src/lib/share.test.ts +++ b/ui/src/lib/share.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest'; -import { pickGcgDelivery } from './share'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { pickGcgDelivery, shareOrDownloadGcg } from './share'; +import type { GcgExport } from './model'; 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'); @@ -20,3 +23,40 @@ describe('pickGcgDelivery', () => { expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download'); }); }); + +describe('shareOrDownloadGcg', () => { + afterEach(() => vi.unstubAllGlobals()); + + function stubDownloadEnv(canShare: boolean, share: () => Promise) { + const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() }; + const createElement = vi.fn(() => anchor); + vi.stubGlobal('File', class {}); + vi.stubGlobal('Blob', class {}); + vi.stubGlobal('navigator', { canShare: () => canShare, share }); + vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } }); + vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() }); + return { anchor, createElement }; + } + + it('never falls back to the navigating Blob download when a share is cancelled', async () => { + // Reproduces the iOS Telegram Mini App break: Web Share is available and the user cancels it + // (AbortError). The fallback navigates the WKWebView to the blob: URL and strands + // the app, so a cancelled (or failed) share must do nothing here. + const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError')); + const { createElement } = stubDownloadEnv(true, share); + + await shareOrDownloadGcg(gcg); + + 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 () => { + const { anchor, createElement } = stubDownloadEnv(false, vi.fn()); + + await shareOrDownloadGcg(gcg); + + expect(createElement).toHaveBeenCalledWith('a'); + expect(anchor.click).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts index 33587b4..757aef2 100644 --- a/ui/src/lib/share.ts +++ b/ui/src/lib/share.ts @@ -25,12 +25,17 @@ export async function shareOrDownloadGcg(gcg: GcgExport): Promise { 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) { + // 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. try { await nav.share({ files: [file], title: gcg.filename }); - return; } catch { - // The user cancelled or sharing failed — fall back to a download. + /* cancelled or failed — intentionally a no-op (see above) */ } + return; } downloadFile(gcg.content, gcg.filename); }