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); }