feat(export): server-rendered artifacts behind one signed download URL #160

Merged
developer merged 7 commits from feature/export-server-render into development 2026-07-02 21:58:07 +00:00
11 changed files with 189 additions and 60 deletions
Showing only changes of commit c3c26bbbc0 - Show all commits
+8 -3
View File
@@ -794,9 +794,14 @@ platform: the client calls the authenticated `game.export_url` op, the backend m
**relative, HMAC-signed, short-lived path** **relative, HMAC-signed, short-lived path**
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL, (`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no `BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
service ever needs to know the public host) and hands it to the platform's native service ever needs to know the public host) and hands it to the platform's most
download: Telegram `downloadFile` (Bot API 8.0), `VKWebAppDownloadFile`, or a plain native affordance: Telegram `downloadFile` (Bot API 8.0; the chooser there is the
browser anchor elsewhere (including the desktop VK iframe). The GET is the gateway's native showPopup — the whole TG chain is activation-free bridge calls), VK's native
photo viewer for the PNG (`VKWebAppShowImages`) + `VKWebAppDownloadFile` for the
GCG, the OS share sheet with the fetched file on a mobile browser, or a plain
anchor download (desktop, and the VK desktop iframe fallback). The gateway serves
the bytes via `http.ServeContent` (Content-Length + Range/206 — Android's system
DownloadManager hangs on chunked bodies of unknown length). The GET is the gateway's
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the **only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
per-IP public rate limiter): the native download calls carry no cookies or headers, so per-IP public rate limiter): the native download calls carry no cookies or headers, so
the URL's signature — verified by the backend on its `/api/v1/public` group in constant the URL's signature — verified by the backend on its `/api/v1/public` group in constant
+14 -12
View File
@@ -322,9 +322,9 @@ Finished games are archived in a dictionary-independent form and exportable in
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of **two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
the final position; the export is offered **only once a game is finished**, and the final position; the export is offered **only once a game is finished**, and
never for an honest-AI practice game (a live game's export would leak the move never for an honest-AI practice game (a live game's export would leak the move
journal; an AI game is throwaway). The format chooser is always the app's **own journal; an AI game is throwaway). The format chooser is Telegram's **native popup**
modal** — deliberately not Telegram's native popup, whose callback runs without inside Telegram (safe there: the whole Telegram chain is bridge calls, which need no
user activation and silently breaks the share/clipboard delivery it leads to. user activation) and the app's own modal elsewhere (VK has no native chooser).
The **image** is rendered on the server (the internal render sidecar runs the same The **image** is rendered on the server (the internal render sidecar runs the same
drawing module the web client tests; always the light theme): the final board with drawing module the web client tests; always the light theme): the final board with
@@ -340,15 +340,17 @@ running totals alone do not include it). The footer stamps the site host and the
finish date in the device locale. The scoresheet typography is fixed; a long game finish date in the device locale. The scoresheet typography is fixed; a long game
stretches the board (never below its minimum) so the image carries no dead space. stretches the board (never below its minimum) so the image carries no dead space.
Delivery is **one route for both formats on every platform**: the app requests a Delivery is **one signed, short-lived link for both formats on every platform**,
signed, short-lived download link and hands it to the platform's native download — handed to the most native affordance: Telegram's download dialog (both TG
Telegram's download dialog, VK's `VKWebAppDownloadFile`, or an ordinary browser platforms; sharing onwards from Telegram's own file preview); on VK the image opens
file download elsewhere (including the desktop VK iframe). The link needs no login in **VK's native photo viewer** (on screen at once — saving/sharing are the
to fetch (the platforms' download calls carry none), is valid for minutes, and viewer's own controls) and the GCG goes through VK's download; a **mobile browser
serves the artifact as a named file attachment. The single exception is a legacy gets the OS share sheet** with the file (nothing lands in Downloads first); a
Telegram client without the download dialog (pre-Bot API 8.0): there the GCG falls desktop browser downloads the file. The link needs no login to fetch (the
back to the old clipboard copy (with the confirming toast) and the image option is platforms' downloaders carry none) and is valid for minutes. The single exception
not offered. Statistics (durable accounts only): is a legacy Telegram client without the download dialog (pre-Bot API 8.0): there
the GCG falls back to the old clipboard copy (with the confirming toast) and the
image option is not offered. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not also shows the player's **move count** (their plays — passes and exchanges do not
+14 -11
View File
@@ -331,9 +331,10 @@ Telegram.
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка** **двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда — финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения, партия с ИИ одноразовая). Выбор формата — **нативный попап Telegram** внутри
сознательно не нативный попап Telegram: его коллбек приходит без user activation и Telegram (там это безопасно: вся телеграмовская цепочка — bridge-вызовы, которым
молча ломает доставку через share/буфер, к которой ведёт выбор. user activation не нужна) и собственный модал приложения в остальных случаях (у VK
нативного чузера нет).
**Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же **Картинка** рендерится на сервере (внутренний рендер-сайдкар исполняет тот же
модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная модуль отрисовки, что тестирует веб-клиент; всегда светлая тема): финальная
@@ -349,14 +350,16 @@ Telegram.
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
партия растягивает доску (не ниже минимума), так что пустот на картинке нет. партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
Доставка — **один путь для обоих форматов на любой платформе**: приложение Доставка — **одна подписанная короткоживущая ссылка для обоих форматов на любой
запрашивает подписанную короткоживущую ссылку на скачивание и передаёт её родному платформе**, отданная самому родному механизму: диалог загрузки Telegram (обе
механизму платформы — диалогу загрузки Telegram, `VKWebAppDownloadFile` у VK или платформы TG; поделиться дальше — из телеграмовского превью файла); в VK картинка
обычному браузерному скачиванию файла в остальных случаях (включая десктопный открывается в **нативном просмотрщике фото VK** (сразу на экране — сохранение и
iframe VK). Ссылка не требует логина при скачивании (родные загрузчики платформ его шаринг штатными кнопками просмотрщика), а GCG идёт через загрузку VK; **мобильный
не несут), живёт минуты и отдаёт артефакт именованным файлом. Единственное браузер получает системную share-шторку** с файлом (ничего не оседает в Загрузках);
исключение — устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там десктопный браузер скачивает файл. Ссылка не требует логина при скачивании (родные
GCG откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт загрузчики платформ его не несут) и живёт минуты. Единственное исключение —
устаревший клиент Telegram без диалога загрузки (до Bot API 8.0): там GCG
откатывается на прежнее копирование в буфер (с подтверждающим тостом), а пункт
«картинка» не предлагается. Статистика (только у постоянных аккаунтов): «картинка» не предлагается. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также ход, уже включающий все образованные им слова и бонус за все фишки). Также
+13 -9
View File
@@ -406,15 +406,19 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid - **History / export**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); the 📤 in the history header appears (the word(s) and the move score, no running total); the 📤 in the history header appears
only once the game is finished — never in an honest-AI game (throwaway practice) — and only once the game is finished — never in an honest-AI game (throwaway practice) — and
opens the **format chooser**: always the app's own modal, never Telegram's native popup opens the **format chooser**: Telegram's native popup inside Telegram — safe there,
(a `showPopup` callback carries no user activation, which silently breaks gesture-gated since the whole TG chain is bridge calls needing no user activation (a popup callback
delivery APIs — on-device finding). The accent button leads with the image, the GCG file must never lead into `navigator.share`/clipboard, the on-device finding) — and the app's
is the quiet alternative; both need the connection. Both formats travel the same own modal elsewhere (VK has no native chooser; the accent button leads with the image,
signed-URL route: the app mints a relative link (`game.export_url`), resolves it against both options need the connection). Both formats mint the same signed relative link
its own origin and hands it to the platform's native download — Telegram `downloadFile`, (`game.export_url`), resolved against the app's own origin, then delivered natively:
`VKWebAppDownloadFile`, else a plain anchor download (also the desktop VK iframe, where Telegram `downloadFile` (both TG platforms); on VK the PNG opens in the native photo
the bridge method is absent and the anchor works). A legacy Telegram client without viewer (`VKWebAppShowImages`, save/share from its controls) and the GCG goes through
`downloadFile` keeps the old GCG clipboard copy and hides the image option. Confirming a `VKWebAppDownloadFile`, both falling back to a plain anchor download (the desktop
iframe); a mobile browser fetches the bytes and opens the OS share sheet
(`navigator.share` with the file — the proven fetch-then-share pattern), a desktop
browser downloads by anchor. A legacy Telegram client without `downloadFile` keeps the
old GCG clipboard copy, hides the image option and stays on the app modal. Confirming a
resign reveals the full board: it closes the history drawer (portrait) and zooms the resign reveals the full board: it closes the history drawer (portrait) and zooms the
board out. board out.
- **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar - **Export image** (`lib/gameimage.ts` — the shared drawing module the render sidecar
+6 -5
View File
@@ -7,6 +7,7 @@
package connectsrv package connectsrv
import ( import (
"bytes"
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
@@ -14,7 +15,6 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -466,10 +466,11 @@ func (s *Server) exportDownloadHandler() http.Handler {
} }
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, max-age=0") w.Header().Set("Cache-Control", "private, max-age=0")
// Explicit length, or the body goes out chunked and Android's system // ServeContent (not a bare Write): the platforms' native downloaders are picky —
// DownloadManager (the VKWebAppDownloadFile executor) hangs on it. // Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a
w.Header().Set("Content-Length", strconv.Itoa(len(data))) // chunked body of unknown length and may probe with Range. ServeContent emits
_, _ = w.Write(data) // Content-Length, honours Range/If-* and answers 206s from the buffered artifact.
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
}) })
} }
+48 -4
View File
@@ -58,9 +58,12 @@ test('the chooser downloads the GCG through the same signed-URL route', async ({
expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/); expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/);
}); });
test('inside Telegram both formats go through the native downloadFile dialog', async ({ page }) => { test('inside Telegram the chooser is the native popup and delivery the native downloadFile', async ({
// A Telegram stub with downloadFile present (Bot API 8.0): the image option is offered page,
// and the artifact is handed to the native download — never an in-webview anchor. }) => {
// A Telegram stub with showPopup AND downloadFile (Bot API 8.0): the whole chain stays
// in bridge calls — the popup picks the image, the artifact goes to the native download
// dialog, never an in-webview anchor. (Activation-safe: no gesture-gated Web APIs.)
await page.addInitScript(() => { await page.addInitScript(() => {
Object.assign(window, { Object.assign(window, {
Telegram: { Telegram: {
@@ -69,6 +72,11 @@ test('inside Telegram both formats go through the native downloadFile dialog', a
initDataUnsafe: {}, initDataUnsafe: {},
ready() {}, ready() {},
expand() {}, expand() {},
showPopup(params: { buttons?: { id?: string; type?: string }[] }, cb: (id: string) => void) {
const w = window as unknown as { __popup?: unknown };
w.__popup = params;
setTimeout(() => cb('png'), 0);
},
downloadFile(params: { url: string; file_name: string }) { downloadFile(params: { url: string; file_name: string }) {
const w = window as unknown as { __downloads?: { url: string; file_name: string }[] }; const w = window as unknown as { __downloads?: { url: string; file_name: string }[] };
(w.__downloads ??= []).push(params); (w.__downloads ??= []).push(params);
@@ -84,7 +92,13 @@ test('inside Telegram both formats go through the native downloadFile dialog', a
await page.locator('.scoreboard').click(); await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Export game' }).click(); await page.getByRole('button', { name: 'Export game' }).click();
await page.getByRole('button', { name: 'Image (PNG)' }).click(); // The native popup carried both formats plus cancel, and no in-app modal was mounted.
const popup = await page.evaluate(
() => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup,
);
expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['png', 'gcg', 'cancel']);
await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0);
await expect await expect
.poll(() => .poll(() =>
page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads), page.evaluate(() => (window as unknown as { __downloads?: { url: string; file_name: string }[] }).__downloads),
@@ -98,6 +112,36 @@ test('inside Telegram both formats go through the native downloadFile dialog', a
expect(dl.file_name).toMatch(/^game-.+\.png$/); expect(dl.file_name).toMatch(/^game-.+\.png$/);
}); });
test('a mobile browser gets the OS share sheet with the fetched file', async ({ page }) => {
// Web Share with files present (a mobile browser): the artifact is fetched from the
// signed URL and handed to navigator.share as a File — the sheet opens directly,
// nothing lands in Downloads first.
await page.addInitScript(() => {
const shared: { name: string; type: string }[] = [];
Object.defineProperty(navigator, 'canShare', { value: () => true, configurable: true });
Object.defineProperty(navigator, 'share', {
value: async (data: { files?: File[] }) => {
for (const f of data.files ?? []) shared.push({ name: f.name, type: f.type });
},
configurable: true,
});
(window as unknown as { __shared: typeof shared }).__shared = shared;
});
await routeDl(page);
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
await page.getByRole('button', { name: 'Image (PNG)' }).click();
await expect
.poll(() => page.evaluate(() => (window as unknown as { __shared: unknown[] }).__shared))
.toHaveLength(1);
const shared = await page.evaluate(
() => (window as unknown as { __shared: { name: string; type: string }[] }).__shared[0],
);
expect(shared.name).toMatch(/^game-.+\.png$/);
expect(shared.type).toBe('image/png');
});
test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => { test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
// Headless engines deny the real clipboard; a permissive stub keeps the legacy // Headless engines deny the real clipboard; a permissive stub keeps the legacy
+38 -15
View File
@@ -22,12 +22,12 @@
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints'; import { hintsLeft } from '../lib/hints';
import { downloadUrl, shareOrDownloadGcg } from '../lib/share'; import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
import { insideVK, vkCopyText, vkDownloadFile } from '../lib/vk'; import { insideVK, vkCopyText, vkDownloadFile, vkShowImages } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm } from '../lib/telegram'; import { insideTelegram, telegramCanDownloadFile, telegramDialogsAvailable, telegramDownloadFile, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
import { haptic } from '../lib/haptics'; import { haptic } from '../lib/haptics';
import { import {
BLANK, BLANK,
@@ -932,20 +932,37 @@
} }
// The finished-game export offers two formats — the GCG file and the server-rendered PNG // The finished-game export offers two formats — the GCG file and the server-rendered PNG
// image — behind one 📤 button, always through the app's own modal. Never Telegram's // image — behind one 📤 button, both minted as one signed relative URL and delivered by
// native popup here: its callback runs with NO user activation, which breaks the // the most platform-native affordance:
// gesture-gated delivery APIs (verified on-device). Both formats travel the SAME route on // Telegram native showPopup chooser → native downloadFile dialog. All-native and
// every platform: mint a signed relative URL, resolve it against our origin, then hand it // activation-safe: a popup callback carries no user activation, but the
// to the platform's native download call — Telegram downloadFile / VKWebAppDownloadFile — // whole TG chain is bridge calls, never gesture-gated Web APIs.
// or a plain browser anchor download elsewhere (including the desktop VK iframe, where // VK our modal chooser (VK has no native one) → the PNG opens in VK's native
// the bridge method is unavailable and vkDownloadFile falls through). // image viewer (VKWebAppShowImages — on screen at once, save/share from the
// viewer's own controls); the GCG goes through VKWebAppDownloadFile; either
// falls back to a plain anchor download when the bridge call fails (the
// desktop iframe is an ordinary browser).
// browsers our modal → the OS share sheet with the fetched file where files can be
// shared (mobile), else a plain anchor download (desktop).
let exportOpen = $state(false); let exportOpen = $state(false);
// The only platform without the URL delivery is a legacy Telegram client predating // The only platform without the URL delivery is a legacy Telegram client predating
// Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and // Bot API 8.0 downloadFile: the GCG falls back to the old clipboard copy there, and
// the image option is not offered. // the image option is not offered (and the chooser stays our modal).
const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile()); const exportImageAvailable = $derived(!insideTelegram() || telegramCanDownloadFile());
function onExportClick() { async function onExportClick() {
if (insideTelegram() && telegramCanDownloadFile() && telegramDialogsAvailable()) {
const choice = await telegramShowPopup({
message: t('game.exportChoice'),
buttons: [
{ id: 'png', type: 'default', text: t('game.exportImageOpt') },
{ id: 'gcg', type: 'default', text: t('game.exportGcgOpt') },
{ type: 'cancel' },
],
});
if (choice === 'png' || choice === 'gcg') void exportArtifact(choice);
return;
}
exportOpen = true; exportOpen = true;
} }
@@ -963,8 +980,8 @@
} }
// exportArtifact delivers a finished game's artifact by the unified signed-URL route. // exportArtifact delivers a finished game's artifact by the unified signed-URL route.
// The device date locale and the UI-localized non-play labels ride the URL so the // The device date locale, its IANA time zone and the UI-localized non-play labels ride
// server-rendered image matches what the player would have seen locally. // the URL so the server-rendered image matches what the player would have seen locally.
const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const; const EXPORT_ACTIONS = ['pass', 'exchange', 'resign', 'timeout'] as const;
async function exportArtifact(kind: 'png' | 'gcg') { async function exportArtifact(kind: 'png' | 'gcg') {
if (insideTelegram() && !telegramCanDownloadFile()) { if (insideTelegram() && !telegramCanDownloadFile()) {
@@ -978,8 +995,14 @@
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone); const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
const url = new URL(path, location.origin).href; const url = new URL(path, location.origin).href;
if (insideTelegram() && telegramDownloadFile(url, filename)) return; if (insideTelegram() && telegramDownloadFile(url, filename)) return;
if (insideVK() && (await vkDownloadFile(url, filename))) return; if (insideVK()) {
if (kind === 'png' && (await vkShowImages(url))) return;
if (kind === 'gcg' && (await vkDownloadFile(url, filename))) return;
downloadUrl(url, filename); downloadUrl(url, filename);
return;
}
const outcome = await shareUrlAsFile(url, filename, kind === 'png' ? 'image/png' : 'text/plain');
if (outcome === 'failed') showToast(t('error.generic'), 'error');
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
+1
View File
@@ -311,6 +311,7 @@ export const en = {
'stats.guestHint': 'Sign in to track your statistics.', 'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export game', 'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
'game.exportImageOpt': 'Image (PNG)', 'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file', 'game.exportGcgOpt': 'GCG file',
'game.gcgActiveOnly': 'Available once the game is finished.', 'game.gcgActiveOnly': 'Available once the game is finished.',
+1
View File
@@ -311,6 +311,7 @@ export const ru: Record<MessageKey, string> = {
'stats.guestHint': 'Войдите, чтобы вести статистику.', 'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии', 'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
'game.exportImageOpt': 'Картинка (PNG)', 'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG', 'game.exportGcgOpt': 'Файл GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.gcgActiveOnly': 'Доступно после завершения игры.',
+31
View File
@@ -97,6 +97,37 @@ export function downloadUrl(url: string, filename: string): void {
a.remove(); 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 {
await nav.share({ files: [file], title: filename });
} catch {
/* cancelled — intentionally a no-op */
}
return 'shared';
}
} catch {
return 'failed';
}
}
downloadUrl(url, filename);
return 'downloaded';
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] }; type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/** /**
+14
View File
@@ -155,6 +155,20 @@ export async function vkShare(link: string): Promise<boolean> {
} }
} }
/**
* vkShowImages opens VK's native image viewer on url the image is on screen at once and
* the viewer's own controls save/share it, which beats a blind background download for a
* picture. Resolves false on any failure or outside VK, 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) * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile)
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false * the mobile in-app file delivery, where the webview ignores <a download>. Resolves false