feat(vk): show a launch diagnostic on a direct /vk/ open instead of a guest
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
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.
This commit is contained in:
@@ -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
|
`/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
|
— 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
|
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
|
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,
|
`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
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
|
|||||||
+4
-1
@@ -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
|
`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
|
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
|
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
|
**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
|
(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
|
is provisioned on the first request but only becomes a durable account once the code
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
тихого повтора «не удалось
|
тихого повтора «не удалось
|
||||||
загрузить» действует и внутри VK.
|
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||||
|
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||||
|
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||||
|
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
|
|||||||
+6
-5
@@ -25,7 +25,7 @@
|
|||||||
import Blocked from './screens/Blocked.svelte';
|
import Blocked from './screens/Blocked.svelte';
|
||||||
import AccountDeleted from './screens/AccountDeleted.svelte';
|
import AccountDeleted from './screens/AccountDeleted.svelte';
|
||||||
import BootError from './screens/BootError.svelte';
|
import BootError from './screens/BootError.svelte';
|
||||||
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
import LaunchError from './screens/LaunchError.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
|
// 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>
|
<div class="splash">{t('common.loading')}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if app.launchError}
|
{:else if app.launchError}
|
||||||
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
|
<!-- A dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch: a compact,
|
||||||
of bouncing to the marketing landing (also the probe for the empty-initData failure on
|
shareable diagnostic screen instead of bouncing to the marketing landing (Telegram) or
|
||||||
some Android clients). -->
|
silently proceeding as a guest (VK). Also the probe for the empty-initData failure on some
|
||||||
<TelegramLaunchError />
|
Android Telegram clients. -->
|
||||||
|
<LaunchError />
|
||||||
{:else if app.bootError}
|
{:else if app.bootError}
|
||||||
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
<!-- 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. -->
|
show the retry screen instead of falling back to the web login. -->
|
||||||
|
|||||||
+22
-12
@@ -14,8 +14,7 @@ import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type
|
|||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
telegramClose,
|
telegramClose,
|
||||||
collectTelegramDiag,
|
telegramLaunchDiag,
|
||||||
type TelegramDiag,
|
|
||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
hasLaunchFragment,
|
hasLaunchFragment,
|
||||||
loadTelegramSDK,
|
loadTelegramSDK,
|
||||||
@@ -33,7 +32,8 @@ import {
|
|||||||
telegramCloudGet,
|
telegramCloudGet,
|
||||||
telegramCloudSet,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} 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 { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||||
import { isStandalone } from './pwa';
|
import { isStandalone } from './pwa';
|
||||||
import { registerServiceWorker } from './pwa.svelte';
|
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
|
* 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. */
|
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||||
bootError: boolean;
|
bootError: boolean;
|
||||||
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
/** On a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch, set to a
|
||||||
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
* privacy-safe diagnostic snapshot. App.svelte then renders the compact, shareable launch-error
|
||||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
* screen (screens/LaunchError) — a probe for why Telegram delivered no initData (seen on some
|
||||||
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
|
* Android clients), or why /vk/ carried no signed launch — instead of bouncing to the landing
|
||||||
launchError: TelegramDiag | null;
|
* (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
|
/** 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. */
|
* the header title ten times in quick succession. A support aid; carries no secrets. */
|
||||||
debugOpen: boolean;
|
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
|
// 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.
|
// with the developer, instead of bouncing the visitor to the marketing landing.
|
||||||
if (onTelegramPath() && !insideTelegram()) {
|
if (onTelegramPath() && !insideTelegram()) {
|
||||||
app.launchError = collectTelegramDiag();
|
app.launchError = telegramLaunchDiag();
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -875,10 +876,19 @@ export async function bootstrap(): Promise<void> {
|
|||||||
return;
|
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
|
// 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
|
// 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
|
// VKWebAppGetUserInfo, since VK omits it from the signed params.
|
||||||
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
|
|
||||||
if (onVKPath() && insideVK()) {
|
if (onVKPath() && insideVK()) {
|
||||||
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
|
// 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).
|
// 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.
|
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
|
||||||
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
|
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
|
||||||
if (!insideTelegram()) {
|
if (!insideTelegram()) {
|
||||||
app.launchError = collectTelegramDiag();
|
app.launchError = telegramLaunchDiag();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.launchError = null;
|
app.launchError = null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const en = {
|
|||||||
'boot.errorBody': 'Please try again in a moment.',
|
'boot.errorBody': 'Please try again in a moment.',
|
||||||
|
|
||||||
'launch.errorTitle': "Can't open in Telegram",
|
'launch.errorTitle': "Can't open in Telegram",
|
||||||
|
'launch.errorTitleVk': "Can't open in VK",
|
||||||
'launch.errorBody': 'Screenshot or share this with the developer.',
|
'launch.errorBody': 'Screenshot or share this with the developer.',
|
||||||
'launch.share': 'Share',
|
'launch.share': 'Share',
|
||||||
'launch.copied': 'Copied',
|
'launch.copied': 'Copied',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
||||||
|
|
||||||
'launch.errorTitle': 'Не открывается в Telegram',
|
'launch.errorTitle': 'Не открывается в Telegram',
|
||||||
|
'launch.errorTitleVk': 'Не открывается в VK',
|
||||||
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
|
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
|
||||||
'launch.share': 'Поделиться',
|
'launch.share': 'Поделиться',
|
||||||
'launch.copied': 'Скопировано',
|
'launch.copied': 'Скопировано',
|
||||||
|
|||||||
@@ -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
@@ -4,6 +4,7 @@
|
|||||||
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
|
// 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.
|
// 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';
|
import type { TelegramThemeParams } from './theme';
|
||||||
|
|
||||||
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
|
/** 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. */
|
* the launch-error screen reports. Only field names are ever inspected, never their values. */
|
||||||
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
|
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
|
/** 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
|
* 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. */
|
* 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
|
* 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 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[];
|
fieldsPresent: string[];
|
||||||
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
|
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
|
||||||
fieldsMissing: string[];
|
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
|
* 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 —
|
* 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 {
|
export function collectTelegramDiag(): TelegramDiag {
|
||||||
const w = webApp();
|
const w = webApp();
|
||||||
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
|
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
|
||||||
const initData = w?.initData ?? '';
|
const initData = w?.initData ?? '';
|
||||||
const fragData = initData ? '' : launchFragmentData();
|
const fragData = initData ? '' : launchFragmentData();
|
||||||
const present = initDataFieldNames(initData || fragData);
|
const present = queryFieldNames(initData || fragData);
|
||||||
const ua = uaData();
|
|
||||||
const navPlatform =
|
|
||||||
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
|
|
||||||
return {
|
return {
|
||||||
hasSDK: sdk,
|
hasSDK: sdk,
|
||||||
hasWebApp: !!w,
|
hasWebApp: !!w,
|
||||||
@@ -576,13 +538,29 @@ export function collectTelegramDiag(): TelegramDiag {
|
|||||||
hashHadTgData: fragData.length > 0,
|
hashHadTgData: fragData.length > 0,
|
||||||
fieldsPresent: present,
|
fieldsPresent: present,
|
||||||
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
|
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
|
* 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,
|
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
routeExternalLinkInVK,
|
routeExternalLinkInVK,
|
||||||
vkAndroidWebView,
|
vkAndroidWebView,
|
||||||
vkAppId,
|
vkAppId,
|
||||||
|
vkDiagLines,
|
||||||
vkExternalBrowserUrl,
|
vkExternalBrowserUrl,
|
||||||
|
vkLaunchDiag,
|
||||||
vkLaunchParams,
|
vkLaunchParams,
|
||||||
vkStartParam,
|
vkStartParam,
|
||||||
appearanceForBg,
|
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', () => {
|
describe('appearanceForBg', () => {
|
||||||
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
|
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
|
||||||
expect(appearanceForBg('#0f1115')).toBe('dark');
|
expect(appearanceForBg('#0f1115')).toBe('dark');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
||||||
// call outside VK.
|
// call outside VK.
|
||||||
|
|
||||||
|
import { clientEnvLines, queryFieldNames, type LaunchDiag } from './launchdiag';
|
||||||
import { isExternalHttpUrl, type Haptic } from './telegram';
|
import { isExternalHttpUrl, type Haptic } from './telegram';
|
||||||
|
|
||||||
async function bridge() {
|
async function bridge() {
|
||||||
@@ -108,6 +109,66 @@ export function vkPlatform(): string {
|
|||||||
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? '';
|
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
|
* 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
|
* (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView
|
||||||
|
|||||||
@@ -1,34 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
|
// Shown on a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch — instead of
|
||||||
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
|
// bouncing to the marketing landing (Telegram) or silently proceeding as a guest (VK). It states the
|
||||||
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
|
// problem plainly and renders a compact, privacy-safe diagnostic snapshot (app.launchError, taken at
|
||||||
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
|
// the moment of failure) sized to fit one screenshot, with a Share button (the OS share sheet, or a
|
||||||
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
|
// clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why the launch
|
||||||
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
|
// carried no sign-in data. The snapshot carries only presence / identification signals and field
|
||||||
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
|
// 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 { app, retryTelegramLaunch } from '../lib/app.svelte';
|
||||||
import { shareText } from '../lib/share';
|
import { shareText } from '../lib/share';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
const diag = $derived(app.launchError);
|
const diag = $derived(app.launchError);
|
||||||
|
// The title names the platform the entry belongs to; the body and the report are platform-neutral.
|
||||||
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
|
const title = $derived(t(diag?.platform === 'vk' ? 'launch.errorTitleVk' : 'launch.errorTitle'));
|
||||||
// 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')
|
|
||||||
: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
let retrying = $state(false);
|
let retrying = $state(false);
|
||||||
async function retry(): Promise<void> {
|
async function retry(): Promise<void> {
|
||||||
@@ -44,7 +30,8 @@
|
|||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
async function share(): Promise<void> {
|
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.
|
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
|
||||||
if (r === 'copied') {
|
if (r === 'copied') {
|
||||||
copied = true;
|
copied = true;
|
||||||
@@ -57,12 +44,14 @@
|
|||||||
{#if diag}
|
{#if diag}
|
||||||
<div class="boot">
|
<div class="boot">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>{t('launch.errorTitle')}</h1>
|
<h1>{title}</h1>
|
||||||
<p class="msg">{t('launch.errorBody')}</p>
|
<p class="msg">{t('launch.errorBody')}</p>
|
||||||
<pre class="diag">{report}</pre>
|
<pre class="diag">{diag.report}</pre>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user