d5fbaa3034
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README.
171 lines
7.1 KiB
TypeScript
171 lines
7.1 KiB
TypeScript
// 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 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' &&
|
|
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 <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;
|
|
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).
|
|
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 <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: 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<Navigator, 'share'> & { 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';
|
|
}
|
|
}
|