From cc34622630d31551638c74c7d3902c304a8cfb11 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 13 Jul 2026 23:25:26 +0200 Subject: [PATCH] feat(vk): show a launch diagnostic on a direct /vk/ open instead of a guest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the dedicated /vk/ entry directly in an ordinary browser (no signed VK launch) fell through to the web flow and silently started a throwaway guest, which is wrong for a VK-only entry. Mirror the existing /telegram/ launch-error behaviour: render a compact, shareable, privacy-safe diagnostic screen instead. - Boot: a new branch `onVKPath() && !insideVK()` sets app.launchError and stops the fall-through, the VK counterpart of the /telegram/ diagnostic guard. - Diagnostic (passive, no VK Bridge round-trip): reads the URL launch-parameter NAMES (never the `sign` value — auth material), whether the URL was signed, `vk_platform`, the iframe/referrer context, plus the shared client-environment lines. VK signs the launch URL at load, so — unlike Telegram's initData — the parameters never arrive late; hence the VK screen offers Share only, no Retry. - Generalise the screen: TelegramLaunchError.svelte -> LaunchError.svelte, which renders a neutral pre-formatted report and a per-platform title, with Retry gated on a `retry` flag (Telegram true, VK false). app.launchError is now a neutral LaunchDiag { platform, report, retry }. - New lib/launchdiag.ts holds the shared pieces both platforms reuse: the LaunchDiag shape, the client-environment lines, and the query field-NAME reader (moved out of telegram.ts so VK does not duplicate them). Tests: pure vkDiagLines units, incl. a guard that the `sign` value never leaks into the report. i18n: launch.errorTitleVk (en/ru). Docs: FUNCTIONAL (+ru) user story and ARCHITECTURE entry-path note. --- docs/ARCHITECTURE.md | 4 +- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- ui/src/App.svelte | 11 +-- ui/src/lib/app.svelte.ts | 34 +++++---- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/launchdiag.ts | 71 +++++++++++++++++++ ui/src/lib/telegram.ts | 70 +++++++----------- ui/src/lib/vk.test.ts | 41 +++++++++++ ui/src/lib/vk.ts | 61 ++++++++++++++++ ...mLaunchError.svelte => LaunchError.svelte} | 47 +++++------- 12 files changed, 256 insertions(+), 95 deletions(-) create mode 100644 ui/src/lib/launchdiag.ts rename ui/src/screens/{TelegramLaunchError.svelte => LaunchError.svelte} (55%) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c0f08e6..3e4d037 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 32d31e6..70e4e02 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c8808cd..49d9fdd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран тихого повтора «не удалось -загрузить» действует и внутри VK. +загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере — +вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается +компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой +на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот. **Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения — локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная, diff --git a/ui/src/App.svelte b/ui/src/App.svelte index d656247..e768811 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -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 @@
{t('common.loading')}
{/if} {:else if app.launchError} - - + + {:else if app.bootError} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 6a17009..bf5c7ab 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 { // 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 { 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 { // 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; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index c450f93..6590022 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 6b12b58..0660455 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -28,6 +28,7 @@ export const ru: Record = { 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', 'launch.errorTitle': 'Не открывается в Telegram', + 'launch.errorTitleVk': 'Не открывается в VK', 'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.', 'launch.share': 'Поделиться', 'launch.copied': 'Скопировано', diff --git a/ui/src/lib/launchdiag.ts b/ui/src/lib/launchdiag.ts new file mode 100644 index 0000000..2c3f03d --- /dev/null +++ b/ui/src/lib/launchdiag.ts @@ -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 []; + } +} diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index a570a82..50b0772 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -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, diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts index 82a7df8..9f93f99 100644 --- a/ui/src/lib/vk.test.ts +++ b/ui/src/lib/vk.test.ts @@ -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'); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 68d6a5f..b1cf75b 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -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 diff --git a/ui/src/screens/TelegramLaunchError.svelte b/ui/src/screens/LaunchError.svelte similarity index 55% rename from ui/src/screens/TelegramLaunchError.svelte rename to ui/src/screens/LaunchError.svelte index ecf9c58..0769817 100644 --- a/ui/src/screens/TelegramLaunchError.svelte +++ b/ui/src/screens/LaunchError.svelte @@ -1,34 +1,20 @@