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}
+