feat(export): server-rendered artifacts behind one signed download URL (#160)
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
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.
This commit was merged in pull request #160.
This commit is contained in:
@@ -14,6 +14,7 @@ import type {
|
||||
FriendCode,
|
||||
GameList,
|
||||
GameView,
|
||||
ExportUrl,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
@@ -161,6 +162,10 @@ export interface GatewayClient {
|
||||
profileUpdate(p: ProfileUpdate): Promise<Profile>;
|
||||
statsGet(): Promise<Stats>;
|
||||
exportGcg(gameId: string): Promise<GcgExport>;
|
||||
/** Mints a signed download URL for a finished game's artifact. dateLocale and the
|
||||
* localized non-play labels (pass/exchange/resign/timeout, in that order) ride the
|
||||
* URL so the server-rendered image matches the player's presentation. */
|
||||
exportUrl(gameId: string, kind: 'png' | 'gcg', dateLocale: string, actionLabels: string[], timeZone: string): Promise<ExportUrl>;
|
||||
|
||||
// --- account linking & merge ---
|
||||
linkEmailRequest(email: string): Promise<void>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
decodeBlockStatus,
|
||||
decodeDraftView,
|
||||
decodeEvent,
|
||||
decodeExportUrl,
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
encodeDraftSave,
|
||||
encodeEnqueue,
|
||||
encodeExchange,
|
||||
encodeExportUrlRequest,
|
||||
encodeGuestLogin,
|
||||
encodeStateRequest,
|
||||
encodeSubmitPlay,
|
||||
@@ -34,6 +36,30 @@ import {
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
it('round-trips the export-url request and response', () => {
|
||||
const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
|
||||
new ByteBuffer(
|
||||
encodeExportUrlRequest('g-1', 'png', 'ru-RU', ['пас', 'обмен', 'сдался', 'таймаут'], 'Europe/Moscow'),
|
||||
),
|
||||
);
|
||||
expect(req.gameId()).toBe('g-1');
|
||||
expect(req.kind()).toBe('png');
|
||||
expect(req.dateLocale()).toBe('ru-RU');
|
||||
expect(req.timeZone()).toBe('Europe/Moscow');
|
||||
expect(req.actionLabelsLength()).toBe(4);
|
||||
expect(req.actionLabels(1)).toBe('обмен');
|
||||
|
||||
const b = new Builder(128);
|
||||
const path = b.createString('/dl/g-1/png?e=1&s=x');
|
||||
const fn = b.createString('game-g-1.png');
|
||||
fb.ExportUrl.startExportUrl(b);
|
||||
fb.ExportUrl.addPath(b, path);
|
||||
fb.ExportUrl.addFilename(b, fn);
|
||||
b.finish(fb.ExportUrl.endExportUrl(b));
|
||||
const out = decodeExportUrl(b.asUint8Array());
|
||||
expect(out).toEqual({ path: '/dl/g-1/png?e=1&s=x', filename: 'game-g-1.png' });
|
||||
});
|
||||
|
||||
it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
|
||||
for (const vsAi of [false, true]) {
|
||||
const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
EvalResult,
|
||||
FeedbackState,
|
||||
FriendCode,
|
||||
ExportUrl,
|
||||
GameList,
|
||||
GameView,
|
||||
GcgExport,
|
||||
@@ -882,6 +883,36 @@ export function decodeGcg(buf: Uint8Array): GcgExport {
|
||||
return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
|
||||
}
|
||||
|
||||
export function encodeExportUrlRequest(
|
||||
gameId: string,
|
||||
kind: 'png' | 'gcg',
|
||||
dateLocale: string,
|
||||
actionLabels: string[],
|
||||
timeZone: string,
|
||||
): Uint8Array {
|
||||
const b = new Builder(256);
|
||||
const gid = b.createString(gameId);
|
||||
const k = b.createString(kind);
|
||||
const dl = b.createString(dateLocale);
|
||||
const tz = b.createString(timeZone);
|
||||
const labels = fb.ExportUrlRequest.createActionLabelsVector(
|
||||
b,
|
||||
actionLabels.map((l) => b.createString(l)),
|
||||
);
|
||||
fb.ExportUrlRequest.startExportUrlRequest(b);
|
||||
fb.ExportUrlRequest.addGameId(b, gid);
|
||||
fb.ExportUrlRequest.addKind(b, k);
|
||||
fb.ExportUrlRequest.addDateLocale(b, dl);
|
||||
fb.ExportUrlRequest.addActionLabels(b, labels);
|
||||
fb.ExportUrlRequest.addTimeZone(b, tz);
|
||||
return finish(b, fb.ExportUrlRequest.endExportUrlRequest(b));
|
||||
}
|
||||
|
||||
export function decodeExportUrl(buf: Uint8Array): ExportUrl {
|
||||
const e = fb.ExportUrl.getRootAsExportUrl(new ByteBuffer(buf));
|
||||
return { path: s(e.path()), filename: s(e.filename()) };
|
||||
}
|
||||
|
||||
function emptyGame(): GameView {
|
||||
return {
|
||||
id: '',
|
||||
|
||||
+38
-26
@@ -1,12 +1,12 @@
|
||||
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
|
||||
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
|
||||
// Canvas 2D — no dependencies — and the module is reached only through a dynamic import, so
|
||||
// it stays out of the startup bundle. The scoresheet typography is fixed; the board instead
|
||||
// stretches to the scoresheet's height (never below MIN_SIDE), so a long game grows the
|
||||
// board rather than leaving a hole under it.
|
||||
// Canvas 2D — no dependencies. The module is the SHARED source of truth for the export
|
||||
// image: the render sidecar bundles it (renderer/src/entry.ts) and rasterizes on
|
||||
// skia-canvas, while this ui project keeps the pure parts unit-tested (the browser app
|
||||
// itself no longer imports it — delivery is the server's signed URL).
|
||||
//
|
||||
// buildScoresheet and layoutImage are pure (vitest-covered); renderGameImage is the thin
|
||||
// canvas pass exercised by the Playwright e2e.
|
||||
// buildScoresheet and layoutImage are pure (vitest-covered); drawGameImage is the thin
|
||||
// canvas pass exercised by the sidecar's node test.
|
||||
|
||||
import type { GameView, MoveRecord, Variant } from './model';
|
||||
import { replay } from './board';
|
||||
@@ -56,16 +56,22 @@ export interface Scoresheet {
|
||||
}
|
||||
|
||||
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
|
||||
* (regional date conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||||
* dateLocale is for tests; production passes undefined = the system default. */
|
||||
export function formatFinishedDate(unixSec: number, dateLocale?: string): string {
|
||||
return new Intl.DateTimeFormat(dateLocale, {
|
||||
* and time zone (regional conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||||
* Both ride in from the client on a server render; an invalid zone falls back to the
|
||||
* process default rather than failing the whole image. */
|
||||
export function formatFinishedDate(unixSec: number, dateLocale?: string, timeZone?: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(unixSec * 1000));
|
||||
};
|
||||
try {
|
||||
return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone }).format(new Date(unixSec * 1000));
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat(dateLocale, opts).format(new Date(unixSec * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +84,7 @@ export function buildScoresheet(
|
||||
game: GameView,
|
||||
moves: MoveRecord[],
|
||||
actionLabel: (action: string) => string,
|
||||
opts?: { hostname?: string; dateLocale?: string },
|
||||
opts?: { hostname?: string; dateLocale?: string; timeZone?: string },
|
||||
): Scoresheet {
|
||||
const seats = game.seats;
|
||||
const grid = historyGrid(moves, seats.length, null);
|
||||
@@ -106,7 +112,7 @@ export function buildScoresheet(
|
||||
rows,
|
||||
adjustments,
|
||||
hasAdjustments: adjustments.some((a) => a !== 0),
|
||||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale)}`,
|
||||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale, opts?.timeZone)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,27 +196,36 @@ export interface RenderOptions {
|
||||
hostname?: string;
|
||||
/** Footer date locale override (defaults to the device locale) — for tests. */
|
||||
dateLocale?: string;
|
||||
/** Footer IANA time zone (the device zone on a server render; defaults to local). */
|
||||
timeZone?: string;
|
||||
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/** CanvasLike is the minimal canvas surface drawGameImage needs — satisfied by both the
|
||||
* browser HTMLCanvasElement and the render sidecar's skia-canvas Canvas, so the drawing
|
||||
* code is shared verbatim between the two runtimes. */
|
||||
export interface CanvasLike {
|
||||
width: number;
|
||||
height: number;
|
||||
getContext(contextId: '2d'): CanvasRenderingContext2D | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGameImage draws the finished game as a PNG blob: final board with classic axes on
|
||||
* the left, the scoresheet on the right, the site + finish date at the bottom. Light theme
|
||||
* always; no last-move highlight (an archival artifact, not a live frame).
|
||||
* drawGameImage sizes the canvas and draws the finished game onto it: final board with
|
||||
* classic axes on the left, the scoresheet on the right, the site + finish date at the
|
||||
* bottom. Light theme always; no last-move highlight (an archival artifact, not a live
|
||||
* frame). The caller owns the canvas and its PNG encoding (toBlob in the browser,
|
||||
* toBuffer in the sidecar).
|
||||
*/
|
||||
export async function renderGameImage(
|
||||
game: GameView,
|
||||
moves: MoveRecord[],
|
||||
opts: RenderOptions,
|
||||
): Promise<Blob> {
|
||||
export function drawGameImage(canvas: CanvasLike, game: GameView, moves: MoveRecord[], opts: RenderOptions): void {
|
||||
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
|
||||
hostname: opts.hostname,
|
||||
dateLocale: opts.dateLocale,
|
||||
timeZone: opts.timeZone,
|
||||
});
|
||||
const layout = layoutImage(sheet);
|
||||
const scale = opts.scale ?? 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(layout.w * scale);
|
||||
canvas.height = Math.round(layout.h * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -228,12 +243,9 @@ export async function renderGameImage(
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('gameimage: toBlob failed'))), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
|
||||
const { x, y, cell } = l.board;
|
||||
ctx.fillStyle = C.muted;
|
||||
|
||||
@@ -311,6 +311,7 @@ export const en = {
|
||||
'stats.guestHint': 'Sign in to track your statistics.',
|
||||
|
||||
'game.exportGcg': 'Export game',
|
||||
'game.exportChoice': 'Export the finished game as:',
|
||||
'game.exportImageOpt': 'Image (PNG)',
|
||||
'game.exportGcgOpt': 'GCG file',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
|
||||
@@ -311,6 +311,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'stats.guestHint': 'Войдите, чтобы вести статистику.',
|
||||
|
||||
'game.exportGcg': 'Экспорт партии',
|
||||
'game.exportChoice': 'Экспортировать завершённую партию как:',
|
||||
'game.exportImageOpt': 'Картинка (PNG)',
|
||||
'game.exportGcgOpt': 'Файл GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
FeedbackState,
|
||||
FriendCode,
|
||||
GameList,
|
||||
ExportUrl,
|
||||
GcgExport,
|
||||
History,
|
||||
HintResult,
|
||||
@@ -677,6 +678,12 @@ export class MockGateway implements GatewayClient {
|
||||
content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
|
||||
};
|
||||
}
|
||||
async exportUrl(gameId: string, kind: 'png' | 'gcg'): Promise<ExportUrl> {
|
||||
const g = this.game(gameId);
|
||||
if (g.view.status !== 'finished') throw new GatewayError('game_active');
|
||||
// A shape-faithful signed path; the e2e intercepts the route and serves bytes.
|
||||
return { path: `/dl/${gameId}/${kind}?e=9999999999&s=mock`, filename: `game-${gameId}.${kind}` };
|
||||
}
|
||||
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
|
||||
|
||||
@@ -326,6 +326,14 @@ export interface GcgExport {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A minted signed download link for a finished game's export artifact. path is
|
||||
* relative — the client resolves it against its own origin (the SPA and the
|
||||
* gateway edge share it), so the server never needs to know the public host. */
|
||||
export interface ExportUrl {
|
||||
path: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
userId: string;
|
||||
|
||||
@@ -47,6 +47,7 @@ export const READ_OPS: ReadonlySet<string> = new Set([
|
||||
'game.state',
|
||||
'game.history',
|
||||
'game.gcg',
|
||||
'game.export_url',
|
||||
'game.evaluate',
|
||||
'game.check_word',
|
||||
'stats.get',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { pickGcgDelivery, pickImageDelivery, pickTextShare, shareOrDownloadGcg, shareOrDownloadImage, shareText } from './share';
|
||||
import { downloadUrl, pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
|
||||
import type { GcgExport } from './model';
|
||||
|
||||
const file = {} as File;
|
||||
@@ -83,52 +83,21 @@ describe('shareOrDownloadGcg', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickImageDelivery', () => {
|
||||
const canShareNav = { canShare: () => true, share: async () => {} };
|
||||
const noShareNav = { canShare: () => false, share: async () => {} };
|
||||
|
||||
it('shares when the platform can share files', () => {
|
||||
expect(pickImageDelivery(canShareNav, file)).toBe('share');
|
||||
});
|
||||
|
||||
it('downloads on a plain browser without Web Share (desktop)', () => {
|
||||
expect(pickImageDelivery(noShareNav, file)).toBe('download');
|
||||
expect(pickImageDelivery(undefined, file)).toBe('download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareOrDownloadImage', () => {
|
||||
describe('downloadUrl', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubEnv(canShare: boolean, share: () => Promise<void>) {
|
||||
it('clicks a temporary anchor carrying the URL and the filename', () => {
|
||||
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||
const createElement = vi.fn(() => anchor);
|
||||
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 };
|
||||
}
|
||||
|
||||
const png = () => ({ name: 'game-1.png', type: 'image/png' }) as File;
|
||||
|
||||
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
|
||||
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
||||
const { createElement } = stubEnv(true, share);
|
||||
|
||||
expect(await shareOrDownloadImage(png())).toBe('shared');
|
||||
|
||||
expect(share).toHaveBeenCalledOnce();
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads via an anchor on a desktop browser that cannot share files', async () => {
|
||||
const { anchor, createElement } = stubEnv(false, vi.fn());
|
||||
|
||||
expect(await shareOrDownloadImage(png())).toBe('downloaded');
|
||||
downloadUrl('https://example.test/dl/g-1/png?e=1&s=x', 'game-1.png');
|
||||
|
||||
expect(createElement).toHaveBeenCalledWith('a');
|
||||
expect(anchor.click).toHaveBeenCalledOnce();
|
||||
expect(anchor.href).toBe('https://example.test/dl/g-1/png?e=1&s=x');
|
||||
expect(anchor.download).toBe('game-1.png');
|
||||
expect(anchor.click).toHaveBeenCalledOnce();
|
||||
expect(anchor.remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+36
-26
@@ -81,42 +81,52 @@ function downloadFile(content: Blob | string, filename: string, type = 'applicat
|
||||
}
|
||||
|
||||
/**
|
||||
* pickImageDelivery decides how to deliver the PNG game image: Web Share (with the file)
|
||||
* wherever it exists — mobile browsers and the iOS Telegram Mini App — else a Blob download.
|
||||
* The image option is not offered inside the in-app WebViews at all (Android Telegram/VK, the
|
||||
* desktop VK iframe): a binary PNG has no working route there until the server-rendered
|
||||
* signed-URL delivery lands, so this decision never sees them. Pure, unit-tested with a mock
|
||||
* navigator.
|
||||
* 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 pickImageDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
|
||||
if (
|
||||
nav &&
|
||||
typeof nav.canShare === 'function' &&
|
||||
typeof nav.share === 'function' &&
|
||||
nav.canShare({ files: [file] })
|
||||
) {
|
||||
return 'share';
|
||||
}
|
||||
return 'download';
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* shareOrDownloadImage delivers the rendered game image and reports the route taken. The Web
|
||||
* Share invariant matches shareOrDownloadGcg: a cancelled or failed share is a deliberate
|
||||
* no-op, never a Blob-download fallback (an <a download> strands the iOS WKWebView on the
|
||||
* blob: URL).
|
||||
* 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 shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
|
||||
export async function shareUrlAsFile(url: string, filename: string, mime: string): Promise<'shared' | 'downloaded' | 'failed'> {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
||||
if (pickImageDelivery(nav, file) === 'share' && nav) {
|
||||
if (nav && typeof nav.share === 'function' && typeof nav.canShare === 'function') {
|
||||
try {
|
||||
await nav.share({ files: [file], title: file.name });
|
||||
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 {
|
||||
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
|
||||
return 'failed';
|
||||
}
|
||||
return 'shared';
|
||||
}
|
||||
downloadFile(file, file.name, file.type);
|
||||
downloadUrl(url, filename);
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
|
||||
+26
-1
@@ -67,6 +67,7 @@ interface TelegramWebApp {
|
||||
};
|
||||
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
||||
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
||||
downloadFile?: (params: { url: string; file_name: string }, cb?: (accepted: boolean) => void) => void;
|
||||
}
|
||||
|
||||
function webApp(): TelegramWebApp | undefined {
|
||||
@@ -372,7 +373,8 @@ export function telegramShowConfirm(message: string): Promise<boolean> {
|
||||
/**
|
||||
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
||||
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
||||
* predating the popup, so callers can fall back to their own modal.
|
||||
* predating the popup, so callers can fall back to their own modal. NOTE: the callback runs with
|
||||
* no user activation — never lead from it into navigator.share or a clipboard write.
|
||||
*/
|
||||
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
||||
const w = webApp();
|
||||
@@ -380,6 +382,29 @@ export function telegramShowPopup(params: TelegramPopupParams): Promise<string |
|
||||
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
||||
}
|
||||
|
||||
/** telegramCanDownloadFile reports whether the native download dialog exists (Bot API 8.0). */
|
||||
export function telegramCanDownloadFile(): boolean {
|
||||
return !!webApp()?.downloadFile;
|
||||
}
|
||||
|
||||
/** telegramPlatform returns the SDK's platform tag ('ios', 'android', 'tdesktop', …) or ''
|
||||
* outside Telegram — the export delivery branches on it (iOS gets the OS share sheet). */
|
||||
export function telegramPlatform(): string {
|
||||
return webApp()?.platform ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramDownloadFile asks Telegram to download url as fileName through its native
|
||||
* dialog — the Mini App file delivery that works on every Telegram platform (a webview
|
||||
* <a download> does not). Returns false outside Telegram or on a client predating it.
|
||||
*/
|
||||
export function telegramDownloadFile(url: string, fileName: string): boolean {
|
||||
const w = webApp();
|
||||
if (!w?.downloadFile) return false;
|
||||
w.downloadFile({ url, file_name: fileName });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Haptic is the set of feedbacks the app triggers. */
|
||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||
|
||||
|
||||
@@ -271,6 +271,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async exportGcg(gameId) {
|
||||
return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId)));
|
||||
},
|
||||
async exportUrl(gameId, kind, dateLocale, actionLabels, timeZone) {
|
||||
return codec.decodeExportUrl(
|
||||
await exec('game.export_url', codec.encodeExportUrlRequest(gameId, kind, dateLocale, actionLabels, timeZone)),
|
||||
);
|
||||
},
|
||||
|
||||
subscribe(onEvent, onError) {
|
||||
const ctrl = new AbortController();
|
||||
|
||||
@@ -155,6 +155,36 @@ export async function vkShare(link: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShowImages opens VK's native image viewer on url — on the Android client this is the
|
||||
* PNG delivery: the viewer's own "save" works there, while VKWebAppDownloadFile hangs
|
||||
* indefinitely (on-device finding). Resolves false on any failure, so the caller can
|
||||
* fall back.
|
||||
*/
|
||||
export async function vkShowImages(url: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppShowImages', { images: [url] });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||
* on any failure or where the method is unavailable (notably the desktop iframe), so the
|
||||
* caller falls back to a plain browser download of the same URL.
|
||||
*/
|
||||
export async function vkDownloadFile(url: string, filename: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppDownloadFile', { url, filename });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
||||
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
||||
|
||||
Reference in New Issue
Block a user