diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fa5361f..99d0faf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1040,8 +1040,9 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app Single public origin, path-routed. The Vite build has two entries: a lightweight **landing page** and the game **SPA**. The gateway **embeds** the SPA build (`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at -`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path -redirects to the root — the client-side guard); a stray hit on the gateway's `/` +`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data +— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead +of redirecting away); a stray hit on the gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 3c24066..ace40bd 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -8,7 +8,10 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi `prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**: on a Telegram Mini App launch — the app is served under `/telegram/` and detects the launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at -runtime; opened outside Telegram, the `/telegram/` path redirects to the site root. +runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App +launch that delivered none, as seen on some Android clients) the app renders a compact, +shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than +redirecting to the site root. ## Layout shell (`components/Screen.svelte`) diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index d402033..dcf0b7b 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -107,11 +107,17 @@ test('inside Telegram, a failed launch shows the retry screen, not the web login await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); }); -test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => { +test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a redirect', async ({ + page, +}) => { await page.goto('/telegram/'); - // The guard sends a non-Telegram visitor back to the root, where the normal - // (guest / email) login is shown. - await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); - await expect(page).not.toHaveURL(/\/telegram\//); + // The entry no longer bounces a visitor without Telegram sign-in data to the marketing landing; + // it shows a compact diagnostic screen (Share + Retry) that helps pinpoint why initData was + // absent — notably the Android empty-initData failure. + await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + // It stays on /telegram/ (no redirect) and never shows the web (guest) login. + await expect(page).toHaveURL(/\/telegram\//); + await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); }); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 2368666..099dbe7 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -19,6 +19,7 @@ import Feedback from './screens/Feedback.svelte'; import Blocked from './screens/Blocked.svelte'; import BootError from './screens/BootError.svelte'; + import TelegramLaunchError from './screens/TelegramLaunchError.svelte'; onMount(() => { void bootstrap(); @@ -84,6 +85,11 @@ {#if !routeIsLobby}
{t('common.loading')}
{/if} +{:else if app.launchError} + + {:else if app.bootError} @@ -128,7 +134,7 @@ -{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError} +{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} {/if} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index c96a82f..bacda45 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -12,6 +12,8 @@ import { languageNeedsServerSync } from './language'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { insideTelegram, + collectTelegramDiag, + type TelegramDiag, onTelegramPath, telegramColorScheme, telegramContentSafeAreaTop, @@ -47,6 +49,11 @@ export const app = $state<{ * backend was down during a deploy). App.svelte then renders the boot-error retry screen * instead of the web login — a Mini App has no manual sign-in to fall back to. */ bootError: boolean; + /** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App + * launch carried no sign-in data (empty initData). App.svelte then renders the compact + * launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram + * delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */ + launchError: TelegramDiag | null; /** Whether the lobby's first cold load has settled (success or error). The loading splash * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ lobbyReady: boolean; @@ -96,6 +103,7 @@ export const app = $state<{ }>({ ready: false, bootError: false, + launchError: null, lobbyReady: false, splashDone: false, streamAlive: false, @@ -530,6 +538,29 @@ function syncViewportHeight(): void { if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); } +/** + * applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative + * colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets, + * the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial + * bootstrap and a manual launch retry call it. + */ +function applyTelegramChrome(launch: TelegramLaunch): void { + if (launch.theme) applyTelegramTheme(launch.theme); + // Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS + // prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls + // back to the stored preference when the SDK omits it. + applyTheme(telegramColorScheme() ?? app.theme); + // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile + // drag / board scroll. + syncTelegramChrome(); + syncTelegramSafeArea(); + telegramDisableVerticalSwipes(); + // On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged + // listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's + // full-size window. No-op on clients predating Bot API 8.0. + telegramRequestFullscreen(); +} + export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; @@ -553,33 +584,23 @@ export async function bootstrap(): Promise { window.visualViewport.addEventListener('scroll', syncViewportHeight); } - // Telegram Mini App launch: apply the platform theme, authenticate via initData, - // and route any deep-link start parameter. On the dedicated /telegram/ entry path - // outside Telegram (no initData), refuse to render and send the visitor to the - // site root. + // Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any + // deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty + // initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android + // clients), render the compact launch-error screen with a diagnostic snapshot the user can share + // with the developer, instead of bouncing the visitor to the marketing landing. if (onTelegramPath() && !insideTelegram()) { - if (typeof location !== 'undefined') location.replace('/'); + app.launchError = collectTelegramDiag(); + app.ready = true; return; } if (insideTelegram()) { const launch = telegramLaunch(); - if (launch.theme) applyTelegramTheme(launch.theme); - // Inside Telegram the colour scheme is Telegram's to decide; force it explicitly - // so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview) - // cannot fight it. Falls back to the stored preference when the SDK omits it. - applyTheme(telegramColorScheme() ?? app.theme); - // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from - // fighting tile drag / board scroll. - syncTelegramChrome(); - syncTelegramSafeArea(); + applyTelegramChrome(launch); + // Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load). telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); - telegramDisableVerticalSwipes(); - // On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged - // listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size - // window. No-op on clients predating Bot API 8.0. - telegramRequestFullscreen(); await bootTelegram(launch); app.ready = true; return; @@ -646,6 +667,26 @@ export async function retryTelegramBoot(): Promise { app.ready = true; } +/** + * retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If + * sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs + * the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the + * current state. It never reloads — telegram-web-app.js consumes the launch fragment on first load, + * so a reload could discard the very data we are waiting for. + */ +export async function retryTelegramLaunch(): Promise { + if (!insideTelegram()) { + app.launchError = collectTelegramDiag(); + return; + } + app.launchError = null; + app.ready = false; + const launch = telegramLaunch(); + applyTelegramChrome(launch); + await bootTelegram(launch); + app.ready = true; +} + /** * routeStartParam navigates a Telegram deep-link start parameter to its target: a * specific game, the friends screen with a friend-code redemption, or the lobby diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 6f7cb9e..2b83d99 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -14,6 +14,11 @@ export const en = { 'boot.errorTitle': "Couldn't load the game", 'boot.errorBody': 'Please try again in a moment.', + 'launch.errorTitle': "Can't open in Telegram", + 'launch.errorBody': 'Screenshot or share this with the developer.', + 'launch.share': 'Share', + 'launch.copied': 'Copied', + 'common.back': 'Back', 'common.cancel': 'Cancel', 'common.ok': 'OK', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index bd5b5a6..31a8c05 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -15,6 +15,11 @@ export const ru: Record = { 'boot.errorTitle': 'Не удалось загрузить игру', 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', + 'launch.errorTitle': 'Не открывается в Telegram', + 'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.', + 'launch.share': 'Поделиться', + 'launch.copied': 'Скопировано', + 'common.back': 'Назад', 'common.cancel': 'Отмена', 'common.ok': 'ОК', diff --git a/ui/src/lib/share.test.ts b/ui/src/lib/share.test.ts index 4551802..34fae16 100644 --- a/ui/src/lib/share.test.ts +++ b/ui/src/lib/share.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { pickGcgDelivery, shareOrDownloadGcg } from './share'; +import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share'; import type { GcgExport } from './model'; const file = {} as File; @@ -60,3 +60,42 @@ describe('shareOrDownloadGcg', () => { expect(anchor.click).toHaveBeenCalledOnce(); }); }); + +describe('pickTextShare', () => { + it('shares when Web Share is available', () => { + expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share'); + }); + + it('shares when share exists without canShare (text needs no file capability check)', () => { + expect(pickTextShare({ share: async () => {} })).toBe('share'); + }); + + it('copies when there is no Web Share (desktop)', () => { + expect(pickTextShare(undefined)).toBe('copy'); + expect(pickTextShare({} as never)).toBe('copy'); + }); +}); + +describe('shareText', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('uses the OS share sheet when available', async () => { + const share = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { share, canShare: () => true }); + expect(await shareText('diag', 'title')).toBe('shared'); + expect(share).toHaveBeenCalledWith({ title: 'title', text: 'diag' }); + }); + + it('copies to the clipboard when Web Share is absent (desktop)', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + expect(await shareText('diag', 'title')).toBe('copied'); + expect(writeText).toHaveBeenCalledWith('diag'); + }); + + it('reports failure without a fallback when the share is cancelled', async () => { + const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError')); + vi.stubGlobal('navigator', { share, canShare: () => true }); + expect(await shareText('diag', 'title')).toBe('failed'); + }); +}); diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts index 757aef2..4b4528d 100644 --- a/ui/src/lib/share.ts +++ b/ui/src/lib/share.ts @@ -51,3 +51,42 @@ function downloadFile(content: string, filename: string): void { a.remove(); URL.revokeObjectURL(url); } + +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'; + } +} diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index 143053b..d365282 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + collectTelegramDiag, insideTelegram, routeExternalLinkInTelegram, telegramClosingConfirmation, @@ -155,3 +156,44 @@ describe('routeExternalLinkInTelegram', () => { expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false); }); }); + +describe('collectTelegramDiag', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('reports a missing SDK outside Telegram', () => { + const d = collectTelegramDiag(); + expect(d.hasSDK).toBe(false); + expect(d.hasWebApp).toBe(false); + expect(d.initDataLen).toBe(0); + expect(d.fieldsPresent).toEqual([]); + expect(d.fieldsMissing).toEqual(['user', 'auth_date', 'hash', 'signature']); + }); + + it('reads field NAMES (never values) from a non-empty initData', () => { + stubWebApp('query_id=abc&user=%7B%7D&auth_date=1&hash=deadbeef'); + const d = collectTelegramDiag(); + expect(d.hasSDK).toBe(true); + expect(d.hasWebApp).toBe(true); + expect(d.initDataLen).toBeGreaterThan(0); + expect(d.fieldsPresent).toEqual(['query_id', 'user', 'auth_date', 'hash']); + expect(d.fieldsMissing).toEqual(['signature']); + }); + + it('recovers field names from the URL fragment when the SDK left initData empty', () => { + // Telegram passed launch data in the fragment, but WebApp.initData is empty (the Android + // failure this screen diagnoses): the names come from the raw fragment instead, and + // hashHadTgData flags that the data did arrive in the URL. + vi.stubGlobal('window', { Telegram: { WebApp: { initData: '', platform: 'android' } } }); + vi.stubGlobal('location', { + hash: '#tgWebAppData=user%3D%257B%257D%26auth_date%3D1%26hash%3Ddeadbeef&tgWebAppVersion=7.0', + pathname: '/telegram/', + }); + const d = collectTelegramDiag(); + expect(d.hasSDK).toBe(true); + expect(d.platform).toBe('android'); + expect(d.initDataLen).toBe(0); + expect(d.hashHadTgData).toBe(true); + expect(d.fieldsPresent).toEqual(['user', 'auth_date', 'hash']); + expect(d.fieldsMissing).toEqual(['signature']); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 25f8d41..aa67709 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -9,6 +9,7 @@ interface TelegramWebApp { initData: string; initDataUnsafe?: { start_param?: string }; platform?: string; + version?: string; themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; isFullscreen?: boolean; @@ -302,6 +303,120 @@ export function onTelegramPath(): boolean { return location.pathname.startsWith('/telegram/'); } +// --- Launch diagnostics (the /telegram/ entry without sign-in data) --- + +/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal + * the launch-error screen reports. Only field names are ever inspected, never their values. */ +const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature']; + +interface uaBrand { + brand: string; + version: string; +} + +interface uaDataValue { + platform?: string; + mobile?: boolean; + brands?: uaBrand[]; +} + +/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android Telegram + * webview), or undefined where it is unavailable (iOS / Safari / Firefox). */ +function uaData(): uaDataValue | undefined { + if (typeof navigator === 'undefined') return undefined; + return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData; +} + +/** launchFragmentData returns the raw tgWebAppData carried in the URL fragment (the form Telegram + * appends on launch), or '' when absent. With no SDK present a non-empty value means Telegram + * delivered the data but telegram-web-app.js never ran to parse it. */ +function launchFragmentData(): string { + if (typeof location === 'undefined') return ''; + const frag = location.hash.replace(/^#/, ''); + if (!frag) return ''; + try { + return new URLSearchParams(frag).get('tgWebAppData') ?? ''; + } catch { + return ''; + } +} + +/** initDataFieldNames parses a Telegram initData (or raw fragment data) query string and returns + * only its field NAMES — never the values, since the hash / signature are auth material. */ +function initDataFieldNames(raw: string): string[] { + if (!raw) return []; + try { + return [...new URLSearchParams(raw).keys()]; + } catch { + return []; + } +} + +/** + * TelegramDiag is a privacy-safe snapshot of why a Mini App launch lacked sign-in data, taken at + * the moment of failure and rendered on the launch-error screen so a stuck user can share it with + * the developer. It carries no secret values — only presence flags, client / OS identification and + * the field NAMES of the launch data, never the signed initData itself, and never an IP. + */ +export interface TelegramDiag { + /** Whether window.Telegram (the telegram-web-app.js script) is present at all. */ + hasSDK: boolean; + /** Whether window.Telegram.WebApp is present. */ + hasWebApp: boolean; + /** Telegram's own platform string (ios | android | android_x | tdesktop | web | …), or ''. */ + platform: string; + /** The Bot API version the client reports, or ''. */ + version: string; + /** The length of WebApp.initData — 0 is the failure this screen reports. */ + initDataLen: number; + /** Whether the URL fragment still carried tgWebAppData at launch; true with hasSDK false means + * Telegram delivered the data but the SDK script did not load to parse it. */ + hashHadTgData: boolean; + /** The field names present in the launch data (from initData, or the raw fragment when the SDK + * left initData empty); values are never included. */ + fieldsPresent: string[]; + /** The expected field names absent from the launch data (a subset of expectedInitDataFields). */ + fieldsMissing: string[]; + /** The OS / platform per User-Agent Client Hints (else navigator.platform), e.g. 'Android', ''. */ + osPlatform: string; + /** Whether the client reports itself mobile per Client Hints: 'yes' | 'no' | '' (unknown). */ + mobile: string; + /** The browser brands + major versions per Client Hints (Chromium only), or ''. */ + browser: string; + /** The full User-Agent string — the catch-all that also carries the OS version and webview build. */ + userAgent: string; +} + +/** + * collectTelegramDiag captures a TelegramDiag snapshot of the current launch state. Call it at the + * point a /telegram/ launch is found to lack sign-in data, so the snapshot reflects that moment — + * sign-in data arriving late would otherwise mask the failure. + */ +export function collectTelegramDiag(): TelegramDiag { + const w = webApp(); + const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram; + const initData = w?.initData ?? ''; + const fragData = initData ? '' : launchFragmentData(); + const present = initDataFieldNames(initData || fragData); + const ua = uaData(); + const navPlatform = + typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? ''; + return { + hasSDK: sdk, + hasWebApp: !!w, + platform: w?.platform ?? '', + version: w?.version ?? '', + initDataLen: initData.length, + hashHadTgData: fragData.length > 0, + fieldsPresent: present, + fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)), + osPlatform: ua?.platform ?? navPlatform, + mobile: ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no', + browser: (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', '), + userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent, + }; +} + // --- Login Widget (web sign-in for account linking) --- // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to diff --git a/ui/src/screens/TelegramLaunchError.svelte b/ui/src/screens/TelegramLaunchError.svelte new file mode 100644 index 0000000..979e2e0 --- /dev/null +++ b/ui/src/screens/TelegramLaunchError.svelte @@ -0,0 +1,132 @@ + + +{#if diag} +
+
+

{t('launch.errorTitle')}

+

{t('launch.errorBody')}

+
{report}
+
+ + +
+
+
+{/if} + +