From 0c5d3808d7ecc9c739dd348654b97884112755d4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 09:37:36 +0200 Subject: [PATCH 1/3] feat(ui): diagnostic screen on /telegram/ instead of the landing bounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mini App launch on /telegram/ without sign-in data (empty initData) used to location.replace('/') — bouncing the visitor to the marketing landing. That destroyed all diagnosability and, on some Android clients that reach the entry with no initData, simply looked like the app refusing to open. Replace the bounce with a compact, privacy-safe launch-error screen (screens/TelegramLaunchError.svelte) that renders a one-screenshot diagnostic snapshot captured at the moment of failure: SDK/WebApp presence, Telegram platform/version, whether initData is empty, whether the URL fragment carried tgWebAppData, the initData field NAMES present/missing (never the signed values, never an IP), and OS/mobile/browser via User-Agent Client Hints plus the full User-Agent. A Share button delivers it through the OS share sheet (clipboard copy on desktop, reusing the GCG no-webview-strand guard); Retry re-checks in place to recover a late initData without a reload (which would discard the launch fragment). This is the instrument to root-cause the Android empty-initData failure, which is not reproducible in Playwright. Tests: collectTelegramDiag + shareText/pickTextShare unit tests; the /telegram/ e2e now asserts the diagnostic screen, not a redirect. Docs: ARCHITECTURE.md + UI_DESIGN.md updated. --- docs/ARCHITECTURE.md | 5 +- docs/UI_DESIGN.md | 5 +- ui/e2e/telegram.spec.ts | 16 ++- ui/src/App.svelte | 8 +- ui/src/lib/app.svelte.ts | 79 +++++++++---- ui/src/lib/i18n/en.ts | 5 + ui/src/lib/i18n/ru.ts | 5 + ui/src/lib/share.test.ts | 41 ++++++- ui/src/lib/share.ts | 39 +++++++ ui/src/lib/telegram.test.ts | 42 +++++++ ui/src/lib/telegram.ts | 115 +++++++++++++++++++ ui/src/screens/TelegramLaunchError.svelte | 132 ++++++++++++++++++++++ 12 files changed, 463 insertions(+), 29 deletions(-) create mode 100644 ui/src/screens/TelegramLaunchError.svelte 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} + + From ae5090b851f2b74742da5af137fdaf51174ae720 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 09:59:24 +0200 Subject: [PATCH 2/3] fix(ui): load telegram-web-app.js dynamically with a timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Telegram SDK was a render-blocking + { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; @@ -584,6 +592,13 @@ export async function bootstrap(): Promise { window.visualViewport.addEventListener('scroll', syncViewportHeight); } + // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no + // longer a render-blocking