Compare commits
34 Commits
7e142d471c
...
v1.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce9f7e9b4 | |||
| 522beec82e | |||
| 3b485883ee | |||
| efeed17abc | |||
| cc34622630 | |||
| f6d256215a | |||
| fe5a3d6d3b | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -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
|
||||
|
||||
+6
-2
@@ -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
|
||||
@@ -539,7 +542,8 @@ at any time (games already lost stay lost).
|
||||
|
||||
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
||||
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
||||
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||||
or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
|
||||
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||||
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
||||
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||||
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||||
|
||||
@@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||
тихого повтора «не удалось
|
||||
загрузить» действует и внутри VK.
|
||||
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||
@@ -552,7 +555,9 @@ high-rate флага. С карточки пользователя операт
|
||||
|
||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
||||
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||
|
||||
+2
-1
@@ -121,7 +121,8 @@ web code exchange via `internal/vkid`, and both forward the trusted `external_id
|
||||
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
|
||||
300/min·user (burst 80, sized for multi-device play), admin
|
||||
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
|
||||
email-code 5/10 min·IP (burst 2).
|
||||
email-code 5/10 min·IP (burst 4, covering request + a mistyped code + the
|
||||
correct one; defence-in-depth over the backend's per-code 5-attempt/15-min cap).
|
||||
|
||||
Every rejection increments `gateway_rate_limited_total{class}`
|
||||
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
||||
|
||||
@@ -165,7 +165,12 @@ func DefaultRateLimit() RateLimitConfig {
|
||||
// because multi-device play tripped the old 120/40.
|
||||
UserPerMinute: 300, UserBurst: 80,
|
||||
AdminPerMinute: 60, AdminBurst: 20,
|
||||
EmailPer10Min: 5, EmailBurst: 2,
|
||||
// Email-code path (per IP), defence-in-depth over the backend's own per-code guards
|
||||
// (a 5-attempt cap + 15-min TTL + per-recipient send throttle). Burst 4 so the honest flow
|
||||
// — request a code, mistype once or twice, then enter the right one — is not throttled
|
||||
// mid-login: a request + wrong-code + right-code sequence exhausted the old burst of 2 and
|
||||
// tripped the limit on the correct code, which the client mis-read as going offline.
|
||||
EmailPer10Min: 5, EmailBurst: 4,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
@@ -44,3 +45,20 @@ func TestPerWindow(t *testing.T) {
|
||||
t.Fatalf("per-window burst = %v, want [true true false]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailBurstAllowsHonestLoginFlow guards the default email-code burst against being
|
||||
// tightened back below the honest login sequence: requesting a code, submitting one wrong
|
||||
// code, then the correct one are three immediate email-class events from one IP, and all
|
||||
// must pass. A burst of 2 denied the correct-code login, which the client mis-read as going
|
||||
// offline and could not recover from on the session-less login screen. This is defence in
|
||||
// depth over the backend's own per-code attempt cap and code TTL.
|
||||
func TestEmailBurstAllowsHonestLoginFlow(t *testing.T) {
|
||||
rl := config.DefaultRateLimit()
|
||||
p := ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst)
|
||||
l := ratelimit.New()
|
||||
for i, want := range []bool{true, true, true} { // request code, wrong code, right code
|
||||
if got := l.Allow("email:198.51.100.7", p); got != want {
|
||||
t.Fatalf("honest email event %d allowed = %v, want %v", i+1, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
the bot applies it — but only to a member currently in the chat (it probes one user with
|
||||
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
|
||||
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
|
||||
`chat_member` updates, which Telegram delivers only to a chat admin.
|
||||
`chat_member` updates, which Telegram delivers only to a chat admin. The bot also **removes
|
||||
the automatic pin** Telegram places on every channel post auto-forwarded into this linked
|
||||
chat (the `Message.is_automatic_forward` marker): it unpins only that one message
|
||||
(`unpinChatMessage` by id), so a pin set by a human admin — or by the bot for another
|
||||
message — is never touched. This needs the **pin-messages** right (`can_pin_messages`) in
|
||||
addition to "Ban users"; a startup self-check warns when it is missing.
|
||||
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
|
||||
supergroup**, the bot runs a direct support channel for users who message it. A user's first
|
||||
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
|
||||
|
||||
@@ -285,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
|
||||
// The same moderated chat also gets the linked channel's auto-forwarded posts un-pinned,
|
||||
// which needs the pin-messages right; warn (separately from gating) when it is missing.
|
||||
if !m.Administrator.CanPinMessages {
|
||||
t.log.Warn("auto-unpin of linked channel posts WILL NOT WORK: the bot lacks the pin-messages right",
|
||||
zap.Int64("chat_id", t.chatID))
|
||||
}
|
||||
}
|
||||
|
||||
// Notify sends a notification message with a Mini App launch button that opens the
|
||||
@@ -405,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleSuccessfulPayment(ctx, update.Message)
|
||||
return
|
||||
}
|
||||
// A channel post automatically forwarded into the moderated discussion chat is
|
||||
// auto-pinned by Telegram (a non-disableable behaviour). Unpin that one message so only
|
||||
// deliberate pins remain; it targets this exact message id, so a pin set by a human admin
|
||||
// — or by the bot for another message — is never touched.
|
||||
if m := update.Message; m != nil && m.IsAutomaticForward && t.chatID != 0 && m.Chat.ID == t.chatID {
|
||||
t.handleLinkedChannelPin(ctx, m)
|
||||
return
|
||||
}
|
||||
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||
// is either an operator's reply in the support chat or a user's direct message to
|
||||
// relay. Everything else falls through to the Mini App launch reply.
|
||||
@@ -421,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleStart(ctx, api, update)
|
||||
}
|
||||
|
||||
// handleLinkedChannelPin removes Telegram's automatic pin from a linked channel's post
|
||||
// that was auto-forwarded into the moderated discussion chat. It unpins only that exact
|
||||
// message (by id), so a pin set by a human admin — or by the bot for another message — is
|
||||
// left untouched. It needs the bot to hold the pin-messages right in the chat; a missing
|
||||
// right surfaces as a warning (the unpin then no-ops), not a crash.
|
||||
func (t *Bot) handleLinkedChannelPin(ctx context.Context, m *models.Message) {
|
||||
if _, err := t.api.UnpinChatMessage(ctx, &tgbot.UnpinChatMessageParams{
|
||||
ChatID: m.Chat.ID,
|
||||
MessageID: m.ID,
|
||||
}); err != nil {
|
||||
t.log.Warn("could not unpin an auto-forwarded channel post; does the bot have the pin-messages right?",
|
||||
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
t.log.Debug("unpinned an auto-forwarded channel post",
|
||||
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID))
|
||||
}
|
||||
|
||||
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
|
||||
// bot-link client backing it is built after the bot).
|
||||
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
||||
|
||||
@@ -19,10 +19,11 @@ const (
|
||||
)
|
||||
|
||||
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
|
||||
// scripted getChatMember status, and records restrictChatMember calls.
|
||||
// scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
|
||||
type chatAPI struct {
|
||||
memberStatus string // the status getChatMember reports (default "left")
|
||||
restricts []restrictCall
|
||||
unpins []unpinCall
|
||||
}
|
||||
|
||||
type restrictCall struct {
|
||||
@@ -30,6 +31,13 @@ type restrictCall struct {
|
||||
canSend bool // can_send_messages in the applied permissions
|
||||
}
|
||||
|
||||
// unpinCall records a single unpinChatMessage request (raw form values, mirroring the
|
||||
// restrictCall style).
|
||||
type unpinCall struct {
|
||||
chatID string
|
||||
messageID string
|
||||
}
|
||||
|
||||
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
||||
@@ -47,6 +55,9 @@ func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
|
||||
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/unpinChatMessage"):
|
||||
a.unpins = append(a.unpins, unpinCall{chatID: r.FormValue("chat_id"), messageID: r.FormValue("message_id")})
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
default:
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
}
|
||||
@@ -237,3 +248,44 @@ func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// autoForwardUpdate builds a message update as it lands in the discussion chat: a post in
|
||||
// chat chatID with id msgID and is_automatic_forward set to auto.
|
||||
func autoForwardUpdate(chatID int64, msgID int, auto bool) *models.Update {
|
||||
return &models.Update{Message: &models.Message{
|
||||
ID: msgID,
|
||||
Chat: models.Chat{ID: chatID, Type: models.ChatTypeSupergroup},
|
||||
IsAutomaticForward: auto,
|
||||
}}
|
||||
}
|
||||
|
||||
func TestAutoForwardUnpinned(t *testing.T) {
|
||||
// A channel post auto-forwarded into the moderated chat is unpinned by its exact id.
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, true))
|
||||
if len(api.unpins) != 1 || api.unpins[0].chatID != "555" || api.unpins[0].messageID != "42" {
|
||||
t.Fatalf("unpins = %+v, want one {555, 42}", api.unpins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAutoForwardNotUnpinned(t *testing.T) {
|
||||
// A normal message in the moderated chat (e.g. another admin's pinned message) is never
|
||||
// unpinned — the is_automatic_forward filter is what guards every other pin.
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, false))
|
||||
if len(api.unpins) != 0 {
|
||||
t.Fatalf("unpins = %+v, want none for a non-auto-forward message", api.unpins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoForwardOtherChatIgnored(t *testing.T) {
|
||||
// An auto-forward in some other chat (not the configured moderated one) is ignored.
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(999, 42, true))
|
||||
if len(api.unpins) != 0 {
|
||||
t.Fatalf("unpins = %+v, want none for a foreign chat", api.unpins)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
||||
|
||||
'launch.errorTitle': 'Не открывается в Telegram',
|
||||
'launch.errorTitleVk': 'Не открывается в VK',
|
||||
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
|
||||
'launch.share': 'Поделиться',
|
||||
'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 [];
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,15 @@ describe('toGatewayError', () => {
|
||||
});
|
||||
|
||||
describe('retryable', () => {
|
||||
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
|
||||
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
|
||||
expect(retryable('rate_limited', 'games.list')).toBe(true);
|
||||
expect(retryable('rate_limited', 'chat.post')).toBe(true);
|
||||
it('does not auto-retry a rate-limit rejection (a deliberate throttle, surfaced not spun)', () => {
|
||||
// A rate-limit is the server saying "slow down": auto-retrying cannot succeed until the bucket
|
||||
// refills, only freezes the calling button for the whole backoff and feeds the gateway's ban
|
||||
// tripwire with more rejections. It is surfaced to the caller instead. (Regression guard: the
|
||||
// old retry path also called reportOffline, which on the session-less login screen — where the
|
||||
// reachability probe can never heal — latched a stuck phantom offline.)
|
||||
expect(retryable('rate_limited', 'game.submit_play')).toBe(false);
|
||||
expect(retryable('rate_limited', 'games.list')).toBe(false);
|
||||
expect(retryable('rate_limited', 'auth.email.login')).toBe(false);
|
||||
});
|
||||
|
||||
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
|
||||
@@ -53,9 +58,12 @@ describe('retryable', () => {
|
||||
});
|
||||
|
||||
describe('isConnectionCode', () => {
|
||||
it('flags the transport/rate-limit codes the indicator covers', () => {
|
||||
it('flags only the transport connectivity code (a rate-limit is a surfaced message, not connectivity)', () => {
|
||||
expect(isConnectionCode('unavailable')).toBe(true);
|
||||
expect(isConnectionCode('rate_limited')).toBe(true);
|
||||
// A rate-limit must NOT read as connectivity: isConnectionCode gates both reportOffline (the
|
||||
// offline chrome) and handleError's toast suppression, so treating it as connectivity produced
|
||||
// a silent, stuck phantom offline on the login screen. It surfaces as error.rate_limited.
|
||||
expect(isConnectionCode('rate_limited')).toBe(false);
|
||||
expect(isConnectionCode('not_your_turn')).toBe(false);
|
||||
expect(isConnectionCode('internal')).toBe(false);
|
||||
});
|
||||
|
||||
+16
-11
@@ -2,11 +2,14 @@
|
||||
// fails at the transport level the app retries it with capped exponential backoff while showing
|
||||
// the "Connecting…" indicator, instead of flashing a red toast each time.
|
||||
//
|
||||
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
|
||||
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
|
||||
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
|
||||
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
|
||||
// re-enables on reconnect, so the player re-issues it deliberately).
|
||||
// Retry policy: a transport 'unavailable' is ambiguous for a mutation (its response could have been
|
||||
// lost after the backend applied it), so only **read-only** ops are auto-retried on 'unavailable'; a
|
||||
// mutation is surfaced instead (its button is disabled while offline and re-enables on reconnect, so
|
||||
// the player re-issues it deliberately). A rate-limit rejection (ResourceExhausted) is NOT retried and
|
||||
// is NOT a connectivity code: it is a deliberate "slow down", so auto-retrying cannot succeed until the
|
||||
// bucket refills — it only freezes the calling button for the whole backoff and feeds the gateway's ban
|
||||
// tripwire with more rejections. It is surfaced as its own message (error.rate_limited) and never flips
|
||||
// the offline chrome, which the session-less login screen has no session-bearing probe to recover from.
|
||||
|
||||
import { Code, ConnectError } from '@connectrpc/connect';
|
||||
import { GatewayError } from './client';
|
||||
@@ -65,20 +68,22 @@ export const READ_OPS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always
|
||||
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
|
||||
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
|
||||
* retryable reports whether a failed op should be auto-retried. A transport 'unavailable' is retried
|
||||
* only for read-only ops, never a mutation; every other code — a rate-limit (a deliberate throttle,
|
||||
* surfaced not spun), a domain rejection, not-found, … — is final.
|
||||
*/
|
||||
export function retryable(code: string, op: string): boolean {
|
||||
if (code === 'rate_limited') return true;
|
||||
if (code === 'unavailable') return READ_OPS.has(op);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
|
||||
* indicator covers (so the UI suppresses its red toast). */
|
||||
* indicator covers — so the UI suppresses its red toast and reportOffline may flip the offline
|
||||
* chrome. A rate-limit is deliberately NOT one: it is a genuine server signal, surfaced as its own
|
||||
* "slow down" message, never the offline chrome (which the session-less login screen, whose
|
||||
* reachability probe needs a bearer token, cannot recover from). */
|
||||
export function isConnectionCode(code: string): boolean {
|
||||
return code === 'unavailable' || code === 'rate_limited';
|
||||
return code === 'unavailable';
|
||||
}
|
||||
|
||||
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
|
||||
|
||||
+24
-46
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user