Compare commits

..

7 Commits

17 changed files with 202 additions and 805 deletions
+2 -3
View File
@@ -1040,9 +1040,8 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
Single public origin, path-routed. The Vite build has two entries: a lightweight Single public origin, path-routed. The Vite build has two entries: a lightweight
**landing page** and the game **SPA**. The gateway **embeds** the SPA build **landing page** and the game **SPA**. The gateway **embeds** the SPA build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at (`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data `/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead redirects to the root — the client-side guard); a stray hit on the gateway's `/`
of redirecting away); a stray hit on the gateway's `/`
308-redirects to `/app/`. The **landing** ships in its own static container: the 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
+9 -17
View File
@@ -8,13 +8,7 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**: `prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
launch that delivered none, as seen on some Android clients) the app renders a compact,
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
never load it.
## Layout shell (`components/Screen.svelte`) ## Layout shell (`components/Screen.svelte`)
@@ -97,16 +91,14 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` / theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
app's **own back chevron** (Header) drives back-navigation on every platform — the native native header **BackButton** drives back-navigation (the app's chevron is hidden in
Telegram BackButton is not used, as it does not render reliably in the windowed Mini App; Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
**HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API
bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid 8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window;
the native header (and its BackButton) and the Android system swipe-back then minimised the app, **closing confirmation** is enabled while a game is open **on mobile only** (on desktop
so it stays windowed with Telegram's thin native header (close) above the app's own header; move closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten auto-save); **vertical swipes** (swipe-to-minimise)
quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
the board scroll; **external links** (the word-check
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink` dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
so Telegram shows them in its in-app browser instead of the WebView's "open this link?" so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
confirmation a plain `target=_blank` triggers; and a live stream dropped confirmation a plain `target=_blank` triggers; and a live stream dropped
+5 -5
View File
@@ -1,13 +1,13 @@
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
// All e2e specs run hermetically against the mock transport. Neutralise the real // All e2e specs run hermetically against the mock transport. Neutralise the real
// telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the // telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks
// suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise // on telegram.org — it is unreachable from the CI runner, and a render-blocking
// the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the // <script> to it would hang every page load. Specs that exercise the Telegram launch
// dynamic load short-circuits on the already-present SDK. // inject their own window.Telegram via addInitScript before navigating.
export const test = base.extend({ export const test = base.extend({
page: async ({ page }, use) => { page: async ({ page }, use) => {
await page.route('**/telegram-web-app.js*', (route) => await page.route('**/telegram-web-app.js', (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }), route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
); );
await use(page); await use(page);
+5 -24
View File
@@ -107,30 +107,11 @@ test('inside Telegram, a failed launch shows the retry screen, not the web login
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
}); });
test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a redirect', async ({ test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
page,
}) => {
await page.goto('/telegram/'); await page.goto('/telegram/');
// The entry no longer bounces a visitor without Telegram sign-in data to the marketing landing; // The guard sends a non-Telegram visitor back to the root, where the normal
// it shows a compact diagnostic screen (Share + Retry) that helps pinpoint why initData was // (guest / email) login is shown.
// absent — notably the Android empty-initData failure. await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); await expect(page).not.toHaveURL(/\/telegram\//);
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
// It stays on /telegram/ (no redirect) and never shows the web (guest) login.
await expect(page).toHaveURL(/\/telegram\//);
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
// fetch must not strand the page — the diagnostic screen still renders, reporting no SDK. (This
// route overrides the fixture's empty-body fulfill; the later registration wins.)
await page.route('**/telegram-web-app.js*', (route) => route.abort());
await page.goto('/telegram/');
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// The diagnostic names the load outcome: a failed fetch reads as sdk-load: error.
await expect(page.getByText('sdk-load: error')).toBeVisible();
}); });
+3 -5
View File
@@ -2,11 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a <!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside
render-blocking <script> to telegram.org hangs the whole page on a network that blocks Telegram (initData is empty), so it loads on every entry. -->
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even <script src="https://telegram.org/js/telegram-web-app.js"></script>
the launch-diagnostic screen. The app loads it dynamically, with a timeout, only on a
Telegram entry — see lib/telegram.ts loadTelegramSDK and lib/app.svelte.ts bootstrap. -->
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch <!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). --> to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta <meta
+16 -13
View File
@@ -2,13 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte'; import { app, bootstrap } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte'; import { navigate, router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte'; import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte'; import Toast from './components/Toast.svelte';
import Splash from './components/Splash.svelte'; import Splash from './components/Splash.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte'; import NewGame from './screens/NewGame.svelte';
@@ -19,7 +19,6 @@
import Feedback from './screens/Feedback.svelte'; import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte'; import Blocked from './screens/Blocked.svelte';
import BootError from './screens/BootError.svelte'; import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => { onMount(() => {
void bootstrap(); void bootstrap();
@@ -30,6 +29,19 @@
// another screen is not covered. // another screen is not covered.
const routeIsLobby = $derived(router.route.name === 'lobby'); const routeIsLobby = $derived(router.route.name === 'lobby');
// Inside Telegram, drive its native header back button: show it on any sub-screen
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
$effect(() => {
if (!insideTelegram()) return;
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
let target = '/';
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the // Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the // lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so // screen out to the right and reveals the lobby (back). Transitions are local, so
@@ -72,11 +84,6 @@
{#if !routeIsLobby} {#if !routeIsLobby}
<div class="splash">{t('common.loading')}</div> <div class="splash">{t('common.loading')}</div>
{/if} {/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 />
{: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. -->
@@ -121,14 +128,10 @@
<StaleInviteModal /> <StaleInviteModal />
<WelcomeRedeemModal /> <WelcomeRedeemModal />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError}
<Splash /> <Splash />
{/if} {/if}
{#if app.debugOpen}
<DebugPanel />
{/if}
<style> <style>
.splash { .splash {
height: 100%; height: 100%;
-71
View File
@@ -1,71 +0,0 @@
<script lang="ts">
// Hidden on-device debug panel, opened by tapping the header title ten times (Header.svelte) and
// closed by tapping anywhere except the Share control. It shows a privacy-safe client diagnostic
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
import { app, closeDebug } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { shareText } from '../lib/share';
import { telegramChromeDiag } from '../lib/telegram';
const report = [
`app: ${__APP_VERSION__}`,
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
telegramChromeDiag(),
].join('\n');
let label = $state('Share');
async function share(e: MouseEvent): Promise<void> {
e.stopPropagation(); // a tap on Share shares; it must not also close the panel
const r = await shareText(report, `Scrabble debug ${__APP_VERSION__}`);
if (r === 'copied') {
label = 'Copied';
setTimeout(() => (label = 'Share'), 1500);
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={closeDebug}>
<button class="share" onclick={share}>{label}</button>
<pre class="body">{report}</pre>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 10000;
/* Drawn from the top; the content clears the app header (~56px + the device safe-area). */
padding: calc(var(--tg-safe-top, 0px) + 56px) 12px 16px;
background: rgba(0, 0, 0, 0.82);
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.share {
flex: 0 0 auto;
padding: 7px 16px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
font-size: 0.95rem;
}
.body {
margin: 0;
width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.45;
color: #d6e6ff;
}
</style>
+6 -21
View File
@@ -1,30 +1,17 @@
<script lang="ts"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app, openDebug } from '../lib/app.svelte'; import { app } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte'; import AdBanner from './AdBanner.svelte';
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props(); let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
// The app always shows its own back chevron when there is a back target — on every platform, in // Inside Telegram the native header back button (App.svelte) is the back control, so
// and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in // the app's own chevron is hidden to avoid two back affordances.
// the windowed Mini App (relying on it would lose back navigation there). const showBack = $derived(!!back && !insideTelegram());
const showBack = $derived(!!back);
// Ten quick taps on the title open the hidden debug panel (components/DebugPanel) — a support aid.
let titleTaps = 0;
let lastTitleTap = 0;
function onTitleTap(): void {
const now = Date.now();
titleTaps = now - lastTitleTap < 400 ? titleTaps + 1 : 1;
lastTitleTap = now;
if (titleTaps >= 10) {
titleTaps = 0;
openDebug();
}
}
</script> </script>
<header class="nav" class:grow> <header class="nav" class:grow>
@@ -37,9 +24,7 @@
<span class="spacer"></span> <span class="spacer"></span>
{/if} {/if}
{#if connection.online} {#if connection.online}
<!-- svelte-ignore a11y_click_events_have_key_events --> <h1>{title}</h1>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<h1 onclick={onTitleTap}>{title}</h1>
{:else} {:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1> <h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if} {/if}
+4 -1
View File
@@ -24,7 +24,7 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramHaptic } from '../lib/telegram'; import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -228,6 +228,8 @@
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack); placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
} }
onMount(() => { onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
// Render instantly from the cache (a game opened before), then refresh in the // Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves. // background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id); const cached = getCachedGame(id);
@@ -577,6 +579,7 @@
clearTimeout(draftSaveTimer); clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
} }
telegramClosingConfirmation(false);
}); });
function onCell(row: number, col: number) { function onCell(row: number, col: number) {
+20 -87
View File
@@ -12,11 +12,7 @@ import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { import {
insideTelegram, insideTelegram,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath, onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
telegramColorScheme, telegramColorScheme,
telegramContentSafeAreaTop, telegramContentSafeAreaTop,
telegramSafeAreaTop, telegramSafeAreaTop,
@@ -25,6 +21,7 @@ import {
telegramLaunch, telegramLaunch,
type TelegramLaunch, type TelegramLaunch,
telegramOnEvent, telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome, telegramSetChrome,
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
@@ -50,14 +47,6 @@ 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
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
launchError: TelegramDiag | null;
/** Whether the 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;
/** Whether the lobby's first cold load has settled (success or error). The loading splash /** Whether the lobby's first cold load has settled (success or error). The loading splash
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean; lobbyReady: boolean;
@@ -107,8 +96,6 @@ export const app = $state<{
}>({ }>({
ready: false, ready: false,
bootError: false, bootError: false,
launchError: null,
debugOpen: false,
lobbyReady: false, lobbyReady: false,
splashDone: false, splashDone: false,
streamAlive: false, streamAlive: false,
@@ -200,16 +187,6 @@ export function dismissWelcomeRedeem(): void {
app.welcomeRedeem = false; app.welcomeRedeem = false;
} }
/** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened
* by tapping the header title ten times — a support aid that shows and shares client diagnostics. */
export function openDebug(): void {
app.debugOpen = true;
}
export function closeDebug(): void {
app.debugOpen = false;
}
/** /**
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby * seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result): unread is any unread entry, message whether one of them * list, a game's state, or a move result): unread is any unread entry, message whether one of them
@@ -553,31 +530,6 @@ function syncViewportHeight(): void {
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
} }
/**
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
* bootstrap and a manual launch retry call it.
*/
function applyTelegramChrome(launch: TelegramLaunch): void {
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
// back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
// drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramDisableVerticalSwipes();
}
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
* the launch-error screen. A network that blocks telegram.org makes the script hang rather than
* fail fast (a connection refusal resolves immediately via the script's error event), so this only
* bounds a true hang; it is generous enough not to misfire on a slow but working network. */
const TELEGRAM_SDK_TIMEOUT_MS = 10000;
export async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
@@ -601,30 +553,33 @@ export async function bootstrap(): Promise<void> {
window.visualViewport.addEventListener('scroll', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewportHeight);
} }
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no // Telegram Mini App launch: apply the platform theme, authenticate via initData,
// longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common // and route any deep-link start parameter. On the dedicated /telegram/ entry path
// where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app // outside Telegram (no initData), refuse to render and send the visitor to the
// or the diagnostic screen below. Skipped on a plain web / native entry, which never needs it. // site root.
if (onTelegramPath() || hasLaunchFragment()) {
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
// clients), render the compact launch-error screen with a diagnostic snapshot the user can share
// with the developer, instead of bouncing the visitor to the marketing landing.
if (onTelegramPath() && !insideTelegram()) { if (onTelegramPath() && !insideTelegram()) {
app.launchError = collectTelegramDiag(); if (typeof location !== 'undefined') location.replace('/');
app.ready = true;
return; return;
} }
if (insideTelegram()) { if (insideTelegram()) {
const launch = telegramLaunch(); const launch = telegramLaunch();
applyTelegramChrome(launch); if (launch.theme) applyTelegramTheme(launch.theme);
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load). // Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
// fighting tile drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
// window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
await bootTelegram(launch); await bootTelegram(launch);
app.ready = true; app.ready = true;
return; return;
@@ -691,28 +646,6 @@ export async function retryTelegramBoot(): Promise<void> {
app.ready = true; app.ready = true;
} }
/**
* retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If
* sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs
* the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the
* current state. It never reloads — telegram-web-app.js consumes the launch fragment on first load,
* so a reload could discard the very data we are waiting for.
*/
export async function retryTelegramLaunch(): Promise<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();
return;
}
app.launchError = null;
app.ready = false;
const launch = telegramLaunch();
applyTelegramChrome(launch);
await bootTelegram(launch);
app.ready = true;
}
/** /**
* routeStartParam navigates a Telegram deep-link start parameter to its target: a * routeStartParam navigates a Telegram deep-link start parameter to its target: a
* specific game, the friends screen with a friend-code redemption, or the lobby * specific game, the friends screen with a friend-code redemption, or the lobby
-5
View File
@@ -14,11 +14,6 @@ export const en = {
'boot.errorTitle': "Couldn't load the game", 'boot.errorTitle': "Couldn't load the game",
'boot.errorBody': 'Please try again in a moment.', 'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
'common.back': 'Back', 'common.back': 'Back',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.ok': 'OK', 'common.ok': 'OK',
-5
View File
@@ -15,11 +15,6 @@ export const ru: Record<MessageKey, string> = {
'boot.errorTitle': 'Не удалось загрузить игру', 'boot.errorTitle': 'Не удалось загрузить игру',
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
'common.back': 'Назад', 'common.back': 'Назад',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.ok': 'ОК', 'common.ok': 'ОК',
+1 -40
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share'; import { pickGcgDelivery, shareOrDownloadGcg } from './share';
import type { GcgExport } from './model'; import type { GcgExport } from './model';
const file = {} as File; const file = {} as File;
@@ -60,42 +60,3 @@ describe('shareOrDownloadGcg', () => {
expect(anchor.click).toHaveBeenCalledOnce(); expect(anchor.click).toHaveBeenCalledOnce();
}); });
}); });
describe('pickTextShare', () => {
it('shares when Web Share is available', () => {
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
});
it('shares when share exists without canShare (text needs no file capability check)', () => {
expect(pickTextShare({ share: async () => {} })).toBe('share');
});
it('copies when there is no Web Share (desktop)', () => {
expect(pickTextShare(undefined)).toBe('copy');
expect(pickTextShare({} as never)).toBe('copy');
});
});
describe('shareText', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses the OS share sheet when available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'title', text: 'diag' });
});
it('copies to the clipboard when Web Share is absent (desktop)', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
expect(await shareText('diag', 'title')).toBe('copied');
expect(writeText).toHaveBeenCalledWith('diag');
});
it('reports failure without a fallback when the share is cancelled', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('failed');
});
});
-39
View File
@@ -51,42 +51,3 @@ function downloadFile(content: string, filename: string): void {
a.remove(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**
* pickTextShare decides how to deliver a plain-text payload: through the OS share sheet (Web Share,
* available on mobile including the Telegram Mini App) or, on a desktop browser without it, a
* clipboard copy. Pure, so it is unit-tested with a mock navigator.
*/
export function pickTextShare(nav: TextShareNav | undefined): 'share' | 'copy' {
if (nav && typeof nav.share === 'function' && (typeof nav.canShare !== 'function' || nav.canShare({ text: 'x' }))) {
return 'share';
}
return 'copy';
}
/**
* shareText delivers text through the OS share sheet where supported, else copies it to the
* clipboard. It reports the path taken — 'shared', 'copied', or 'failed' (a cancelled share or an
* unavailable clipboard) — so the caller can confirm a silent copy to the user. Like
* shareOrDownloadGcg it never strands the webview: a cancelled share simply does nothing.
*/
export async function shareText(text: string, title: string): Promise<'shared' | 'copied' | 'failed'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (!nav) return 'failed';
if (pickTextShare(nav) === 'share') {
try {
await nav.share({ title, text });
return 'shared';
} catch {
return 'failed';
}
}
try {
await nav.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
+65 -106
View File
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
collectTelegramDiag,
insideTelegram, insideTelegram,
loadTelegramSDK,
telegramSdkOutcome,
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramClosingConfirmation,
telegramLaunch, telegramLaunch,
telegramOpenExternalLink, telegramOpenExternalLink,
telegramRequestFullscreen,
} from './telegram'; } from './telegram';
function stubWebApp(initData: string, startParam?: string) { function stubWebApp(initData: string, startParam?: string) {
@@ -46,6 +45,69 @@ describe('telegram launch detection', () => {
}); });
}); });
// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated
// chrome toggles, so the platform gate can be asserted without a real Telegram client.
function stubClient(platform?: string) {
const enable = vi.fn();
const disable = vi.fn();
const requestFullscreen = vi.fn();
vi.stubGlobal('window', {
Telegram: {
WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen },
},
});
return { enable, disable, requestFullscreen };
}
const mobilePlatforms = ['ios', 'android', 'android_x'];
const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
describe('telegramClosingConfirmation', () => {
afterEach(() => vi.unstubAllGlobals());
it('arms the close guard on mobile clients', () => {
for (const p of mobilePlatforms) {
const { enable } = stubClient(p);
telegramClosingConfirmation(true);
expect(enable, `platform=${p}`).toHaveBeenCalledOnce();
}
});
it('skips the close guard on desktop clients (the dialog there is just noise)', () => {
for (const p of desktopPlatforms) {
const { enable } = stubClient(p);
telegramClosingConfirmation(true);
expect(enable, `platform=${p}`).not.toHaveBeenCalled();
}
});
it('always lifts the guard on leave, regardless of platform', () => {
const { disable } = stubClient('tdesktop');
telegramClosingConfirmation(false);
expect(disable).toHaveBeenCalledOnce();
});
});
describe('telegramRequestFullscreen', () => {
afterEach(() => vi.unstubAllGlobals());
it('goes immersive fullscreen on mobile clients', () => {
for (const p of mobilePlatforms) {
const { requestFullscreen } = stubClient(p);
telegramRequestFullscreen();
expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce();
}
});
it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => {
for (const p of desktopPlatforms) {
const { requestFullscreen } = stubClient(p);
telegramRequestFullscreen();
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
}
});
});
describe('telegramOpenExternalLink', () => { describe('telegramOpenExternalLink', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
@@ -93,106 +155,3 @@ describe('routeExternalLinkInTelegram', () => {
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false); expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false);
}); });
}); });
describe('collectTelegramDiag', () => {
afterEach(() => vi.unstubAllGlobals());
it('reports a missing SDK outside Telegram', () => {
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(false);
expect(d.hasWebApp).toBe(false);
expect(d.initDataLen).toBe(0);
expect(d.fieldsPresent).toEqual([]);
expect(d.fieldsMissing).toEqual(['user', 'auth_date', 'hash', 'signature']);
});
it('reads field NAMES (never values) from a non-empty initData', () => {
stubWebApp('query_id=abc&user=%7B%7D&auth_date=1&hash=deadbeef');
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.hasWebApp).toBe(true);
expect(d.initDataLen).toBeGreaterThan(0);
expect(d.fieldsPresent).toEqual(['query_id', 'user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
it('recovers field names from the URL fragment when the SDK left initData empty', () => {
// Telegram passed launch data in the fragment, but WebApp.initData is empty (the Android
// failure this screen diagnoses): the names come from the raw fragment instead, and
// hashHadTgData flags that the data did arrive in the URL.
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '', platform: 'android' } } });
vi.stubGlobal('location', {
hash: '#tgWebAppData=user%3D%257B%257D%26auth_date%3D1%26hash%3Ddeadbeef&tgWebAppVersion=7.0',
pathname: '/telegram/',
});
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.platform).toBe('android');
expect(d.initDataLen).toBe(0);
expect(d.hashHadTgData).toBe(true);
expect(d.fieldsPresent).toEqual(['user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
});
describe('loadTelegramSDK', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('resolves true and records "present" when the SDK is already there', async () => {
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '' } } });
vi.stubGlobal('document', { createElement: vi.fn(), head: { appendChild: vi.fn() } });
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('present');
});
it('records "loaded" when the script defines the WebApp', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: {
appendChild: () => {
(window as unknown as { Telegram: unknown }).Telegram = { WebApp: { initData: '' } };
(script.onload as () => void)();
},
},
});
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('loaded');
});
it('records "no-webapp" when the script loads but defines nothing', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onload as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('no-webapp');
});
it('records "error" when the script fails to load (telegram.org unreachable)', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onerror as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('error');
});
it('records "timeout" when the script neither loads nor fails in time', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', {});
vi.stubGlobal('document', { createElement: () => ({}), head: { appendChild: vi.fn() } });
const p = loadTelegramSDK(10000);
await vi.advanceTimersByTimeAsync(10000);
await expect(p).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('timeout');
});
});
+66 -230
View File
@@ -1,7 +1,6 @@
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded dynamically with a // Telegram Mini App SDK access. The official telegram-web-app.js (loaded in
// timeout by loadTelegramSDK — not a render-blocking <script> in index.html, so a network that // index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses:
// blocks telegram.org cannot hang the page) exposes window.Telegram.WebApp; this wraps the subset // 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 type { TelegramThemeParams } from './theme'; import type { TelegramThemeParams } from './theme';
@@ -10,18 +9,14 @@ interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
platform?: string; platform?: string;
version?: string;
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean; isFullscreen?: boolean;
isExpanded?: boolean;
viewportHeight?: number;
viewportStableHeight?: number;
exitFullscreen?: () => void;
safeAreaInset?: { top: number; bottom: number; left: number; right: number }; safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void; openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void; openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
@@ -29,13 +24,14 @@ interface TelegramWebApp {
setBackgroundColor?: (color: string) => void; setBackgroundColor?: (color: string) => void;
setBottomBarColor?: (color: string) => void; setBottomBarColor?: (color: string) => void;
disableVerticalSwipes?: () => void; disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
HapticFeedback?: { HapticFeedback?: {
impactOccurred?: (style: string) => void; impactOccurred?: (style: string) => void;
notificationOccurred?: (type: string) => void; notificationOccurred?: (type: string) => void;
selectionChanged?: () => void; selectionChanged?: () => void;
}; };
BackButton?: { BackButton?: {
isVisible?: boolean;
show?: () => void; show?: () => void;
hide?: () => void; hide?: () => void;
onClick?: (cb: () => void) => void; onClick?: (cb: () => void) => void;
@@ -57,72 +53,6 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0; return !!w && typeof w.initData === 'string' && w.initData.length > 0;
} }
// The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the
// version the official Mini Apps page currently recommends so newer client features (fullscreen,
// safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs.
const sdkScriptSrc = 'https://telegram.org/js/telegram-web-app.js?62';
/**
* TelegramSdkOutcome records how the dynamic telegram-web-app.js load resolved, surfaced on the
* launch-error screen to tell the failure modes apart — notably 'error' / 'timeout', which mean the
* network could not reach telegram.org (the script blocked or hung):
*
* not-attempted — loadTelegramSDK was never called (a plain web / native entry)
* present — window.Telegram.WebApp was already there (a cached load or native injection)
* loaded — the script loaded and defined window.Telegram.WebApp
* no-webapp — the script loaded (HTTP 200) but did not define window.Telegram.WebApp
* error — the script failed to load (telegram.org unreachable / blocked, a fast failure)
* timeout — the script neither loaded nor failed within the timeout (a blocked, hanging fetch)
*/
export type TelegramSdkOutcome = 'not-attempted' | 'present' | 'loaded' | 'no-webapp' | 'error' | 'timeout';
let sdkLoadOutcome: TelegramSdkOutcome = 'not-attempted';
/** telegramSdkOutcome returns how the last loadTelegramSDK attempt resolved (see TelegramSdkOutcome). */
export function telegramSdkOutcome(): TelegramSdkOutcome {
return sdkLoadOutcome;
}
/**
* loadTelegramSDK injects the official telegram-web-app.js and resolves true once
* window.Telegram.WebApp is available, or false if the script errors or does not load within
* timeoutMs. It is loaded dynamically — not a render-blocking <script> in index.html — so a network
* that blocks telegram.org (common where Telegram itself reaches users only over a proxy) cannot
* hang the page and strand the app or the launch-error screen. Resolves true immediately when the
* SDK is already present (a cached load, or a future native injection); a fast connection failure
* resolves via the error event without waiting out the timeout, so the timeout only bounds a true
* hang.
*/
export function loadTelegramSDK(timeoutMs: number): Promise<boolean> {
if (typeof document === 'undefined') return Promise.resolve(false);
if (webApp()) {
sdkLoadOutcome = 'present';
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
let done = false;
const finish = (outcome: TelegramSdkOutcome): void => {
if (done) return;
done = true;
sdkLoadOutcome = outcome;
resolve(outcome === 'loaded');
};
const timer = setTimeout(() => finish(webApp() ? 'loaded' : 'timeout'), timeoutMs);
const s = document.createElement('script');
s.src = sdkScriptSrc;
s.async = true;
s.onload = () => {
clearTimeout(timer);
finish(webApp() ? 'loaded' : 'no-webapp');
};
s.onerror = () => {
clearTimeout(timer);
finish('error');
};
document.head.appendChild(s);
});
}
/** /**
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to * telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false * it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
@@ -294,6 +224,66 @@ export function telegramHaptic(kind: Haptic): void {
else h.impactOccurred?.(kind); else h.impactOccurred?.(kind);
} }
/**
* isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
* Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
* Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
* mobile-only chrome such as the close guard and immersive fullscreen.
*/
function isMobilePlatform(): boolean {
const p = webApp()?.platform;
return p === 'ios' || p === 'android' || p === 'android_x';
}
/**
* telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
* but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones
* while staying a standard window on desktop (where the bot's full-size setting already fills
* the window). A no-op outside Telegram, on desktop, or on clients predating the method.
*/
export function telegramRequestFullscreen(): void {
if (isMobilePlatform()) webApp()?.requestFullscreen?.();
}
/**
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user swipes
* the Mini App closed — enabled during an active game so it is not lost by accident. The
* guard is only armed on mobile clients: on desktop, closing a window is deliberate and
* Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
* auto-save), so the confirmation is skipped there.
*/
export function telegramClosingConfirmation(on: boolean): void {
const w = webApp();
if (on) {
if (isMobilePlatform()) w?.enableClosingConfirmation?.();
} else {
w?.disableClosingConfirmation?.();
}
}
let backHandler: (() => void) | null = null;
/**
* telegramBackButton shows or hides Telegram's native header back button, wiring its
* click to onClick (replacing any previous handler). The app hides its own back chevron
* inside Telegram so only the native control shows.
*/
export function telegramBackButton(show: boolean, onClick?: () => void): void {
const b = webApp()?.BackButton;
if (!b) return;
if (backHandler) b.offClick?.(backHandler);
backHandler = null;
if (show) {
if (onClick) {
backHandler = onClick;
b.onClick?.(onClick);
}
b.show?.();
} else {
b.hide?.();
}
}
/** /**
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app * startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe. * launch button carries the deep-link there rather than in initDataUnsafe.
@@ -312,160 +302,6 @@ export function onTelegramPath(): boolean {
return location.pathname.startsWith('/telegram/'); return location.pathname.startsWith('/telegram/');
} }
/** hasLaunchFragment reports whether the URL fragment carries Telegram launch params (tgWebApp…),
* the form Telegram appends when opening a Mini App — so the SDK is loaded for a Mini App opened
* at the site root too, not only the /telegram/ path. */
export function hasLaunchFragment(): boolean {
if (typeof location === 'undefined') return false;
return location.hash.includes('tgWebApp');
}
// --- Launch diagnostics (the /telegram/ entry without sign-in data) ---
/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal
* the launch-error screen reports. Only field names are ever inspected, never their values. */
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
interface uaBrand {
brand: string;
version: string;
}
interface uaDataValue {
platform?: string;
mobile?: boolean;
brands?: uaBrand[];
}
/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android Telegram
* webview), or undefined where it is unavailable (iOS / Safari / Firefox). */
function uaData(): uaDataValue | undefined {
if (typeof navigator === 'undefined') return undefined;
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
}
/** launchFragmentData returns the raw tgWebAppData carried in the URL fragment (the form Telegram
* appends on launch), or '' when absent. With no SDK present a non-empty value means Telegram
* delivered the data but telegram-web-app.js never ran to parse it. */
function launchFragmentData(): string {
if (typeof location === 'undefined') return '';
const frag = location.hash.replace(/^#/, '');
if (!frag) return '';
try {
return new URLSearchParams(frag).get('tgWebAppData') ?? '';
} catch {
return '';
}
}
/** initDataFieldNames parses a Telegram initData (or raw fragment data) query string and returns
* only its field NAMES — never the values, since the hash / signature are auth material. */
function initDataFieldNames(raw: string): string[] {
if (!raw) return [];
try {
return [...new URLSearchParams(raw).keys()];
} catch {
return [];
}
}
/**
* TelegramDiag is a privacy-safe snapshot of why a Mini App launch lacked sign-in data, taken at
* the moment of failure and rendered on the launch-error screen so a stuck user can share it with
* the developer. It carries no secret values — only presence flags, client / OS identification and
* the field NAMES of the launch data, never the signed initData itself, and never an IP.
*/
export interface TelegramDiag {
/** Whether window.Telegram (the telegram-web-app.js script) is present at all. */
hasSDK: boolean;
/** Whether window.Telegram.WebApp is present. */
hasWebApp: boolean;
/** How the dynamic telegram-web-app.js load resolved (see TelegramSdkOutcome) — 'error' /
* 'timeout' mean telegram.org was unreachable, the prime suspect for an empty launch. */
sdkLoad: TelegramSdkOutcome;
/** Telegram's own platform string (ios | android | android_x | tdesktop | web | …), or ''. */
platform: string;
/** The Bot API version the client reports, or ''. */
version: string;
/** The length of WebApp.initData — 0 is the failure this screen reports. */
initDataLen: number;
/** Whether the URL fragment still carried tgWebAppData at launch; true with hasSDK false means
* Telegram delivered the data but the SDK script did not load to parse it. */
hashHadTgData: boolean;
/** The field names present in the launch data (from initData, or the raw fragment when the SDK
* left initData empty); values are never included. */
fieldsPresent: string[];
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
fieldsMissing: string[];
/** The OS / platform per User-Agent Client Hints (else navigator.platform), e.g. 'Android', ''. */
osPlatform: string;
/** Whether the client reports itself mobile per Client Hints: 'yes' | 'no' | '' (unknown). */
mobile: string;
/** The browser brands + major versions per Client Hints (Chromium only), or ''. */
browser: string;
/** The full User-Agent string — the catch-all that also carries the OS version and webview build. */
userAgent: string;
}
/**
* collectTelegramDiag captures a TelegramDiag snapshot of the current launch state. Call it at the
* point a /telegram/ launch is found to lack sign-in data, so the snapshot reflects that moment —
* sign-in data arriving late would otherwise mask the failure.
*/
export function collectTelegramDiag(): TelegramDiag {
const w = webApp();
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
const initData = w?.initData ?? '';
const fragData = initData ? '' : launchFragmentData();
const present = initDataFieldNames(initData || fragData);
const ua = uaData();
const navPlatform =
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
return {
hasSDK: sdk,
hasWebApp: !!w,
sdkLoad: sdkLoadOutcome,
platform: w?.platform ?? '',
version: w?.version ?? '',
initDataLen: initData.length,
hashHadTgData: fragData.length > 0,
fieldsPresent: present,
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
osPlatform: ua?.platform ?? navPlatform,
mobile: ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no',
browser: (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', '),
userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent,
};
}
/**
* 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,
* back-button state, UA). It feeds the hidden debug panel (components/DebugPanel), opened by tapping
* the header title ten times. No secrets: no initData values, no IP.
*/
export function telegramChromeDiag(): string {
const w = webApp();
if (!w) return 'no telegram';
const win = typeof window === 'undefined' ? undefined : window;
const scr = typeof screen === 'undefined' ? undefined : screen;
const vv = win?.visualViewport ?? undefined;
const bar = typeof document === 'undefined' ? null : (document.querySelector('.bar')?.getBoundingClientRect() ?? null);
const sa = w.safeAreaInset;
const csa = w.contentSafeAreaInset;
const n = (v: number | undefined): string => (v === undefined ? '—' : String(Math.round(v)));
return [
`platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`,
`isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`,
`inTG: ${insideTelegram()} sdkLoad: ${sdkLoadOutcome} backPresent: ${!!w.BackButton} backVisible: ${w.BackButton?.isVisible}`,
`innerH: ${n(win?.innerHeight)} outerH: ${n(win?.outerHeight)} screenH: ${n(scr?.height)} availH: ${n(scr?.availHeight)}`,
`screenY: ${n(win?.screenY)} vv.offTop: ${n(vv?.offsetTop)} vv.h: ${n(vv?.height)}`,
`tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`,
`safeArea T/B: ${n(sa?.top)}/${n(sa?.bottom)} contentSafe T/B: ${n(csa?.top)}/${n(csa?.bottom)}`,
`appHeader top/h: ${bar ? Math.round(bar.top) : '—'}/${bar ? Math.round(bar.height) : '—'}`,
].join('\n');
}
// --- Login Widget (web sign-in for account linking) --- // --- Login Widget (web sign-in for account linking) ---
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
-133
View File
@@ -1,133 +0,0 @@
<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.
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')
: '',
);
let retrying = $state(false);
async function retry(): Promise<void> {
if (retrying) return;
retrying = true;
try {
await retryTelegramLaunch();
} finally {
retrying = false;
}
}
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function share(): Promise<void> {
const r = await shareText(report, t('launch.errorTitle'));
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
if (r === 'copied') {
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => (copied = false), 1500);
}
}
</script>
{#if diag}
<div class="boot">
<div class="card">
<h1>{t('launch.errorTitle')}</h1>
<p class="msg">{t('launch.errorBody')}</p>
<pre class="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>
</div>
</div>
</div>
{/if}
<style>
.boot {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
background: var(--bg);
}
.card {
max-width: 32rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
text-align: center;
color: var(--text);
}
h1 {
margin: 0;
font-size: 1.1rem;
}
.msg {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.diag {
margin: 0;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.4;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.share,
.retry {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
.share {
background: var(--accent);
color: var(--accent-text);
}
.retry {
background: transparent;
color: var(--accent);
}
.retry:disabled {
opacity: 0.5;
}
</style>