// 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 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' && typeof nav.share === 'function' && nav.canShare({ files: [file] }) ) { return 'share'; } return inAppWebView ? 'copy' : 'download'; } /** * 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; 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). try { await nav.share({ files: [file], title: gcg.filename }); } catch { /* cancelled or failed — intentionally a no-op (see above) */ } 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: Blob | string, filename: string, type = 'application/x-gcg'): void { if (typeof document === 'undefined') return; const blob = typeof content === 'string' ? new Blob([content], { type }) : content; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } /** * downloadUrl saves a same-origin signed export URL as a file through a temporary * anchor: the server names it via Content-Disposition, the download attribute is the * same-origin hint. This is the browser leg of the unified export delivery; the in-app * WebViews never reach it (they use the platform's native download call instead), so * the iOS blob-URL navigation trap does not apply — this is a real https URL. */ export function downloadUrl(url: string, filename: string): void { if (typeof document === 'undefined') return; const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); } /** * shareUrlAsFile delivers a signed export URL through the OS share sheet where files can * be shared (mobile browsers): it fetches the same-origin bytes, wraps them in a File and * calls navigator.share — the sheet opens directly, nothing lands in Downloads first. The * fetch-then-share chain keeps the click's user activation (the proven GCG pattern). * Where file sharing is absent (desktop) it falls back to the plain anchor download. A * cancelled share is a no-op by the shareOrDownloadGcg invariant. */ export async function shareUrlAsFile(url: string, filename: string, mime: string): Promise<'shared' | 'downloaded' | 'failed'> { const nav = typeof navigator !== 'undefined' ? navigator : undefined; if (nav && typeof nav.share === 'function' && typeof nav.canShare === 'function') { try { const resp = await fetch(url); if (!resp.ok) return 'failed'; const file = new File([await resp.blob()], filename, { type: mime }); if (nav.canShare({ files: [file] })) { try { // No title: iOS pastes it as accompanying text next to the image; the named // file speaks for itself. await nav.share({ files: [file] }); } catch { /* cancelled — intentionally a no-op */ } return 'shared'; } } catch { return 'failed'; } } downloadUrl(url, filename); return 'downloaded'; } type TextShareNav = Pick & { canShare?: Navigator['canShare'] }; /** * pickTextShare decides how to deliver a plain-text payload: through the OS share sheet (Web Share, * available on mobile including the Telegram Mini App) or, on a desktop browser without it, a * clipboard copy. Pure, so it is unit-tested with a mock navigator. */ export function pickTextShare(nav: TextShareNav | undefined): 'share' | 'copy' { if (nav && typeof nav.share === 'function' && (typeof nav.canShare !== 'function' || nav.canShare({ text: 'x' }))) { return 'share'; } return 'copy'; } /** * shareText delivers text through the OS share sheet where supported, else copies it to the * clipboard. It reports the path taken — 'shared', 'copied', or 'failed' (a cancelled share or an * unavailable clipboard) — so the caller can confirm a silent copy to the user. Like * shareOrDownloadGcg it never strands the webview: a cancelled share simply does nothing. */ export async function shareText(text: string, title: string): Promise<'shared' | 'copied' | 'failed'> { const nav = typeof navigator !== 'undefined' ? navigator : undefined; if (!nav) return 'failed'; if (pickTextShare(nav) === 'share') { try { await nav.share({ title, text }); return 'shared'; } catch { return 'failed'; } } try { await nav.clipboard.writeText(text); return 'copied'; } catch { return 'failed'; } }