Merge pull request 'feat(vk): launch diagnostic on a direct /vk/ open instead of a guest' (#256) from feature/vk-launch-diagnostic into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s

This commit was merged in pull request #256.
This commit is contained in:
2026-07-13 21:42:51 +00:00
12 changed files with 256 additions and 95 deletions
+3 -1
View File
@@ -1369,7 +1369,9 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
`/app/` (web), `/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) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
parameters from the URL and the gateway verifies them in-process — §12; on that path without a
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
a throwaway guest); 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
+4 -1
View File
@@ -72,7 +72,10 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
the name in the signed launch). While the theme preference is "auto" the app follows the VK
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
"couldn't load" screen applies inside VK.
"couldn't load" screen applies inside VK. Opening a Mini App link directly in an ordinary browser —
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
(en/ru), no images — to the address; entering it signs the player in. A new address
is provisioned on the first request but only becomes a durable account once the code
+4 -1
View File
@@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось
загрузить» действует и внутри VK.
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
+6 -5
View File
@@ -25,7 +25,7 @@
import Blocked from './screens/Blocked.svelte';
import AccountDeleted from './screens/AccountDeleted.svelte';
import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
import LaunchError from './screens/LaunchError.svelte';
onMount(() => {
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
@@ -88,10 +88,11 @@
<div class="splash">{t('common.loading')}</div>
{/if}
{:else if app.launchError}
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
of bouncing to the marketing landing (also the probe for the empty-initData failure on
some Android clients). -->
<TelegramLaunchError />
<!-- A dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch: a compact,
shareable diagnostic screen instead of bouncing to the marketing landing (Telegram) or
silently proceeding as a guest (VK). Also the probe for the empty-initData failure on some
Android Telegram clients. -->
<LaunchError />
{:else if app.bootError}
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
show the retry screen instead of falling back to the web login. -->
+22 -12
View File
@@ -14,8 +14,7 @@ import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type
import {
insideTelegram,
telegramClose,
collectTelegramDiag,
type TelegramDiag,
telegramLaunchDiag,
onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
@@ -33,7 +32,8 @@ import {
telegramCloudGet,
telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import type { LaunchDiag } from './launchdiag';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte';
@@ -82,11 +82,12 @@ 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;
/** On a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch, set to a
* privacy-safe diagnostic snapshot. App.svelte then renders the compact, shareable launch-error
* screen (screens/LaunchError) — a probe for why Telegram delivered no initData (seen on some
* Android clients), or why /vk/ carried no signed launch — instead of bouncing to the landing
* (Telegram) or silently proceeding as a guest (VK). */
launchError: LaunchDiag | null;
/** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping
* the header title ten times in quick succession. A support aid; carries no secrets. */
debugOpen: boolean;
@@ -850,7 +851,7 @@ export async function bootstrap(): Promise<void> {
// 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()) {
app.launchError = collectTelegramDiag();
app.launchError = telegramLaunchDiag();
app.ready = true;
return;
}
@@ -875,10 +876,19 @@ export async function bootstrap(): Promise<void> {
return;
}
// The dedicated /vk/ entry opened without a signed VK launch (a plain browser tab, or a VK client
// that delivered no launch parameters) shows the compact, shareable launch diagnostic instead of
// silently proceeding as a guest — the VK counterpart of the /telegram/ launch-error screen. VK puts
// its signed parameters in the launch URL at load, so there is nothing to wait for (hence no Retry).
if (onVKPath() && !insideVK()) {
app.launchError = vkLaunchDiag();
app.ready = true;
return;
}
// VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
// authenticate from the signed launch parameters in the URL — the display name comes from
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
// VKWebAppGetUserInfo, since VK omits it from the signed params.
if (onVKPath() && insideVK()) {
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
// VK mobile webview's prefers-color-scheme does not track it).
@@ -1072,7 +1082,7 @@ export async function retryTelegramLaunch(): Promise<void> {
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
if (!insideTelegram()) {
app.launchError = collectTelegramDiag();
app.launchError = telegramLaunchDiag();
return;
}
app.launchError = null;
+1
View File
@@ -27,6 +27,7 @@ export const en = {
'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorTitleVk': "Can't open in VK",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
+1
View File
@@ -28,6 +28,7 @@ export const ru: Record<MessageKey, string> = {
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorTitleVk': 'Не открывается в VK',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
+71
View File
@@ -0,0 +1,71 @@
// Shared launch-diagnostic plumbing for the dedicated Mini App entries (/telegram/, /vk/). When one of
// those is opened without a valid launch — a plain browser tab, or a client that delivered no sign-in
// data — the app renders a compact, privacy-safe diagnostic snapshot (the LaunchError screen) the user
// can screenshot or share, instead of silently proceeding (to the marketing landing, or as a guest).
// This module holds the pieces both platforms share: the neutral report shape the screen renders, the
// client environment lines, and a query-string field-NAME reader (names only — the values can be auth
// material). The platform-specific collectors live in telegram.ts / vk.ts.
/**
* LaunchDiag is the neutral, pre-formatted snapshot the LaunchError screen renders, so the screen is
* platform-agnostic: it shows the report text and, only when retry is set, a Retry button. The
* collectors (telegramLaunchDiag / vkLaunchDiag) own the platform-specific formatting.
*/
export interface LaunchDiag {
/** platform selects the screen's title copy ('telegram' | 'vk'). */
platform: 'telegram' | 'vk';
/** report is the compact multi-line "key: value" snapshot, sized to fit one screenshot. */
report: string;
/** retry is whether the screen shows a Retry button — true only where a late-arriving launch can
* still succeed (Telegram initData), false where the launch is fixed at load (VK's signed URL). */
retry: boolean;
}
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 in-app
* webviews), or undefined where it is unavailable (iOS / Safari / Firefox). */
export function uaData(): uaDataValue | undefined {
if (typeof navigator === 'undefined') return undefined;
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
}
/**
* clientEnvLines returns the shared client-environment lines of a launch diagnostic — the OS / mobile
* flag, the browser brands, and the full User-Agent — read from Client Hints (with a navigator.platform
* fallback). Both the Telegram and VK reports append these, so the environment readout stays identical
* across platforms. No secrets: it carries only client identification, never an IP.
*/
export function clientEnvLines(): string[] {
const ua = uaData();
const navPlatform =
typeof navigator === 'undefined' ? '' : ((navigator as unknown as { platform?: string }).platform ?? '');
const osPlatform = ua?.platform ?? navPlatform;
const mobile = ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no';
const browser = (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', ');
const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
return [`os: ${osPlatform || '—'} mobile: ${mobile || '—'}`, `browser: ${browser || '—'}`, `ua: ${userAgent || '—'}`];
}
/**
* queryFieldNames parses a URL query string (or any `key=value&…` form) and returns only its field
* NAMES, never the values — the values (a Telegram initData hash, a VK sign) are auth material that must
* never surface in a shareable diagnostic. Returns an empty array for empty or unparseable input.
*/
export function queryFieldNames(raw: string): string[] {
if (!raw) return [];
try {
return [...new URLSearchParams(raw).keys()];
} catch {
return [];
}
}
+24 -46
View File
@@ -4,6 +4,7 @@
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
import { clientEnvLines, queryFieldNames, type LaunchDiag } from './launchdiag';
import type { TelegramThemeParams } from './theme';
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
@@ -471,24 +472,6 @@ export function hasLaunchFragment(): boolean {
* 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. */
@@ -503,17 +486,6 @@ function launchFragmentData(): string {
}
}
/** 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
@@ -542,30 +514,20 @@ export interface TelegramDiag {
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.
* sign-in data arriving late would otherwise mask the failure. telegramLaunchDiag wraps it into the
* neutral LaunchDiag the shared LaunchError screen renders.
*/
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 ?? '';
const present = queryFieldNames(initData || fragData);
return {
hasSDK: sdk,
hasWebApp: !!w,
@@ -576,13 +538,29 @@ export function collectTelegramDiag(): TelegramDiag {
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,
};
}
/**
* telegramLaunchDiag captures the current Telegram launch state as a neutral LaunchDiag for the shared
* LaunchError screen: the platform-specific lines (SDK load, initData length, field names) plus the
* shared client-environment lines. retry is true — Telegram's initData can arrive late on a slow
* client, so the screen's Retry can still recover the launch.
*/
export function telegramLaunchDiag(): LaunchDiag {
const d = collectTelegramDiag();
const report = [
`sdk-load: ${d.sdkLoad}`,
`sdk: ${d.hasSDK ? 'yes' : 'no'} webapp: ${d.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${d.platform || '—'} tg-version: ${d.version || '—'}`,
`initData: ${d.initDataLen > 0 ? d.initDataLen : 'empty'} tgdata-in-url: ${d.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${d.fieldsPresent.join(',') || '—'}`,
`missing: ${d.fieldsMissing.join(',') || '—'}`,
...clientEnvLines(),
].join('\n');
return { platform: 'telegram', report, retry: true };
}
/**
* telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
+41
View File
@@ -5,7 +5,9 @@ import {
routeExternalLinkInVK,
vkAndroidWebView,
vkAppId,
vkDiagLines,
vkExternalBrowserUrl,
vkLaunchDiag,
vkLaunchParams,
vkStartParam,
appearanceForBg,
@@ -119,6 +121,45 @@ describe('vk external links', () => {
});
});
describe('vkDiagLines (launch diagnostic)', () => {
it('reports an unsigned browser open: no sign, every VK param missing', () => {
const lines = vkDiagLines('utm_source=catalog', false, '');
expect(lines[0]).toBe('launch: unsigned iframe: no');
expect(lines[1]).toBe('vk-platform: —');
expect(lines[2]).toBe('params: utm_source');
expect(lines[3]).toBe('missing: vk_app_id,vk_user_id,vk_ts,vk_platform,sign');
expect(lines[4]).toBe('referrer: —');
});
it('reports a signed launch and never leaks the sign value, only its name', () => {
const lines = vkDiagLines(
'vk_app_id=6736218&vk_platform=mobile_android&vk_user_id=42&vk_ts=1700000000&sign=SECRETSIG',
true,
'vk.com',
);
expect(lines[0]).toBe('launch: signed iframe: yes');
expect(lines[1]).toBe('vk-platform: mobile_android');
expect(lines[3]).toBe('missing: —');
expect(lines[4]).toBe('referrer: vk.com');
// The signature is auth material: its NAME may appear, its VALUE must never.
const report = lines.join('\n');
expect(report).toContain('sign');
expect(report).not.toContain('SECRETSIG');
});
});
describe('vkLaunchDiag', () => {
afterEach(() => vi.unstubAllGlobals());
it('builds a VK launch diagnostic with no Retry (VK signs the URL at load, nothing to wait for)', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?utm_source=catalog' });
const d = vkLaunchDiag();
expect(d.platform).toBe('vk');
expect(d.retry).toBe(false);
expect(d.report).toContain('launch: unsigned');
});
});
describe('appearanceForBg', () => {
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
expect(appearanceForBg('#0f1115')).toBe('dark');
+61
View File
@@ -6,6 +6,7 @@
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
// call outside VK.
import { clientEnvLines, queryFieldNames, type LaunchDiag } from './launchdiag';
import { isExternalHttpUrl, type Haptic } from './telegram';
async function bridge() {
@@ -108,6 +109,66 @@ export function vkPlatform(): string {
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? '';
}
// --- Launch diagnostics (the /vk/ entry without a signed launch) ---
/** The launch parameters a valid VK Mini App launch is expected to carry in the URL; their absence —
* notably `sign` — is the signal the launch-error screen reports. Only names are inspected. */
const expectedVKParams = ['vk_app_id', 'vk_user_id', 'vk_ts', 'vk_platform', 'sign'];
/**
* vkDiagLines formats the URL-derived lines of the VK launch diagnostic from a query string (no
* leading '?') and two runtime flags. It reports only the launch-parameter NAMES present and the
* expected ones missing — never a value, since `sign` is auth material — plus the non-secret
* `vk_platform` and whether the URL carried a signature at all. Pure, so it is unit-tested.
*/
export function vkDiagLines(search: string, inIframe: boolean, referrerHost: string): string[] {
const names = queryFieldNames(search);
const platform = new URLSearchParams(search).get('vk_platform') ?? '';
const missing = expectedVKParams.filter((p) => !names.includes(p));
return [
`launch: ${names.includes('sign') ? 'signed' : 'unsigned'} iframe: ${inIframe ? 'yes' : 'no'}`,
`vk-platform: ${platform || '—'}`,
`params: ${names.join(',') || '—'}`,
`missing: ${missing.join(',') || '—'}`,
`referrer: ${referrerHost || '—'}`,
];
}
/** vkInIframe reports whether the app runs inside an iframe (VK desktop frames the Mini App; a plain
* browser tab is top-level). A cross-origin top frame denies property access, which itself means we
* are framed, so that is caught as true. */
function vkInIframe(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.self !== window.top;
} catch {
return true;
}
}
/** vkReferrerHost returns the host of document.referrer (a real VK launch is referred from vk.com; a
* direct type-in has none), or '' when there is no referrer or it does not parse. */
function vkReferrerHost(): string {
if (typeof document === 'undefined' || !document.referrer) return '';
try {
return new URL(document.referrer).host;
} catch {
return '';
}
}
/**
* vkLaunchDiag captures the current VK launch state as a neutral LaunchDiag for the shared LaunchError
* screen — a passive read of the URL launch parameters (names only), the iframe / referrer context and
* the shared client environment. retry is false: VK delivers its signed parameters in the launch URL at
* load, so — unlike Telegram's initData — they never arrive late, and a Retry could not recover them.
*/
export function vkLaunchDiag(): LaunchDiag {
const search = typeof location === 'undefined' ? '' : location.search.replace(/^\?/, '');
const report = [...vkDiagLines(search, vkInIframe(), vkReferrerHost()), ...clientEnvLines()].join('\n');
return { platform: 'vk', report, retry: false };
}
/**
* vkAndroidWebView reports whether the app runs inside the Android VK client's WebView
* (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView
@@ -1,34 +1,20 @@
<script lang="ts">
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
// Shown on a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch — instead of
// bouncing to the marketing landing (Telegram) or silently proceeding as a guest (VK). It states the
// problem plainly and renders a compact, privacy-safe diagnostic snapshot (app.launchError, taken at
// the moment of failure) sized to fit one screenshot, with a Share button (the OS share sheet, or a
// clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why the launch
// carried no sign-in data. The snapshot carries only presence / identification signals and field
// NAMES, never the signed launch data itself, nor an IP. Retry is shown only where a late-arriving
// launch can still succeed (Telegram initData); VK delivers its signed parameters in the URL at load,
// so it offers Share only.
import { app, retryTelegramLaunch } from '../lib/app.svelte';
import { shareText } from '../lib/share';
import { t } from '../lib/i18n/index.svelte';
const diag = $derived(app.launchError);
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
// prose), so the developer reads the same report in any locale. Kept terse to fit one screenshot.
const report = $derived(
diag
? [
`sdk-load: ${diag.sdkLoad}`,
`sdk: ${diag.hasSDK ? 'yes' : 'no'} webapp: ${diag.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${diag.platform || '—'} tg-version: ${diag.version || '—'}`,
`initData: ${diag.initDataLen > 0 ? diag.initDataLen : 'empty'} tgdata-in-url: ${diag.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${diag.fieldsPresent.join(',') || '—'}`,
`missing: ${diag.fieldsMissing.join(',') || '—'}`,
`os: ${diag.osPlatform || '—'} mobile: ${diag.mobile || '—'}`,
`browser: ${diag.browser || '—'}`,
`ua: ${diag.userAgent || '—'}`,
].join('\n')
: '',
);
// The title names the platform the entry belongs to; the body and the report are platform-neutral.
const title = $derived(t(diag?.platform === 'vk' ? 'launch.errorTitleVk' : 'launch.errorTitle'));
let retrying = $state(false);
async function retry(): Promise<void> {
@@ -44,7 +30,8 @@
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function share(): Promise<void> {
const r = await shareText(report, t('launch.errorTitle'));
if (!diag) return;
const r = await shareText(diag.report, title);
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
if (r === 'copied') {
copied = true;
@@ -57,12 +44,14 @@
{#if diag}
<div class="boot">
<div class="card">
<h1>{t('launch.errorTitle')}</h1>
<h1>{title}</h1>
<p class="msg">{t('launch.errorBody')}</p>
<pre class="diag">{report}</pre>
<pre class="diag">{diag.report}</pre>
<div class="actions">
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
{#if diag.retry}
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
{/if}
</div>
</div>
</div>